feat(players): app Players library and circular NPC tokens on scenes
Add userData players/teams, scene npcTokens with hex-inscribed sizing, session scale synced to presentation, and Playwright e2e coverage. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { test as base, expect, _electron as electron, type ElectronApplication, type Page } from '@playwright/test';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const electronExecutable = require('electron') as string;
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const mainEntry = path.join(root, 'dist/main/index.cjs');
|
||||
const fixturePng = path.join(root, 'e2e/fixtures/sample.png');
|
||||
|
||||
const SAMPLE_PNG_B64 =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
|
||||
|
||||
export type ElectronFixtures = {
|
||||
electronApp: ElectronApplication;
|
||||
editorWindow: Page;
|
||||
userDataDir: string;
|
||||
sampleImagePath: string;
|
||||
};
|
||||
|
||||
async function ensureBuilt() {
|
||||
try {
|
||||
await fs.access(mainEntry);
|
||||
const editorHtml = await fs.readFile(path.join(root, 'dist/renderer/editor.html'), 'utf8');
|
||||
// Production Vite build uses relative asset URLs (required for Electron file://).
|
||||
if (editorHtml.includes('src="/assets/') || editorHtml.includes("src='/assets/")) {
|
||||
throw new Error('dev-base');
|
||||
}
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error && err.message === 'dev-base' ? 'dev-base' : 'missing';
|
||||
throw new Error(
|
||||
reason === 'dev-base'
|
||||
? 'dist/renderer was built with Vite base "/". Run "npm run build" (production) before e2e.'
|
||||
: `Missing ${mainEntry}. Run "npm run build" (or "npm run test:e2e:build") before e2e.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const test = base.extend<ElectronFixtures>({
|
||||
userDataDir: async ({}, use) => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-e2e-'));
|
||||
await use(dir);
|
||||
await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
||||
},
|
||||
|
||||
sampleImagePath: async ({}, use) => {
|
||||
await fs.mkdir(path.dirname(fixturePng), { recursive: true });
|
||||
await fs.writeFile(fixturePng, Buffer.from(SAMPLE_PNG_B64, 'base64'));
|
||||
await use(fixturePng);
|
||||
},
|
||||
|
||||
electronApp: async ({ userDataDir }, use) => {
|
||||
await ensureBuilt();
|
||||
const app = await electron.launch({
|
||||
executablePath: electronExecutable,
|
||||
// Launch the package root so app.getAppPath() is the repo (not dist/main).
|
||||
args: ['.', `--user-data-dir=${userDataDir}`],
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: 'production',
|
||||
DND_SKIP_LICENSE: '1',
|
||||
DND_SKIP_BOOT: '1',
|
||||
ELECTRON_DISABLE_SECURITY_WARNINGS: 'true',
|
||||
},
|
||||
});
|
||||
await use(app);
|
||||
try {
|
||||
await app.evaluate(({ app: electronApp }) => {
|
||||
electronApp.exit(0);
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
await Promise.race([
|
||||
app.close(),
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 3_000);
|
||||
}),
|
||||
]);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
|
||||
editorWindow: async ({ electronApp }, use) => {
|
||||
const page = await electronApp.firstWindow();
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await expect(page.getByTestId('players-header-btn')).toBeVisible({ timeout: 60_000 });
|
||||
await use(page);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
|
||||
/** Stub native open-dialog to return a fixed image path. */
|
||||
export async function stubOpenImageDialog(app: ElectronApplication, filePath: string) {
|
||||
await app.evaluate(async ({ dialog }, chosen) => {
|
||||
dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [chosen] });
|
||||
}, filePath);
|
||||
}
|
||||
|
||||
export async function invokeInRenderer<T>(
|
||||
page: Page,
|
||||
channel: string,
|
||||
payload: unknown = {},
|
||||
): Promise<T> {
|
||||
return page.evaluate(
|
||||
async ({ channel: ch, payload: pl }) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (window as any).dnd.invoke(ch, pl);
|
||||
},
|
||||
{ channel, payload },
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user