From 8e691cfc134cfa9bade0ea25a5144ac2b00a8f39 Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Wed, 6 May 2026 17:33:23 +0800 Subject: [PATCH] feat(airi-screenshot): added --- packages/airi-screenshot/README.md | 42 ++++ packages/airi-screenshot/package.json | 27 +++ packages/airi-screenshot/src/cli.test.ts | 201 +++++++++++++++++ packages/airi-screenshot/src/cli.ts | 251 ++++++++++++++++++++++ packages/airi-screenshot/src/index.ts | 9 + packages/airi-screenshot/tsconfig.json | 18 ++ packages/airi-screenshot/vitest.config.ts | 7 + pnpm-lock.yaml | 12 ++ 8 files changed, 567 insertions(+) create mode 100644 packages/airi-screenshot/README.md create mode 100644 packages/airi-screenshot/package.json create mode 100644 packages/airi-screenshot/src/cli.test.ts create mode 100644 packages/airi-screenshot/src/cli.ts create mode 100644 packages/airi-screenshot/src/index.ts create mode 100644 packages/airi-screenshot/tsconfig.json create mode 100644 packages/airi-screenshot/vitest.config.ts diff --git a/packages/airi-screenshot/README.md b/packages/airi-screenshot/README.md new file mode 100644 index 000000000..99c1b9de6 --- /dev/null +++ b/packages/airi-screenshot/README.md @@ -0,0 +1,42 @@ +# AIRI Screenshot + +Project-specific screenshot orchestration for the AIRI monorepo. + +## Purpose + +This package owns AIRI-specific screenshot knowledge: + +- workspace paths and default output directories +- named presets for AIRI screenshot scenarios +- target-level commands for AIRI surfaces such as `stage-tamagotchi` +- future GitHub slash-command wiring for PR screenshot previews + +It does not own generic screenshot capture primitives. Keep reusable browser, +Electron, Histoire, readiness, and artifact logic in the `vishot-*` packages so +those packages remain publishable without AIRI-specific behavior. + +## Usage + +From the repository root: + +```bash +pnpm -F @proj-airi/airi-screenshot capture tamagotchi --scenario settings-connection --output-dir .vishot/pr-123 +``` + +Use an explicit scenario path when no preset exists: + +```bash +pnpm -F @proj-airi/airi-screenshot capture tamagotchi --scenario packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-hearing-dialog.ts --output-dir .vishot/hearing --format avif +``` + +The CLI currently supports: + +- target: `tamagotchi` +- formats: `png`, `avif` +- presets: `settings-connection`, `demo-hearing-dialog` + +## Boundary + +Use this package when the command needs AIRI product knowledge. Use the +underlying `@proj-airi/vishot-runner-*` packages directly when authoring or +testing generic screenshot capture behavior. diff --git a/packages/airi-screenshot/package.json b/packages/airi-screenshot/package.json new file mode 100644 index 000000000..44a7c71c7 --- /dev/null +++ b/packages/airi-screenshot/package.json @@ -0,0 +1,27 @@ +{ + "name": "@proj-airi/airi-screenshot", + "type": "module", + "version": "0.10.1", + "private": true, + "description": "AIRI monorepo screenshot orchestration CLI", + "author": { + "name": "Moeru AI Project AIRI Team", + "email": "airi@moeru.ai", + "url": "https://github.com/moeru-ai" + }, + "license": "MIT", + "exports": "./src/index.ts", + "bin": { + "airi-screenshot": "./src/cli.ts" + }, + "scripts": { + "capture": "tsx src/cli.ts capture", + "test:run": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@moeru/std": "catalog:", + "cac": "catalog:", + "tinyexec": "catalog:" + } +} diff --git a/packages/airi-screenshot/src/cli.test.ts b/packages/airi-screenshot/src/cli.test.ts new file mode 100644 index 000000000..f6d5eb641 --- /dev/null +++ b/packages/airi-screenshot/src/cli.test.ts @@ -0,0 +1,201 @@ +import path from 'node:path' + +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' + +import { x } from 'tinyexec' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { discoverTamagotchiScenarios, main, parseAiriScreenshotCliArguments } from './cli' + +vi.mock('tinyexec', () => ({ + x: vi.fn(async () => ({ + exitCode: 0, + stderr: '', + stdout: '', + })), +})) + +const xMock = vi.mocked(x) + +beforeEach(() => { + xMock.mockClear() +}) + +describe('parseAiriScreenshotCliArguments', () => { + it('parses a tamagotchi capture preset', () => { + expect(parseAiriScreenshotCliArguments([ + 'capture', + 'tamagotchi', + '--scenario', + 'settings-connection', + '--output-dir', + '.vishot/pr-123', + ])).toEqual({ + command: 'capture', + target: 'tamagotchi', + scenario: 'settings-connection', + outputDir: '.vishot/pr-123', + format: 'png', + }) + }) + + it('parses a tamagotchi scenario file path and output format', () => { + expect(parseAiriScreenshotCliArguments([ + 'capture', + 'tamagotchi', + '--scenario', + 'packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-hearing-dialog.ts', + '--output-dir', + '.vishot/hearing', + '--format', + 'avif', + ])).toEqual({ + command: 'capture', + target: 'tamagotchi', + scenario: 'packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-hearing-dialog.ts', + outputDir: '.vishot/hearing', + format: 'avif', + }) + }) + + it('uses a target-specific default output directory', () => { + expect(parseAiriScreenshotCliArguments([ + 'capture', + 'tamagotchi', + '--scenario', + 'settings-connection', + ])).toEqual({ + command: 'capture', + target: 'tamagotchi', + scenario: 'settings-connection', + outputDir: '.vishot/airi-screenshot/tamagotchi', + format: 'png', + }) + }) + + it('rejects unsupported targets', () => { + expect(() => parseAiriScreenshotCliArguments([ + 'capture', + 'stage-web', + '--route', + '/settings/characters', + ])).toThrow('Unsupported AIRI screenshot target "stage-web". Expected "tamagotchi".') + }) + + it('rejects missing tamagotchi scenario input', () => { + expect(() => parseAiriScreenshotCliArguments([ + 'capture', + 'tamagotchi', + ])).toThrow('Usage: airi-screenshot capture tamagotchi --scenario [--output-dir ]') + }) +}) + +describe('main', () => { + it('delegates a discovered scenario id to the existing Electron runner', async () => { + await main([ + 'capture', + 'tamagotchi', + '--scenario', + 'settings-connection', + '--output-dir', + '.vishot/pr-123', + ]) + + expect(xMock).toHaveBeenCalledWith( + 'pnpm', + [ + '-F', + '@proj-airi/vishot-runner-electron', + 'capture', + '../scenarios-stage-tamagotchi-electron/src/scenarios/settings-connection.ts', + '--output-dir', + '../../.vishot/pr-123', + '--format', + 'png', + ], + { + throwOnError: false, + nodeOptions: { + stdio: 'inherit', + }, + }, + ) + }) + + it('delegates explicit scenario paths to the existing Electron runner', async () => { + await main([ + 'capture', + 'tamagotchi', + '--scenario', + 'packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-hearing-dialog.ts', + '--output-dir', + '.vishot/hearing', + '--format', + 'avif', + ]) + + expect(xMock).toHaveBeenCalledWith( + 'pnpm', + [ + '-F', + '@proj-airi/vishot-runner-electron', + 'capture', + '../scenarios-stage-tamagotchi-electron/src/scenarios/demo-hearing-dialog.ts', + '--output-dir', + '../../.vishot/hearing', + '--format', + 'avif', + ], + { + throwOnError: false, + nodeOptions: { + stdio: 'inherit', + }, + }, + ) + }) + + it('rejects non-zero delegated runner exits', async () => { + xMock.mockResolvedValueOnce({ + exitCode: 1, + stderr: '', + stdout: '', + }) + + await expect(main([ + 'capture', + 'tamagotchi', + '--scenario', + 'packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-hearing-dialog.ts', + ])).rejects.toThrow('AIRI screenshot command exited with code 1.') + }) +}) + +describe('discoverTamagotchiScenarios', () => { + it('discovers root scenario files and folder scenarios with index entrypoints', async () => { + const scenariosRoot = await mkdtemp(path.join(tmpdir(), 'airi-screenshot-scenarios-')) + const folderScenarioRoot = path.join(scenariosRoot, 'demo-controls-settings-chat-websocket') + + await mkdir(folderScenarioRoot) + await writeFile(path.join(scenariosRoot, 'settings-connection.ts'), '') + await writeFile(path.join(scenariosRoot, 'demo-hearing-dialog.ts'), '') + await writeFile(path.join(folderScenarioRoot, 'index.ts'), '') + await writeFile(path.join(folderScenarioRoot, 'manifest.ts'), '') + + await expect(discoverTamagotchiScenarios({ scenariosRoot })).resolves.toEqual({ + 'demo-controls-settings-chat-websocket': path.join(folderScenarioRoot, 'index.ts'), + 'demo-hearing-dialog': path.join(scenariosRoot, 'demo-hearing-dialog.ts'), + 'settings-connection': path.join(scenariosRoot, 'settings-connection.ts'), + }) + }) + + it('discovers a single root scenario file', async () => { + const scenariosRoot = await mkdtemp(path.join(tmpdir(), 'airi-screenshot-scenarios-')) + await writeFile(path.join(scenariosRoot, 'settings-connection.ts'), '') + + await expect(discoverTamagotchiScenarios({ scenariosRoot })).resolves.toEqual({ + 'settings-connection': path.join(scenariosRoot, 'settings-connection.ts'), + }) + }) +}) diff --git a/packages/airi-screenshot/src/cli.ts b/packages/airi-screenshot/src/cli.ts new file mode 100644 index 000000000..2d2c0ed43 --- /dev/null +++ b/packages/airi-screenshot/src/cli.ts @@ -0,0 +1,251 @@ +import path from 'node:path' +import process from 'node:process' + +import { readdir } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' + +import { errorMessageFrom } from '@moeru/std' +import { cac } from 'cac' +import { x } from 'tinyexec' + +type AiriScreenshotCommand = 'capture' +type AiriScreenshotTarget = 'tamagotchi' +type AiriScreenshotFormat = 'png' | 'avif' + +/** Repository root resolved from this package's source location. */ +const repoRootPath = path.resolve(fileURLToPath(new URL('../../..', import.meta.url))) +/** Path to the generic Electron runner package from the repository root. */ +const electronRunnerPackagePath = path.join(repoRootPath, 'packages', 'vishot-runner-electron') +/** Root containing product-owned Electron capture scenarios. */ +const tamagotchiScenariosRootPath = path.join(repoRootPath, 'packages', 'scenarios-stage-tamagotchi-electron', 'src', 'scenarios') + +/** + * Represents one AIRI screenshot CLI capture request. + * + * @param command The top-level CLI command selected by the user. + */ +export interface AiriScreenshotCliArguments { + /** Screenshot operation to run. */ + command: AiriScreenshotCommand + /** AIRI surface to capture. */ + target: AiriScreenshotTarget + /** Preset key or repository-root-relative scenario path. */ + scenario: string + /** Repository-root-relative output directory for generated images. */ + outputDir: string + /** Image format passed through to the underlying runner. */ + format: AiriScreenshotFormat +} + +const usageMessage = 'Usage: airi-screenshot capture tamagotchi --scenario [--output-dir ]' + +function normalizeCliArgv(argv: string[]): string[] { + return argv[0] === '--' ? argv.slice(1) : argv +} + +function parseFormat(format: string | undefined): AiriScreenshotFormat { + if (format === undefined || format.length === 0) { + return 'png' + } + + if (format === 'png' || format === 'avif') { + return format + } + + throw new Error(`Unsupported screenshot format "${format}". Expected "png" or "avif".`) +} + +function createAiriScreenshotCli() { + const cli = cac('airi-screenshot') + + cli + .command('capture ', 'Capture screenshots for an AIRI surface') + .option('--scenario ', 'Scenario id discovered from the target scenario directory, or an explicit scenario file path') + .option('--output-dir, -o ', 'Directory to write generated screenshots') + .option('--format ', 'Output format: png or avif') + + return cli +} + +/** + * Discovers tamagotchi Electron scenario entrypoints. + * + * Use when: + * - Resolving slash-command scenario ids without maintaining a hardcoded list. + * - Keeping scenario ownership in `packages/scenarios-stage-tamagotchi-electron`. + * + * Expects: + * - Root `*.ts` files are runnable scenario entrypoints. + * - First-level folders with `index.ts` are runnable scenario entrypoints. + * + * Returns: + * - A stable id-to-file-path map sorted by scenario id. + */ +export async function discoverTamagotchiScenarios(options: { + scenariosRoot?: string +} = {}): Promise> { + const scenariosRoot = options.scenariosRoot ?? tamagotchiScenariosRootPath + const dirents = await readdir(scenariosRoot, { withFileTypes: true }) + const scenarios: Record = {} + + for (const dirent of dirents) { + if (dirent.isFile() && dirent.name.endsWith('.ts')) { + scenarios[path.basename(dirent.name, '.ts')] = path.join(scenariosRoot, dirent.name) + continue + } + + if (!dirent.isDirectory()) { + continue + } + + const scenarioEntryPath = path.join(scenariosRoot, dirent.name, 'index.ts') + + try { + const nestedDirents = await readdir(path.join(scenariosRoot, dirent.name), { withFileTypes: true }) + if (nestedDirents.some(nestedDirent => nestedDirent.isFile() && nestedDirent.name === 'index.ts')) { + scenarios[dirent.name] = scenarioEntryPath + } + } + catch { + // Directory vanished between the root scan and nested scan; ignore it for discovery. + } + } + + return Object.fromEntries( + Object.entries(scenarios).sort(([left], [right]) => left.localeCompare(right)), + ) +} + +async function resolveTamagotchiScenarioPath( + scenario: string, + options: { + scenariosRoot?: string + } = {}, +): Promise { + if (scenario.endsWith('.ts') || scenario.includes('/') || scenario.includes('\\')) { + return path.isAbsolute(scenario) ? scenario : path.join(repoRootPath, scenario) + } + + const scenarios = await discoverTamagotchiScenarios(options) + const scenarioPath = scenarios[scenario] + + if (scenarioPath) { + return scenarioPath + } + + const availableScenarios = Object.keys(scenarios) + const availableText = availableScenarios.length > 0 + ? availableScenarios.join(', ') + : 'none' + + throw new Error(`Unknown tamagotchi scenario "${scenario}". Available scenarios: ${availableText}.`) +} + +/** + * Parses AIRI screenshot CLI arguments into a typed capture request. + * + * Use when: + * - Translating slash-command payloads into local CLI invocations. + * - Testing AIRI-specific defaults without launching browsers or Electron. + * + * Expects: + * - `argv` starts with `capture tamagotchi`. + * - Scenario values are either known preset keys or repository-root-relative file paths. + * + * Returns: + * - A normalized request with default format and output directory applied. + */ +export function parseAiriScreenshotCliArguments(argv: string[]): AiriScreenshotCliArguments { + const normalizedArgv = normalizeCliArgv(argv) + const cli = createAiriScreenshotCli() + const parsed = cli.parse(['node', 'airi-screenshot', ...normalizedArgv], { run: false }) + const [command] = normalizedArgv + const [target] = parsed.args + + if (command !== 'capture') { + throw new Error(usageMessage) + } + + if (target !== 'tamagotchi') { + throw new Error(`Unsupported AIRI screenshot target "${target ?? ''}". Expected "tamagotchi".`) + } + + const scenario = typeof parsed.options.scenario === 'string' ? parsed.options.scenario : undefined + const outputDir = typeof parsed.options.outputDir === 'string' ? parsed.options.outputDir : undefined + const format = typeof parsed.options.format === 'string' ? parsed.options.format : undefined + + if (!scenario) { + throw new Error(usageMessage) + } + + return { + command, + target, + scenario, + outputDir: outputDir ?? path.join('.vishot', 'airi-screenshot', 'tamagotchi'), + format: parseFormat(format), + } +} + +/** + * Runs the AIRI screenshot CLI. + * + * Use when: + * - Invoked through `pnpm -F @proj-airi/airi-screenshot capture ...`. + * - Reusing AIRI-specific screenshot presets from local automation or CI. + * + * Expects: + * - The current working directory is the AIRI repository root. + * + * Returns: + * - Resolves after the delegated Vishot runner exits successfully. + * + * Call stack: + * + * main + * -> {@link parseAiriScreenshotCliArguments} + * -> {@link resolveTamagotchiScenarioPath} + * -> x + */ +export async function main(argv: string[] = process.argv.slice(2)): Promise { + const request = parseAiriScreenshotCliArguments(argv) + const scenarioPath = await resolveTamagotchiScenarioPath(request.scenario) + const outputDir = path.isAbsolute(request.outputDir) + ? request.outputDir + : path.join(repoRootPath, request.outputDir) + + // The filtered pnpm command runs from packages/vishot-runner-electron. + const runnerScenarioPath = scenarioPath.startsWith(repoRootPath) + ? path.relative(electronRunnerPackagePath, scenarioPath) + : scenarioPath + const runnerOutputDir = outputDir.startsWith(repoRootPath) + ? path.relative(electronRunnerPackagePath, outputDir) + : outputDir + + const output = await x('pnpm', [ + '-F', + '@proj-airi/vishot-runner-electron', + 'capture', + runnerScenarioPath, + '--output-dir', + runnerOutputDir, + '--format', + request.format, + ], { + throwOnError: false, + nodeOptions: { + stdio: 'inherit', + }, + }) + + if (output.exitCode !== 0) { + throw new Error(`AIRI screenshot command exited with code ${output.exitCode ?? 'unknown'}.`) + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + void main().catch((error) => { + console.error(errorMessageFrom(error) ?? usageMessage) + process.exitCode = 1 + }) +} diff --git a/packages/airi-screenshot/src/index.ts b/packages/airi-screenshot/src/index.ts new file mode 100644 index 000000000..b3e6cd693 --- /dev/null +++ b/packages/airi-screenshot/src/index.ts @@ -0,0 +1,9 @@ +export { + discoverTamagotchiScenarios, + main, + parseAiriScreenshotCliArguments, +} from './cli' + +export type { + AiriScreenshotCliArguments, +} from './cli' diff --git a/packages/airi-screenshot/tsconfig.json b/packages/airi-screenshot/tsconfig.json new file mode 100644 index 000000000..97068867e --- /dev/null +++ b/packages/airi-screenshot/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "moduleResolution": "Bundler", + "types": [ + "node" + ], + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts", + "vitest.config.ts" + ] +} diff --git a/packages/airi-screenshot/vitest.config.ts b/packages/airi-screenshot/vitest.config.ts new file mode 100644 index 000000000..647f39364 --- /dev/null +++ b/packages/airi-screenshot/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 87bca7d91..28f0f4941 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2369,6 +2369,18 @@ importers: specifier: ^1.6.0 version: 1.6.0 + packages/airi-screenshot: + dependencies: + '@moeru/std': + specifier: 'catalog:' + version: 0.1.0-beta.17 + cac: + specifier: 'catalog:' + version: 7.0.0 + tinyexec: + specifier: 'catalog:' + version: 1.1.1 + packages/audio: dependencies: '@alexanderolsen/libsamplerate-js':