feat(airi-screenshot): added

This commit is contained in:
Neko Ayaka
2026-05-06 17:33:34 +08:00
parent cbbe1c9810
commit 8e691cfc13
8 changed files with 567 additions and 0 deletions
+42
View File
@@ -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.
+27
View File
@@ -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:"
}
}
+201
View File
@@ -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 <preset-or-path> [--output-dir <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'),
})
})
})
+251
View File
@@ -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 <preset-or-path> [--output-dir <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 <target>', 'Capture screenshots for an AIRI surface')
.option('--scenario <scenario>', 'Scenario id discovered from the target scenario directory, or an explicit scenario file path')
.option('--output-dir, -o <dir>', 'Directory to write generated screenshots')
.option('--format <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<Record<string, string>> {
const scenariosRoot = options.scenariosRoot ?? tamagotchiScenariosRootPath
const dirents = await readdir(scenariosRoot, { withFileTypes: true })
const scenarios: Record<string, string> = {}
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<string> {
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<void> {
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
})
}
+9
View File
@@ -0,0 +1,9 @@
export {
discoverTamagotchiScenarios,
main,
parseAiriScreenshotCliArguments,
} from './cli'
export type {
AiriScreenshotCliArguments,
} from './cli'
+18
View File
@@ -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"
]
}
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
},
})
+12
View File
@@ -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':