diff --git a/packages/scenarios-stage-tamagotchi-browser/README.md b/packages/scenarios-stage-tamagotchi-browser/README.md index f8855bc16..85a245ab0 100644 --- a/packages/scenarios-stage-tamagotchi-browser/README.md +++ b/packages/scenarios-stage-tamagotchi-browser/README.md @@ -8,7 +8,7 @@ This package is the tamagotchi browser composition layer. It contains: - the Vite/Vue scene app - the scene composition components and capture roots -- the `capture` script that exports final PNGs through `@proj-airi/vishot-runner-browser` +- the `capture` script that exports final browser captures through `@proj-airi/vishot-runner-browser` It expects raw business screenshots to exist in `artifacts/raw` before final export runs. @@ -20,22 +20,31 @@ It expects raw business screenshots to exist in `artifacts/raw` before final exp ```bash pnpm -F @proj-airi/stage-tamagotchi build -pnpm -F @proj-airi/vishot-runner-electron capture -- packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-controls-settings-chat-websocket.ts --output-dir packages/scenarios-stage-tamagotchi-browser/artifacts/raw +pnpm -F @proj-airi/vishot-runner-electron capture -- ../../packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-controls-settings-chat-websocket.ts --output-dir ../../packages/scenarios-stage-tamagotchi-browser/artifacts/raw pnpm -F @proj-airi/scenarios-stage-tamagotchi-browser capture ``` Expected raw inputs: -- `artifacts/raw/02-chat-window.png` -- `artifacts/raw/03-websocket-settings.png` +- `artifacts/raw/00-stage-tamagotchi.png` +- `artifacts/raw/04-websocket-settings.png` Current composed outputs: - `artifacts/final/intro-chat-window.png` - `artifacts/final/intro-websocket-settings.png` +Optional AVIF export: + +```bash +pnpm -F @proj-airi/scenarios-stage-tamagotchi-browser capture -- --format avif +``` + +The browser capture flow still starts from PNG because that is the Playwright screenshot format. The package's `capture` script can opt into an AVIF post-processing step through Vishot's `imageTransformers` hook. + ## Notes - The current scene app renders `src/scenes/intro-manual-scene.vue`. - Final export depends on the browser scene reaching the `__SCENARIO_CAPTURE_READY__` flag after its raw images load. - If raw capture is missing or stale, final export will fail or render outdated assets. +- If you enable AVIF output, the final artifact filenames switch from `.png` to `.avif` because the transformer replaces the emitted PNG files after capture. diff --git a/packages/scenarios-stage-tamagotchi-browser/artifacts/final/intro-chat-window.avif b/packages/scenarios-stage-tamagotchi-browser/artifacts/final/intro-chat-window.avif new file mode 100644 index 000000000..16889d0ca Binary files /dev/null and b/packages/scenarios-stage-tamagotchi-browser/artifacts/final/intro-chat-window.avif differ diff --git a/packages/scenarios-stage-tamagotchi-browser/artifacts/raw/00-stage-tamagotchi.avif b/packages/scenarios-stage-tamagotchi-browser/artifacts/raw/00-stage-tamagotchi.avif new file mode 100644 index 000000000..94ca71af1 Binary files /dev/null and b/packages/scenarios-stage-tamagotchi-browser/artifacts/raw/00-stage-tamagotchi.avif differ diff --git a/packages/scenarios-stage-tamagotchi-browser/artifacts/raw/01-controls-island-expanded.avif b/packages/scenarios-stage-tamagotchi-browser/artifacts/raw/01-controls-island-expanded.avif new file mode 100644 index 000000000..25ac17345 Binary files /dev/null and b/packages/scenarios-stage-tamagotchi-browser/artifacts/raw/01-controls-island-expanded.avif differ diff --git a/packages/scenarios-stage-tamagotchi-browser/artifacts/raw/02-settings-window.avif b/packages/scenarios-stage-tamagotchi-browser/artifacts/raw/02-settings-window.avif new file mode 100644 index 000000000..bece586f0 Binary files /dev/null and b/packages/scenarios-stage-tamagotchi-browser/artifacts/raw/02-settings-window.avif differ diff --git a/packages/scenarios-stage-tamagotchi-browser/artifacts/raw/03-websocket-settings.avif b/packages/scenarios-stage-tamagotchi-browser/artifacts/raw/03-websocket-settings.avif new file mode 100644 index 000000000..f7c0ec0c2 Binary files /dev/null and b/packages/scenarios-stage-tamagotchi-browser/artifacts/raw/03-websocket-settings.avif differ diff --git a/packages/scenarios-stage-tamagotchi-browser/package.json b/packages/scenarios-stage-tamagotchi-browser/package.json index 8dccd8292..f45821cec 100644 --- a/packages/scenarios-stage-tamagotchi-browser/package.json +++ b/packages/scenarios-stage-tamagotchi-browser/package.json @@ -23,6 +23,7 @@ }, "dependencies": { "@moeru/std": "catalog:", + "@napi-rs/image": "^1.12.0", "@proj-airi/vishot-runner-browser": "workspace:^", "@proj-airi/vishot-runtime": "workspace:^", "date-fns": "catalog:", diff --git a/packages/scenarios-stage-tamagotchi-browser/scripts/capture.ts b/packages/scenarios-stage-tamagotchi-browser/scripts/capture.ts index ebf5567a4..844aacc37 100644 --- a/packages/scenarios-stage-tamagotchi-browser/scripts/capture.ts +++ b/packages/scenarios-stage-tamagotchi-browser/scripts/capture.ts @@ -1,13 +1,41 @@ +import type { ArtifactTransformer } from '@proj-airi/vishot-runner-browser' + import path from 'node:path' -import { cwd } from 'node:process' +import { readFile, rm, writeFile } from 'node:fs/promises' +import { argv, cwd } from 'node:process' +import { Transformer } from '@napi-rs/image' import { captureBrowserRoots } from '@proj-airi/vishot-runner-browser' const sceneAppRoot = path.resolve(cwd()) const outputDir = path.resolve(sceneAppRoot, 'artifacts', 'final') +const formatFlagIndex = argv.findIndex(arg => arg === '--format') +const requestedFormat = formatFlagIndex >= 0 ? argv[formatFlagIndex + 1] : 'png' + +const avifTransformer: ArtifactTransformer = async (artifact) => { + // eslint-disable-next-line e18e/prefer-static-regex + const derivedFilePath = artifact.filePath.replace(/\.png$/i, '.avif') + const avifBuffer = await new Transformer(await readFile(artifact.filePath)).avif() + + await writeFile(derivedFilePath, avifBuffer) + await rm(artifact.filePath, { force: true }) + + return { + ...artifact, + filePath: derivedFilePath, + format: 'avif', + } +} + +if (!['png', 'avif'].includes(requestedFormat)) { + throw new Error(`Unsupported capture format "${requestedFormat}". Expected "png" or "avif".`) +} await captureBrowserRoots({ + imageTransformers: requestedFormat === 'avif' + ? [avifTransformer] + : undefined, sceneAppRoot, routePath: '/', outputDir, diff --git a/packages/scenarios-stage-tamagotchi-browser/src/scenes/intro-manual-scene.vue b/packages/scenarios-stage-tamagotchi-browser/src/scenes/intro-manual-scene.vue index 8652b6b8b..5def139c4 100644 --- a/packages/scenarios-stage-tamagotchi-browser/src/scenes/intro-manual-scene.vue +++ b/packages/scenarios-stage-tamagotchi-browser/src/scenes/intro-manual-scene.vue @@ -3,8 +3,8 @@ import { markScenarioReady, resetScenarioReady } from '@proj-airi/vishot-runtime import { ScenarioCanvas, ScenarioCaptureRoot } from '@proj-airi/vishot-runtime/vue' import { onMounted } from 'vue' -import stageShot from '../../artifacts/raw/00-stage-tamagotchi.png' -import websocketSettingsShot from '../../artifacts/raw/03-websocket-settings.png' +import stageShot from '../../artifacts/raw/00-stage-tamagotchi.avif' +import websocketSettingsShot from '../../artifacts/raw/03-websocket-settings.avif' import Icon from '../components/icon.vue' import { PlatformRoot } from '../components/platforms/macos-26' diff --git a/packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-controls-settings-chat-websocket.ts b/packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-controls-settings-chat-websocket.ts index b677fa2a8..250355871 100644 --- a/packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-controls-settings-chat-websocket.ts +++ b/packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-controls-settings-chat-websocket.ts @@ -10,7 +10,7 @@ export default defineScenario({ await sleep(500) await controlsIsland.expand(mainWindow.page) - await sleep(1000) + await sleep(250) await capture('01-controls-island-expanded', mainWindow.page) const settingsWindowSnapshot = await controlsIsland.openSettings(mainWindow.page) @@ -18,7 +18,9 @@ export default defineScenario({ await sleep(1000) await capture('02-settings-window', settingsWindowSnapshot.page) - await mainWindow.page.bringToFront() + await settingsWindowSnapshot.page.bringToFront() + await sleep(500) + const websocketSettingsPage = await settingsWindow.goToConnection(settingsWindowSnapshot.page) await websocketSettingsPage.getByText('WebSocket Server Address').waitFor({ state: 'visible' }) await sleep(1000) diff --git a/packages/vishot-runner-browser/README.md b/packages/vishot-runner-browser/README.md index f6f571a6a..d4f7be818 100644 --- a/packages/vishot-runner-browser/README.md +++ b/packages/vishot-runner-browser/README.md @@ -11,6 +11,7 @@ This package is the browser capture engine used by scene packages such as `@proj - the `capture` CLI entry in `src/cli/capture.ts` - Vite dev-server startup for scene packages - Playwright-driven export of each `data-scenario-capture-root` element as its own PNG +- an optional `imageTransformers` pipeline for converting emitted PNG files into other final image artifacts such as AVIF ## Usage @@ -29,17 +30,24 @@ import path from 'node:path' import { captureBrowserRoots } from '@proj-airi/vishot-runner-browser' const sceneAppRoot = path.resolve(process.cwd()) +const requestedFormat = 'avif' await captureBrowserRoots({ sceneAppRoot, routePath: '/', outputDir: path.resolve(sceneAppRoot, 'artifacts', 'final'), + imageTransformers: requestedFormat === 'avif' + ? [avifTransformer] + : undefined, }) ``` +The capture primitive is still PNG because Playwright screenshots write PNG files. If you want AVIF, WebP, or optimized PNG outputs, add an `imageTransformers` pipeline that rewrites the emitted files after capture. + ## Notes - `captureBrowserRoots()` accepts either `sceneAppRoot` or `baseUrl`. - Scene packages mark readiness through `window.__SCENARIO_CAPTURE_READY__`. - The CLI treats `` as the scene package root and captures the default `/` route. - The parser accepts repeated `--root` flags for named capture roots. +- If an image transformer returns a different output file, Vishot treats that derived file as authoritative and removes the intermediate PNG only after the full batch validates successfully. diff --git a/packages/vishot-runner-browser/src/runtime/capture.ts b/packages/vishot-runner-browser/src/runtime/capture.ts index 431fa0bba..38d3216c7 100644 --- a/packages/vishot-runner-browser/src/runtime/capture.ts +++ b/packages/vishot-runner-browser/src/runtime/capture.ts @@ -68,7 +68,11 @@ function assertBrowserImageArtifact(artifact: VishotArtifact): void { async function applyBrowserImageTransformers( artifact: VishotArtifact, transformers: BrowserCaptureRequest['imageTransformers'], -): Promise { +): Promise<{ + artifacts: VishotArtifact[] + shouldRemoveSourceFile: boolean + sourceFilePath: string +}> { const sourceFilePath = artifact.filePath let currentArtifacts: VishotArtifact[] = [artifact] @@ -92,11 +96,11 @@ async function applyBrowserImageTransformers( assertUniqueArtifactFilePaths(currentArtifacts) await assertArtifactFilesExist(currentArtifacts) - if (currentArtifacts.length > 0 && currentArtifacts.every(artifact => artifact.filePath !== sourceFilePath)) { - await rm(sourceFilePath, { force: true }) + return { + artifacts: currentArtifacts, + shouldRemoveSourceFile: currentArtifacts.length > 0 && currentArtifacts.every(artifact => artifact.filePath !== sourceFilePath), + sourceFilePath, } - - return currentArtifacts } async function resolveBaseUrl(request: BrowserCaptureRequest): Promise<{ baseUrl: string, closeServer?: () => Promise }> { @@ -145,17 +149,32 @@ export async function captureBrowserRoots(request: BrowserCaptureRequest): Promi assertUniqueCaptureFilePaths(rootNames) const artifacts: VishotArtifact[] = [] + const cleanupTargets: Array<{ + shouldRemoveSourceFile: boolean + sourceFilePath: string + }> = [] for (const rootName of rootNames) { const artifact = await captureRoot(page, request.outputDir, rootName) - artifacts.push(...await applyBrowserImageTransformers(artifact, request.imageTransformers)) + const transformed = await applyBrowserImageTransformers(artifact, request.imageTransformers) + artifacts.push(...transformed.artifacts) + cleanupTargets.push(transformed) } assertUniqueArtifactFilePaths(artifacts) + + for (const cleanupTarget of cleanupTargets) { + if (cleanupTarget.shouldRemoveSourceFile) { + await rm(cleanupTarget.sourceFilePath, { force: true }) + } + } + + await context.close() + return artifacts } finally { - await context.close() + await context.close().catch(() => {}) } } finally { diff --git a/packages/vishot-runner-browser/src/runtime/integration.test.ts b/packages/vishot-runner-browser/src/runtime/integration.test.ts index 6747d97eb..113e12e7d 100644 --- a/packages/vishot-runner-browser/src/runtime/integration.test.ts +++ b/packages/vishot-runner-browser/src/runtime/integration.test.ts @@ -1,8 +1,8 @@ import path from 'node:path' -import { access, mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { beforeAll, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' interface FakeLocator { evaluateAll: (callback: (elements: Array<{ getAttribute: (name: string) => string | null }>) => T) => Promise @@ -110,18 +110,16 @@ function createFixturePage(html: string): FakePage { } let captureBrowserRoots: typeof import('./capture').captureBrowserRoots +const fixtureRoots = new Set() vi.mock('playwright', () => { return { chromium: { launch: vi.fn(async () => { - let page: FakePage | undefined - return { newContext: async () => ({ newPage: async () => { - page = createFixturePage('') - return page + return createFixturePage('') }, close: async () => {}, }), @@ -138,6 +136,7 @@ beforeAll(async () => { async function createViteSceneFixture(): Promise { const rootDir = await mkdtemp(path.join(process.cwd(), '.tmp-scene-')) + fixtureRoots.add(rootDir) await mkdir(path.join(rootDir, 'artifacts'), { recursive: true }) @@ -182,6 +181,23 @@ export default defineConfig({ return rootDir } +async function cleanupFixtureRoots(): Promise { + await Promise.allSettled( + Array.from(fixtureRoots, async (rootDir) => { + await rm(rootDir, { force: true, recursive: true }) + fixtureRoots.delete(rootDir) + }), + ) +} + +afterEach(async () => { + await cleanupFixtureRoots() +}) + +afterAll(async () => { + await cleanupFixtureRoots() +}) + describe('captureBrowserRoots', () => { it('captures all roots from a vite-served scene app', async () => { const sceneAppRoot = await createViteSceneFixture() @@ -248,14 +264,14 @@ describe('captureBrowserRoots', () => { await expect(access(path.join(outputDir, 'intro-websocket-settings.avif'))).resolves.toBeUndefined() }, 120000) - it('rejects non-image transformer outputs in the browser image pipeline', async () => { + it('rejects non-browser-final transformer outputs in the browser image pipeline', async () => { const sceneAppRoot = await createViteSceneFixture() const outputDir = path.join(sceneAppRoot, 'artifacts', 'final-test') await expect(captureBrowserRoots({ imageTransformers: [async artifact => ({ ...artifact, - kind: 'video', + stage: 'electron-raw', })], sceneAppRoot, routePath: '/', @@ -326,5 +342,7 @@ describe('captureBrowserRoots', () => { routePath: '/', outputDir, })).rejects.toThrow('both resolve to') + + await expect(access(path.join(outputDir, 'intro-chat-window.png'))).resolves.toBeUndefined() }, 120000) }) diff --git a/packages/vishot-runner-browser/src/runtime/types.ts b/packages/vishot-runner-browser/src/runtime/types.ts index 7e38be24e..0c0ef934f 100644 --- a/packages/vishot-runner-browser/src/runtime/types.ts +++ b/packages/vishot-runner-browser/src/runtime/types.ts @@ -17,8 +17,7 @@ export interface BrowserCaptureRequest { deviceScaleFactor?: number } } - -export type VishotArtifactKind = 'image' | 'video' +export type VishotArtifactKind = 'image' export type VishotArtifactStage = 'browser-final' | 'electron-raw' export interface VishotArtifact { diff --git a/packages/vishot-runner-electron/README.md b/packages/vishot-runner-electron/README.md index df71f03fe..a88aa03c4 100644 --- a/packages/vishot-runner-electron/README.md +++ b/packages/vishot-runner-electron/README.md @@ -9,6 +9,7 @@ This package is the Electron capture runner. It provides: - a runtime surface in `src/index.ts` - the `capture` CLI in `src/cli/capture.ts` - the `defineScenario()` authoring helper for scenario modules +- a typed raw-artifact surface for screenshot outputs - reusable helpers for the controls island, settings window, dialogs, drawers, and stage windows This package stops at raw business screenshots. It does not own the scenario modules themselves; those live in `@proj-airi/scenarios-stage-tamagotchi-electron`. @@ -17,16 +18,24 @@ This package stops at raw business screenshots. It does not own the scenario mod ```bash pnpm -F @proj-airi/stage-tamagotchi build -pnpm -F @proj-airi/vishot-runner-electron capture -- packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-controls-settings-chat-websocket.ts --output-dir packages/scenarios-stage-tamagotchi-browser/artifacts/raw +pnpm -F @proj-airi/vishot-runner-electron capture -- ../../packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-controls-settings-chat-websocket.ts --output-dir ../../packages/scenarios-stage-tamagotchi-browser/artifacts/raw +``` + +To emit AVIF files instead of PNG files: + +```bash +pnpm -F @proj-airi/vishot-runner-electron capture -- ../../packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-controls-settings-chat-websocket.ts --output-dir ../../packages/scenarios-stage-tamagotchi-browser/artifacts/raw --format avif ``` This writes the raw inputs consumed by the browser scene package: -- `packages/scenarios-stage-tamagotchi-browser/artifacts/raw/00-controls-island-expanded.png` -- `packages/scenarios-stage-tamagotchi-browser/artifacts/raw/01-settings-window.png` -- `packages/scenarios-stage-tamagotchi-browser/artifacts/raw/02-chat-window.png` +- `packages/scenarios-stage-tamagotchi-browser/artifacts/raw/00-stage-tamagotchi.png` +- `packages/scenarios-stage-tamagotchi-browser/artifacts/raw/01-controls-island-expanded.png` +- `packages/scenarios-stage-tamagotchi-browser/artifacts/raw/02-settings-window.png` - `packages/scenarios-stage-tamagotchi-browser/artifacts/raw/03-websocket-settings.png` +If you pass `--format avif`, the same capture names are emitted as `.avif` files instead. + Then export the composed browser assets: ```bash @@ -41,7 +50,7 @@ To verify the controls-island hearing button specifically: ```bash pnpm -F @proj-airi/stage-tamagotchi build -pnpm -F @proj-airi/vishot-runner-electron capture -- packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-hearing-dialog.ts --output-dir ./artifacts/hearing-demo +pnpm -F @proj-airi/vishot-runner-electron capture -- ../../packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-hearing-dialog.ts --output-dir ./artifacts/hearing-demo ``` Expected file: @@ -120,3 +129,5 @@ It does not open the settings window from the main window for you. The intended - Importing `@proj-airi/vishot-runner-electron` resolves to `src/index.ts` via the package export surface. - The package is no longer a Playwright test suite package. - Final composed exports live in `packages/scenarios-stage-tamagotchi-browser/artifacts/final`, not this package. +- Screenshot capture now returns typed `image` artifacts and can run transformer hooks before those raw files are handed to downstream consumers. +- The CLI supports `--format png|avif`; AVIF remains an opt-in post-processing step on top of the raw PNG screenshot primitive. diff --git a/packages/vishot-runner-electron/package.json b/packages/vishot-runner-electron/package.json index 4be9ae945..bdddd7860 100644 --- a/packages/vishot-runner-electron/package.json +++ b/packages/vishot-runner-electron/package.json @@ -17,6 +17,7 @@ }, "devDependencies": { "@moeru/std": "catalog:", + "@napi-rs/image": "catalog:", "meow": "catalog:", "playwright": "^1.56.1" } diff --git a/packages/vishot-runner-electron/src/cli/capture.test.ts b/packages/vishot-runner-electron/src/cli/capture.test.ts index ea58e9c2e..275b22420 100644 --- a/packages/vishot-runner-electron/src/cli/capture.test.ts +++ b/packages/vishot-runner-electron/src/cli/capture.test.ts @@ -11,6 +11,7 @@ describe('parseCaptureCliArguments', () => { ])).toEqual({ scenarioPath: 'packages/scenarios-stage-tamagotchi-electron/src/scenarios/settings-connection.ts', outputDir: './artifacts/manual-run', + format: 'png', }) }) @@ -22,6 +23,7 @@ describe('parseCaptureCliArguments', () => { ])).toEqual({ scenarioPath: 'packages/scenarios-stage-tamagotchi-electron/src/scenarios/settings-connection.ts', outputDir: './artifacts/manual-run', + format: 'png', }) }) @@ -32,6 +34,21 @@ describe('parseCaptureCliArguments', () => { ])).toEqual({ scenarioPath: 'packages/scenarios-stage-tamagotchi-electron/src/scenarios/settings-connection.ts', outputDir: './artifacts/manual-run', + format: 'png', + }) + }) + + it('accepts an optional --format flag', () => { + expect(parseCaptureCliArguments([ + 'packages/scenarios-stage-tamagotchi-electron/src/scenarios/settings-connection.ts', + '--output-dir', + './artifacts/manual-run', + '--format', + 'avif', + ])).toEqual({ + scenarioPath: 'packages/scenarios-stage-tamagotchi-electron/src/scenarios/settings-connection.ts', + outputDir: './artifacts/manual-run', + format: 'avif', }) }) @@ -48,6 +65,16 @@ describe('parseCaptureCliArguments', () => { ])).toThrow('Usage: capture --output-dir ') }) + it('rejects unsupported output formats', () => { + expect(() => parseCaptureCliArguments([ + 'packages/scenarios-stage-tamagotchi-electron/src/scenarios/settings-connection.ts', + '--output-dir', + './artifacts/manual-run', + '--format', + 'webp', + ])).toThrow('Unsupported capture format "webp". Expected "png" or "avif".') + }) + it('rejects extra positional arguments', () => { expect(() => parseCaptureCliArguments([ 'packages/scenarios-stage-tamagotchi-electron/src/scenarios/settings-connection.ts', diff --git a/packages/vishot-runner-electron/src/cli/capture.ts b/packages/vishot-runner-electron/src/cli/capture.ts index ccd8ebe21..76edc385a 100644 --- a/packages/vishot-runner-electron/src/cli/capture.ts +++ b/packages/vishot-runner-electron/src/cli/capture.ts @@ -1,21 +1,27 @@ +import type { ArtifactTransformer } from '../runtime/types' + import path from 'node:path' import process from 'node:process' -import { mkdir } from 'node:fs/promises' +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import meow from 'meow' import { errorMessageFrom } from '@moeru/std' +import { Transformer } from '@napi-rs/image' import { _electron as electron } from 'playwright' import { createScenarioContext } from '../runtime/context' import { loadScenarioModule } from '../runtime/load-scenario' import { resolveElectronAppInfo } from '../utils/app-path' +type CaptureFormat = 'png' | 'avif' + interface CaptureCliArguments { scenarioPath: string outputDir: string + format: CaptureFormat } const captureHelpText = ` @@ -26,6 +32,7 @@ const captureHelpText = ` Options --output-dir, -o Directory to write PNG screenshots into + --format Output format: png or avif Examples $ capture packages/scenarios-stage-tamagotchi-electron/src/scenarios/settings-connection.ts --output-dir ./artifacts/manual-run @@ -46,6 +53,35 @@ function isDirectExecution(): boolean { return path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) } +function parseCaptureFormat(format: string | undefined): CaptureFormat { + if (format === undefined || format.length === 0) { + return 'png' + } + + if (format === 'png' || format === 'avif') { + return format + } + + throw new Error(`Unsupported capture format "${format}". Expected "png" or "avif".`) +} + +function createAvifTransformer(): ArtifactTransformer { + return async (artifact) => { + // eslint-disable-next-line e18e/prefer-static-regex + const derivedFilePath = artifact.filePath.replace(/\.png$/i, '.avif') + const avifBuffer = await new Transformer(await readFile(artifact.filePath)).avif() + + await writeFile(derivedFilePath, avifBuffer) + await rm(artifact.filePath, { force: true }) + + return { + ...artifact, + filePath: derivedFilePath, + format: 'avif', + } + } +} + export function parseCaptureCliArguments(argv: string[]): CaptureCliArguments { const cli = meow(captureHelpText, { argv: normalizeCliArgv(argv), @@ -55,6 +91,9 @@ export function parseCaptureCliArguments(argv: string[]): CaptureCliArguments { shortFlag: 'o', type: 'string', }, + format: { + type: 'string', + }, }, }) @@ -67,11 +106,12 @@ export function parseCaptureCliArguments(argv: string[]): CaptureCliArguments { return { scenarioPath: cli.input[0], outputDir: cli.flags.outputDir, + format: parseCaptureFormat(cli.flags.format), } } async function main(): Promise { - const { scenarioPath, outputDir } = parseCaptureCliArguments(process.argv.slice(2)) + const { scenarioPath, outputDir, format } = parseCaptureCliArguments(process.argv.slice(2)) const resolvedOutputDir = path.resolve(process.cwd(), outputDir) await mkdir(resolvedOutputDir, { recursive: true }) @@ -87,7 +127,15 @@ async function main(): Promise { }) try { - const context = createScenarioContext(electronApp, resolvedOutputDir) + const context = createScenarioContext( + electronApp, + resolvedOutputDir, + format === 'avif' + ? { + transformers: [createAvifTransformer()], + } + : undefined, + ) await loadedScenario.scenario.run(context) } finally { diff --git a/packages/vishot-runner-electron/src/index.ts b/packages/vishot-runner-electron/src/index.ts index c47b38286..4cbb7a6e4 100644 --- a/packages/vishot-runner-electron/src/index.ts +++ b/packages/vishot-runner-electron/src/index.ts @@ -1,9 +1,11 @@ +export { applyArtifactTransformers, createImageArtifact } from './runtime/artifacts' export { capturePage } from './runtime/capture' export { createScenarioContext } from './runtime/context' export { defineScenario } from './runtime/define-scenario' export { loadScenarioModule } from './runtime/load-scenario' export type { LoadedScenarioModule } from './runtime/load-scenario' export type { + ArtifactTransformer, CaptureOptions, ControlsIslandApi, DialogsApi, @@ -12,6 +14,9 @@ export type { ScenarioContext, SettingsWindowApi, StageWindowsApi, + VishotArtifact, + VishotArtifactKind, + VishotArtifactStage, } from './runtime/types' export { resolveElectronAppInfo } from './utils/app-path' export type { ElectronAppInfo } from './utils/app-path' diff --git a/packages/vishot-runner-electron/src/runtime/artifacts.test.ts b/packages/vishot-runner-electron/src/runtime/artifacts.test.ts new file mode 100644 index 000000000..8a606e009 --- /dev/null +++ b/packages/vishot-runner-electron/src/runtime/artifacts.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from 'vitest' + +import { applyArtifactTransformers, createImageArtifact } from './artifacts' + +describe('createImageArtifact', () => { + it('marks Electron screenshot outputs as electron-raw image artifacts', () => { + expect(createImageArtifact({ + artifactName: 'settings-window', + filePath: '/tmp/settings-window.png', + stage: 'electron-raw', + })).toEqual({ + artifactName: 'settings-window', + filePath: '/tmp/settings-window.png', + format: 'png', + kind: 'image', + metadata: undefined, + stage: 'electron-raw', + }) + }) +}) + +describe('applyArtifactTransformers', () => { + it('returns the original artifact when no transformers are configured', async () => { + const artifact = createImageArtifact({ + artifactName: 'settings-window', + filePath: '/tmp/settings-window.png', + stage: 'electron-raw', + }) + + await expect(applyArtifactTransformers(artifact, [])).resolves.toEqual([artifact]) + }) + + it('passes transformed artifacts through in order', async () => { + const first = vi.fn(async artifact => ({ + ...artifact, + filePath: '/tmp/settings-window.avif', + format: 'avif', + })) + const second = vi.fn(async artifact => ({ + ...artifact, + metadata: { optimized: true }, + })) + + const artifact = createImageArtifact({ + artifactName: 'settings-window', + filePath: '/tmp/settings-window.png', + stage: 'electron-raw', + }) + + const result = await applyArtifactTransformers(artifact, [first, second]) + + expect(first).toHaveBeenCalledTimes(1) + expect(second).toHaveBeenCalledTimes(1) + expect(result).toEqual([ + expect.objectContaining({ + filePath: '/tmp/settings-window.avif', + format: 'avif', + metadata: { optimized: true }, + }), + ]) + }) +}) diff --git a/packages/vishot-runner-electron/src/runtime/artifacts.ts b/packages/vishot-runner-electron/src/runtime/artifacts.ts new file mode 100644 index 000000000..ade096b2b --- /dev/null +++ b/packages/vishot-runner-electron/src/runtime/artifacts.ts @@ -0,0 +1,37 @@ +import type { ArtifactTransformer, VishotArtifact, VishotArtifactStage } from './types' + +export function createImageArtifact(options: { + artifactName: string + filePath: string + stage: VishotArtifactStage + metadata?: Record +}): VishotArtifact { + return { + artifactName: options.artifactName, + filePath: options.filePath, + format: 'png', + kind: 'image', + metadata: options.metadata, + stage: options.stage, + } +} + +export async function applyArtifactTransformers( + artifact: VishotArtifact, + transformers: ArtifactTransformer[] | undefined, +): Promise { + let currentArtifacts: VishotArtifact[] = [artifact] + + for (const transformer of transformers ?? []) { + const nextArtifacts: VishotArtifact[] = [] + + for (const currentArtifact of currentArtifacts) { + const transformed = await transformer(currentArtifact) + nextArtifacts.push(...(Array.isArray(transformed) ? transformed : [transformed])) + } + + currentArtifacts = nextArtifacts + } + + return currentArtifacts +} diff --git a/packages/vishot-runner-electron/src/runtime/capture.ts b/packages/vishot-runner-electron/src/runtime/capture.ts index f486a3b5f..945543725 100644 --- a/packages/vishot-runner-electron/src/runtime/capture.ts +++ b/packages/vishot-runner-electron/src/runtime/capture.ts @@ -1,11 +1,13 @@ import type { Page } from 'playwright' -import type { CaptureOptions } from './types' +import type { CaptureOptions, VishotArtifact } from './types' import path from 'node:path' import { mkdir } from 'node:fs/promises' +import { applyArtifactTransformers, createImageArtifact } from './artifacts' + const nonFilenameCharactersPattern = /[^a-z0-9-_]+/g const edgeDashPattern = /^-+|-+$/g @@ -19,7 +21,12 @@ function sanitizeCaptureName(name: string): string { return sanitized.length > 0 ? sanitized : 'capture' } -export async function capturePage(outputDir: string, name: string, page: Page, options?: CaptureOptions): Promise { +export async function capturePage( + outputDir: string, + name: string, + page: Page, + options?: CaptureOptions, +): Promise { const filePath = path.resolve(outputDir, `${sanitizeCaptureName(name)}.png`) await mkdir(outputDir, { recursive: true }) @@ -29,5 +36,12 @@ export async function capturePage(outputDir: string, name: string, page: Page, o path: filePath, }) - return filePath + return applyArtifactTransformers( + createImageArtifact({ + artifactName: name, + filePath, + stage: 'electron-raw', + }), + options?.transformers, + ) } diff --git a/packages/vishot-runner-electron/src/runtime/context.test.ts b/packages/vishot-runner-electron/src/runtime/context.test.ts new file mode 100644 index 000000000..dd51af08d --- /dev/null +++ b/packages/vishot-runner-electron/src/runtime/context.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createScenarioContext } from './context' + +vi.mock('./capture', () => { + return { + capturePage: vi.fn(async (_outputDir, name, _page, options) => [{ name, options }]), + } +}) + +describe('createScenarioContext', () => { + it('merges default transformers with per-capture transformers', async () => { + const { capturePage } = await import('./capture') + const defaultTransformer = vi.fn(async artifact => artifact) + const perCaptureTransformer = vi.fn(async artifact => artifact) + const electronApp = {} as never + const page = {} as never + const context = createScenarioContext(electronApp, '/tmp/output', { + transformers: [defaultTransformer], + }) + + await context.capture('settings-window', page, { + fullPage: true, + transformers: [perCaptureTransformer], + }) + + expect(capturePage).toHaveBeenCalledWith( + '/tmp/output', + 'settings-window', + page, + expect.objectContaining({ + fullPage: true, + transformers: [defaultTransformer, perCaptureTransformer], + }), + ) + }) +}) diff --git a/packages/vishot-runner-electron/src/runtime/context.ts b/packages/vishot-runner-electron/src/runtime/context.ts index f2f015134..eac2c77b5 100644 --- a/packages/vishot-runner-electron/src/runtime/context.ts +++ b/packages/vishot-runner-electron/src/runtime/context.ts @@ -8,12 +8,32 @@ import { goToSettingsConnectionPage } from '../utils/settings' import { waitForStageWindow } from '../utils/windows' import { capturePage } from './capture' -export function createScenarioContext(electronApp: ElectronApplication, outputDir: string): ScenarioContext { +function mergeCaptureOptions(defaultOptions?: CaptureOptions, options?: CaptureOptions): CaptureOptions | undefined { + const transformers = [ + ...(defaultOptions?.transformers ?? []), + ...(options?.transformers ?? []), + ] + + if (!defaultOptions && !options) { + return undefined + } + + return { + fullPage: options?.fullPage ?? defaultOptions?.fullPage, + transformers: transformers.length > 0 ? transformers : undefined, + } +} + +export function createScenarioContext( + electronApp: ElectronApplication, + outputDir: string, + defaultCaptureOptions?: CaptureOptions, +): ScenarioContext { return { electronApp, outputDir, capture(name: string, page: Page, options?: CaptureOptions) { - return capturePage(outputDir, name, page, options) + return capturePage(outputDir, name, page, mergeCaptureOptions(defaultCaptureOptions, options)) }, stageWindows: { waitFor(name, timeout) { diff --git a/packages/vishot-runner-electron/src/runtime/types.ts b/packages/vishot-runner-electron/src/runtime/types.ts index 7466ab4f7..b237c2f09 100644 --- a/packages/vishot-runner-electron/src/runtime/types.ts +++ b/packages/vishot-runner-electron/src/runtime/types.ts @@ -2,8 +2,25 @@ import type { ElectronApplication, Page } from 'playwright' import type { StageWindowName, StageWindowSnapshot } from '../utils/windows' +export type VishotArtifactKind = 'image' +export type VishotArtifactStage = 'browser-final' | 'electron-raw' + +export interface VishotArtifact { + kind: VishotArtifactKind + stage: VishotArtifactStage + artifactName: string + filePath: string + format: string + metadata?: Record +} + +export type ArtifactTransformer = ( + artifact: VishotArtifact, +) => Promise + export interface CaptureOptions { fullPage?: boolean + transformers?: ArtifactTransformer[] } export interface StageWindowsApi { @@ -34,7 +51,7 @@ export interface DrawersApi { export interface ScenarioContext { electronApp: ElectronApplication outputDir: string - capture: (name: string, page: Page, options?: CaptureOptions) => Promise + capture: (name: string, page: Page, options?: CaptureOptions) => Promise stageWindows: StageWindowsApi controlsIsland: ControlsIslandApi settingsWindow: SettingsWindowApi diff --git a/packages/vishot-runner-electron/src/utils/selectors.ts b/packages/vishot-runner-electron/src/utils/selectors.ts index 8a63623d2..0bfcd3626 100644 --- a/packages/vishot-runner-electron/src/utils/selectors.ts +++ b/packages/vishot-runner-electron/src/utils/selectors.ts @@ -1,5 +1,7 @@ import type { Page } from 'playwright' +import { sleep } from '@moeru/std' + function iconAttributeSelector(iconName: string): string { return `[${iconName.replace(':', '\\:')}]` } @@ -12,10 +14,9 @@ async function clickControlButtonByIcon(page: Page, iconName: string): Promise { diff --git a/packages/vishot-runner-electron/src/utils/settings.ts b/packages/vishot-runner-electron/src/utils/settings.ts index 397713dd6..27d987e3a 100644 --- a/packages/vishot-runner-electron/src/utils/settings.ts +++ b/packages/vishot-runner-electron/src/utils/settings.ts @@ -1,11 +1,5 @@ /* eslint-disable e18e/prefer-static-regex */ -import type { ElectronApplication, Page } from 'playwright' - -import { expandControlsIsland, openSettingsFromControlsIsland } from './selectors' -import { waitForStageWindow } from './windows' - -const mainLoadingProbeSamples = 3 -const mainLoadingProbeIntervalMs = 250 +import type { Page } from 'playwright' function normalizeLabel(label: RegExp | string): string | RegExp { return label @@ -35,84 +29,6 @@ export async function goToSettingsConnectionPage(settingsPage: Page): Promise { - await page.evaluate(() => { - window.location.hash = '#/settings/connection' - }) - await page.waitForURL(/#\/settings\/connection/) -} - -async function findOnboardingPage(electronApp: ElectronApplication): Promise { - for (const page of electronApp.windows()) { - if (page.url().includes('#/onboarding')) { - return page - } - } - - return null -} - -function isTimedOutWaitingForMainWindow(error: unknown): boolean { - return error instanceof Error && error.message === 'Timed out waiting for "main" window' -} - -async function isMainWindowStuckLoading(page: Page): Promise { - for (let index = 0; index < mainLoadingProbeSamples; index += 1) { - const bodyText = await page.locator('body').textContent().catch(() => '') || '' - if (!bodyText.includes('Loading...')) { - return false - } - - if (index < mainLoadingProbeSamples - 1) { - await page.waitForTimeout(mainLoadingProbeIntervalMs) - } - } - - return true -} - -export async function openConnectionSettingsWindow(electronApp: ElectronApplication): Promise { - let mainWindow: Awaited> | null = null - - try { - mainWindow = await waitForStageWindow(electronApp, 'main') - } - catch (error) { - if (!isTimedOutWaitingForMainWindow(error)) { - throw error - } - - const onboardingPage = await findOnboardingPage(electronApp) - if (!onboardingPage) { - throw new Error('Unable to reach the main window and no onboarding window was available for fallback navigation') - } - - // NOTICE: Some local app states keep the main route on Loading... while the - // onboarding renderer is still available. Routing that renderer directly to - // settings keeps the interaction testable without depending on the island. - await navigatePageToConnectionSettings(onboardingPage) - return onboardingPage - } - - if (await isMainWindowStuckLoading(mainWindow.page)) { - const onboardingPage = await findOnboardingPage(electronApp) - if (!onboardingPage) { - throw new Error('The main window was stuck on Loading... and no onboarding window was available for fallback navigation') - } - - await navigatePageToConnectionSettings(onboardingPage) - return onboardingPage - } - - await mainWindow.page.bringToFront() - await expandControlsIsland(mainWindow.page) - await openSettingsFromControlsIsland(mainWindow.page) - - const settingsWindow = await waitForStageWindow(electronApp, 'settings', 10_000) - await goToSettingsConnectionPage(settingsWindow.page) - return settingsWindow.page -} - export async function toggleSettingsSwitchByLabel(settingsPage: Page, label: RegExp | string): Promise<{ before: string, after: string }> { const { labelLocator, row, button } = getSettingsSwitch(settingsPage, label) diff --git a/packages/vishot-runner-electron/src/utils/windows.ts b/packages/vishot-runner-electron/src/utils/windows.ts index 32b9ce00a..4797de3be 100644 --- a/packages/vishot-runner-electron/src/utils/windows.ts +++ b/packages/vishot-runner-electron/src/utils/windows.ts @@ -67,6 +67,14 @@ export interface StageWindowSnapshot { route: string } +export async function snapshotStageWindows(electronApp: ElectronApplication): Promise { + const snapshots = await Promise.all( + electronApp.windows().map(page => classifyWindow(page)), + ) + + return snapshots.filter((snapshot): snapshot is StageWindowSnapshot => snapshot !== null) +} + export async function waitForStageWindow(electronApp: ElectronApplication, name: StageWindowName, timeout = 30_000): Promise { const deadline = Date.now() + timeout diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e0d586ef..a13ae0884 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,9 @@ catalogs: '@moeru/std': specifier: 0.1.0-beta.17 version: 0.1.0-beta.17 + '@napi-rs/image': + specifier: ^1.12.0 + version: 1.12.0 '@nekopaw/tempora': specifier: 0.4.0-alpha.1 version: 0.4.0-alpha.1 @@ -2518,6 +2521,9 @@ importers: '@moeru/std': specifier: 'catalog:' version: 0.1.0-beta.17 + '@napi-rs/image': + specifier: ^1.12.0 + version: 1.12.0 '@proj-airi/vishot-runner-browser': specifier: workspace:^ version: link:../vishot-runner-browser @@ -3553,6 +3559,9 @@ importers: '@moeru/std': specifier: 'catalog:' version: 0.1.0-beta.17 + '@napi-rs/image': + specifier: 'catalog:' + version: 1.12.0 meow: specifier: 'catalog:' version: 14.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index aaf822574..e15cb2f37 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -50,6 +50,7 @@ catalog: '@moeru/eslint-config': 0.1.0-beta.15 '@moeru/eventa': 1.0.0-beta.3 '@moeru/std': 0.1.0-beta.17 + '@napi-rs/image': ^1.12.0 '@nekopaw/tempora': 0.4.0-alpha.1 '@pinia/testing': ^1.0.3 '@proj-airi/drizzle-duckdb-wasm': ^0.4.29