feat(vishot-*,scenarios-*): output transformer, avif by default

This commit is contained in:
Neko Ayaka
2026-04-05 00:27:42 +08:00
parent 22e82c5166
commit 7d88e05fa1
30 changed files with 426 additions and 128 deletions
@@ -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

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'
@@ -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)
+8
View File
@@ -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 {
+16 -5
View File
@@ -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
+9
View File
@@ -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
+1
View File
@@ -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