feat(vishot-*,scenarios-*): output transformer, avif by default
This commit is contained in:
@@ -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.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 776 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
@@ -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:",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
+4
-2
@@ -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)
|
||||
|
||||
@@ -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 `<render-entry>` 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.
|
||||
|
||||
@@ -68,7 +68,11 @@ function assertBrowserImageArtifact(artifact: VishotArtifact): void {
|
||||
async function applyBrowserImageTransformers(
|
||||
artifact: VishotArtifact,
|
||||
transformers: BrowserCaptureRequest['imageTransformers'],
|
||||
): Promise<VishotArtifact[]> {
|
||||
): 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<void> }> {
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: <T>(callback: (elements: Array<{ getAttribute: (name: string) => string | null }>) => T) => Promise<T>
|
||||
@@ -110,18 +110,16 @@ function createFixturePage(html: string): FakePage {
|
||||
}
|
||||
|
||||
let captureBrowserRoots: typeof import('./capture').captureBrowserRoots
|
||||
const fixtureRoots = new Set<string>()
|
||||
|
||||
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<string> {
|
||||
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<void> {
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@moeru/std": "catalog:",
|
||||
"@napi-rs/image": "catalog:",
|
||||
"meow": "catalog:",
|
||||
"playwright": "^1.56.1"
|
||||
}
|
||||
|
||||
@@ -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 <scenario.ts> --output-dir <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',
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
})
|
||||
|
||||
try {
|
||||
const context = createScenarioContext(electronApp, resolvedOutputDir)
|
||||
const context = createScenarioContext(
|
||||
electronApp,
|
||||
resolvedOutputDir,
|
||||
format === 'avif'
|
||||
? {
|
||||
transformers: [createAvifTransformer()],
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
await loadedScenario.scenario.run(context)
|
||||
}
|
||||
finally {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 },
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ArtifactTransformer, VishotArtifact, VishotArtifactStage } from './types'
|
||||
|
||||
export function createImageArtifact(options: {
|
||||
artifactName: string
|
||||
filePath: string
|
||||
stage: VishotArtifactStage
|
||||
metadata?: Record<string, unknown>
|
||||
}): 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<VishotArtifact[]> {
|
||||
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
|
||||
}
|
||||
@@ -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<string> {
|
||||
export async function capturePage(
|
||||
outputDir: string,
|
||||
name: string,
|
||||
page: Page,
|
||||
options?: CaptureOptions,
|
||||
): Promise<VishotArtifact[]> {
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string, unknown>
|
||||
}
|
||||
|
||||
export type ArtifactTransformer = (
|
||||
artifact: VishotArtifact,
|
||||
) => Promise<VishotArtifact | VishotArtifact[]>
|
||||
|
||||
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<string>
|
||||
capture: (name: string, page: Page, options?: CaptureOptions) => Promise<VishotArtifact[]>
|
||||
stageWindows: StageWindowsApi
|
||||
controlsIsland: ControlsIslandApi
|
||||
settingsWindow: SettingsWindowApi
|
||||
|
||||
@@ -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<v
|
||||
})
|
||||
.last()
|
||||
|
||||
await button.waitFor({ state: 'visible', timeout: 20_000 })
|
||||
await button.hover()
|
||||
await button.waitFor({ state: 'visible', timeout: 15_000 })
|
||||
await button.click({ force: true })
|
||||
await button.hover()
|
||||
await sleep(100)
|
||||
}
|
||||
|
||||
export async function expandControlsIsland(page: Page): Promise<void> {
|
||||
|
||||
@@ -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<Pa
|
||||
return settingsPage
|
||||
}
|
||||
|
||||
async function navigatePageToConnectionSettings(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
window.location.hash = '#/settings/connection'
|
||||
})
|
||||
await page.waitForURL(/#\/settings\/connection/)
|
||||
}
|
||||
|
||||
async function findOnboardingPage(electronApp: ElectronApplication): Promise<Page | null> {
|
||||
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<boolean> {
|
||||
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<Page> {
|
||||
let mainWindow: Awaited<ReturnType<typeof waitForStageWindow>> | 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)
|
||||
|
||||
|
||||
@@ -67,6 +67,14 @@ export interface StageWindowSnapshot {
|
||||
route: string
|
||||
}
|
||||
|
||||
export async function snapshotStageWindows(electronApp: ElectronApplication): Promise<StageWindowSnapshot[]> {
|
||||
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<StageWindowSnapshot> {
|
||||
const deadline = Date.now() + timeout
|
||||
|
||||
|
||||
Generated
+9
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user