feat(airi-screenshot): added
This commit is contained in:
@@ -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.
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
discoverTamagotchiScenarios,
|
||||
main,
|
||||
parseAiriScreenshotCliArguments,
|
||||
} from './cli'
|
||||
|
||||
export type {
|
||||
AiriScreenshotCliArguments,
|
||||
} from './cli'
|
||||
@@ -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'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user