Spaces:
Paused
Paused
Download src/cli.ts from Leon4gr45/nova-right-nav: direct link, hf CLI and curl.
- Browser
- Download file 95.1 kB
-
https://huggingface.co/spaces/Leon4gr45/nova-right-nav/resolve/main/src/cli.ts
- Command line
-
hf download hf://spaces/Leon4gr45/nova-right-nav/src/cli.ts
-
curl -L -o cli.ts https://huggingface.co/spaces/Leon4gr45/nova-right-nav/resolve/main/src/cli.ts
95.1 kB
| import { | |
| access, | |
| mkdir, | |
| readFile, | |
| readdir, | |
| stat, | |
| writeFile, | |
| } from "node:fs/promises"; | |
| import { basename, dirname, join, resolve } from "node:path"; | |
| import { homedir } from "node:os"; | |
| import { freemem, totalmem } from "node:os"; | |
| import { statfs } from "node:fs/promises"; | |
| import { pathToFileURL } from "node:url"; | |
| import { fileURLToPath } from "node:url"; | |
| import { Command } from "commander"; | |
| import { | |
| getOAuthApiKey, | |
| getOAuthProvider, | |
| getOAuthProviders, | |
| type OAuthCredentials, | |
| } from "@earendil-works/pi-ai/oauth"; | |
| import { | |
| DataLifecycleConfigSchema, | |
| RunResultSchema, | |
| TesterProfileSchema, | |
| UserJourneySchema, | |
| type BrowserDevicePreset, | |
| type BrowserEnvironment, | |
| type RunAttempt, | |
| type RunResult, | |
| type TesterProfile, | |
| type UserJourney, | |
| } from "./core/schemas.js"; | |
| import { | |
| DraftJourneyInputSchema, | |
| createDraftUserJourney, | |
| createJsonSchemaDocument, | |
| createTesterProfileTemplate, | |
| createUserJourneyTemplate, | |
| defaultAuthoringDir, | |
| formatLintIssue, | |
| initAuthoringProject, | |
| lintUserJourney, | |
| parseSchemaKind, | |
| stringifyJsonDocument, | |
| writeJsonDocument, | |
| type LintIssue, | |
| type SchemaKind, | |
| } from "./core/authoring.js"; | |
| import { readAndParseJsonFile, readJsonFile } from "./core/json.js"; | |
| import { | |
| validateAllowedOrigins, | |
| validateJourneyProfileMatch, | |
| type ValidationIssue, | |
| } from "./core/validation.js"; | |
| import { emitGitHubAnnotations, writeCiReports } from "./reporters/ci.js"; | |
| import { | |
| buildSuiteRunHistory, | |
| runHealth, | |
| snapshotRun, | |
| type RunSnapshot, | |
| type SuiteSelectionSummary, | |
| type SuiteRunHistory, | |
| } from "./reporters/runComparison.js"; | |
| import { | |
| renderSuiteDashboard, | |
| type SuiteDashboardRun, | |
| } from "./reporters/suiteDashboard.js"; | |
| import { runJourney, type RunJourneyOptions } from "./runner/runJourney.js"; | |
| import { isoTimestampForPath, sanitizePathSegment } from "./utils/path.js"; | |
| import { | |
| AppLifecycleController, | |
| applyAppTargetOverride, | |
| DataLifecycleController, | |
| } from "./lifecycle/index.js"; | |
| import { | |
| createDefaultJourneyTestFactoryRegistry, | |
| type JourneyTestFactoryRegistry, | |
| } from "./factories/index.js"; | |
| import type { | |
| AgentDirector, | |
| UiChangeRecordingOptions, | |
| } from "./directors/types.js"; | |
| import type { BrowserDriver } from "./drivers/types.js"; | |
| import type { BookmarkCurator } from "./curation/types.js"; | |
| import { spawn } from "node:child_process"; | |
| import { execFile } from "node:child_process"; | |
| import { promisify } from "node:util"; | |
| import { assertCloudReasoningConfiguration, cognitionConfigStatus, createApiServer, createJourneyRuntime, isHuggingFaceSpace, loadRuntimeCognitionConfig, ManagedLayaService, reasoningModelStatus, resolveLayaMode, assertSafeJourneyNetwork, assertSafeRedirectChain } from "./app/index.js"; | |
| export interface RunCliOptions { | |
| factories?: JourneyTestFactoryRegistry; | |
| } | |
| export type ParallelBrowserMode = "shared-tabs" | "isolated-sessions"; | |
| const collectRepeatableOption = (value: string, previous: string[] = []): string[] => [ | |
| ...previous, | |
| value, | |
| ]; | |
| export async function runCli( | |
| argv = process.argv, | |
| cliOptions: RunCliOptions = {}, | |
| ): Promise<void> { | |
| const factories = | |
| cliOptions.factories ?? createDefaultJourneyTestFactoryRegistry(); | |
| const program = new Command(); | |
| program | |
| .name("journeytest") | |
| .description("AI-agent-directed user journey testing for web apps.") | |
| .version("0.1.0"); | |
| program | |
| .command("init") | |
| .description("Create starter JourneyTest authoring files.") | |
| .option( | |
| "--dir <dir>", | |
| "Directory for generated authoring files.", | |
| defaultAuthoringDir, | |
| ) | |
| .option( | |
| "--app-name <name>", | |
| "App name to use in generated journeys.", | |
| "Acme Admin", | |
| ) | |
| .option( | |
| "--base-url <url>", | |
| "Base URL to use in generated journeys.", | |
| "http://127.0.0.1:3000", | |
| ) | |
| .option("--profile-id <id>", "Tester profile id.", "admin") | |
| .option("--journey-id <id>", "Starter journey id.", "admin-invite-user") | |
| .option("--risk-level <level>", "Journey risk level.", "writes-test-data") | |
| .option("--force", "Overwrite files if they already exist.") | |
| .action( | |
| async (options: { | |
| dir: string; | |
| appName: string; | |
| baseUrl: string; | |
| profileId: string; | |
| journeyId: string; | |
| riskLevel: UserJourney["riskLevel"]; | |
| force?: boolean; | |
| }) => { | |
| const result = await initAuthoringProject({ | |
| rootDir: resolve(options.dir), | |
| appName: options.appName, | |
| baseUrl: options.baseUrl, | |
| profileId: options.profileId, | |
| id: options.journeyId, | |
| riskLevel: options.riskLevel, | |
| force: options.force, | |
| }); | |
| console.log(`Wrote tester profile: ${result.profilePath}`); | |
| console.log(`Wrote user journey: ${result.journeyPath}`); | |
| }, | |
| ); | |
| const newCommand = program | |
| .command("new") | |
| .description("Generate JourneyTest authoring JSON."); | |
| newCommand | |
| .command("profile") | |
| .description("Generate a tester profile JSON file.") | |
| .argument("[id]", "Tester profile id.", "admin") | |
| .option("--name <name>", "Tester profile display name.") | |
| .option("--role <role>", "Tester role.") | |
| .option("--perspective <text>", "Tester perspective.") | |
| .option("--out <path>", "Output JSON path.") | |
| .option("--stdout", "Print JSON to stdout instead of writing a file.") | |
| .option("--force", "Overwrite the output file if it already exists.") | |
| .action( | |
| async ( | |
| id: string, | |
| options: { | |
| name?: string; | |
| role?: string; | |
| perspective?: string; | |
| out?: string; | |
| stdout?: boolean; | |
| force?: boolean; | |
| }, | |
| ) => { | |
| const profile = createTesterProfileTemplate({ | |
| id, | |
| name: options.name, | |
| role: options.role, | |
| perspective: options.perspective, | |
| }); | |
| if (options.stdout) { | |
| process.stdout.write(stringifyJsonDocument(profile)); | |
| return; | |
| } | |
| const outPath = resolve( | |
| options.out ?? | |
| join(defaultAuthoringDir, "profiles", `${profile.id}.json`), | |
| ); | |
| await writeJsonDocument(outPath, profile, { force: options.force }); | |
| console.log(`Wrote tester profile: ${outPath}`); | |
| }, | |
| ); | |
| newCommand | |
| .command("journey") | |
| .description("Generate a user journey JSON file.") | |
| .argument("[id]", "User journey id.", "admin-invite-user") | |
| .option("--title <title>", "Journey title.") | |
| .option("--profile <id>", "Tester profile id used by the journey.", "admin") | |
| .option("--app-name <name>", "App name.", "Acme Admin") | |
| .option("--base-url <url>", "App base URL.", "http://127.0.0.1:3000") | |
| .option("--risk-level <level>", "Journey risk level.", "writes-test-data") | |
| .option("--out <path>", "Output JSON path.") | |
| .option("--stdout", "Print JSON to stdout instead of writing a file.") | |
| .option("--force", "Overwrite the output file if it already exists.") | |
| .action( | |
| async ( | |
| id: string, | |
| options: { | |
| title?: string; | |
| profile: string; | |
| appName: string; | |
| baseUrl: string; | |
| riskLevel: UserJourney["riskLevel"]; | |
| out?: string; | |
| stdout?: boolean; | |
| force?: boolean; | |
| }, | |
| ) => { | |
| const journey = createUserJourneyTemplate({ | |
| id, | |
| title: options.title, | |
| appName: options.appName, | |
| baseUrl: options.baseUrl, | |
| testerProfile: options.profile, | |
| riskLevel: options.riskLevel, | |
| }); | |
| if (options.stdout) { | |
| process.stdout.write(stringifyJsonDocument(journey)); | |
| return; | |
| } | |
| const outPath = resolve( | |
| options.out ?? | |
| join(defaultAuthoringDir, "journeys", `${journey.id}.json`), | |
| ); | |
| await writeJsonDocument(outPath, journey, { force: options.force }); | |
| console.log(`Wrote user journey: ${outPath}`); | |
| }, | |
| ); | |
| const draftCommand = program | |
| .command("draft") | |
| .description( | |
| "Draft richer JourneyTest authoring JSON from structured local inputs.", | |
| ); | |
| draftCommand | |
| .command("journey") | |
| .description("Draft a user journey JSON file from flags or a JSON input file.") | |
| .argument("[id]", "User journey id.") | |
| .option("--input <path>", "JSON draft input file.") | |
| .option("--title <title>", "Journey title.") | |
| .option("--profile <id>", "Tester profile id used by the journey.") | |
| .option("--app-name <name>", "App name.") | |
| .option("--base-url <url>", "App base URL.") | |
| .option("--risk-level <level>", "Journey risk level.") | |
| .option("--objective <text>", "Journey objective.") | |
| .option( | |
| "--precondition <text>", | |
| "Precondition for the tester or environment. Repeatable.", | |
| collectRepeatableOption, | |
| [], | |
| ) | |
| .option( | |
| "--task <instruction>", | |
| "Task instruction. Repeatable.", | |
| collectRepeatableOption, | |
| [], | |
| ) | |
| .option( | |
| "--task-outcome <text>", | |
| "Expected outcome for the task at the same repeat index. Repeatable.", | |
| collectRepeatableOption, | |
| [], | |
| ) | |
| .option( | |
| "--pass <statement>", | |
| "Pass criterion statement. Repeatable.", | |
| collectRepeatableOption, | |
| [], | |
| ) | |
| .option( | |
| "--fail <statement>", | |
| "Fail criterion statement. Repeatable.", | |
| collectRepeatableOption, | |
| [], | |
| ) | |
| .option( | |
| "--blocker <statement>", | |
| "Blocker criterion statement. Repeatable.", | |
| collectRepeatableOption, | |
| [], | |
| ) | |
| .option( | |
| "--data <key=value>", | |
| "Journey data value. Repeatable. Values are parsed as JSON when possible.", | |
| collectRepeatableOption, | |
| [], | |
| ) | |
| .option("--out <path>", "Output JSON path.") | |
| .option("--stdout", "Print JSON to stdout instead of writing a file.") | |
| .option("--force", "Overwrite the output file if it already exists.") | |
| .action( | |
| async ( | |
| id: string | undefined, | |
| options: { | |
| input?: string; | |
| title?: string; | |
| profile?: string; | |
| appName?: string; | |
| baseUrl?: string; | |
| riskLevel?: UserJourney["riskLevel"]; | |
| objective?: string; | |
| precondition: string[]; | |
| task: string[]; | |
| taskOutcome: string[]; | |
| pass: string[]; | |
| fail: string[]; | |
| blocker: string[]; | |
| data: string[]; | |
| out?: string; | |
| stdout?: boolean; | |
| force?: boolean; | |
| }, | |
| ) => { | |
| const input = options.input | |
| ? DraftJourneyInputSchema.parse( | |
| await readJsonFile(resolve(options.input)), | |
| ) | |
| : undefined; | |
| const data = parseDataOptions(options.data); | |
| if (options.taskOutcome.length > options.task.length) { | |
| throw new Error( | |
| "--task-outcome cannot be provided more times than --task.", | |
| ); | |
| } | |
| const tasks = options.task.map((instruction, index) => ({ | |
| instruction, | |
| ...(options.taskOutcome[index] | |
| ? { expectedOutcome: options.taskOutcome[index] } | |
| : {}), | |
| })); | |
| const journey = createDraftUserJourney({ | |
| input, | |
| id: id ?? input?.id, | |
| title: options.title, | |
| appName: options.appName, | |
| baseUrl: options.baseUrl, | |
| testerProfile: options.profile, | |
| riskLevel: options.riskLevel, | |
| objective: options.objective, | |
| preconditions: | |
| options.precondition.length > 0 ? options.precondition : undefined, | |
| ...(Object.keys(data).length > 0 ? { data } : {}), | |
| tasks: tasks.length > 0 ? tasks : undefined, | |
| passCriteria: options.pass.length > 0 ? options.pass : undefined, | |
| failCriteria: options.fail.length > 0 ? options.fail : undefined, | |
| blockerCriteria: | |
| options.blocker.length > 0 ? options.blocker : undefined, | |
| }); | |
| if (options.stdout) { | |
| process.stdout.write(stringifyJsonDocument(journey)); | |
| return; | |
| } | |
| const outPath = resolve( | |
| options.out ?? | |
| join(defaultAuthoringDir, "journeys", `${journey.id}.json`), | |
| ); | |
| await writeJsonDocument(outPath, journey, { force: options.force }); | |
| console.log(`Wrote drafted user journey: ${outPath}`); | |
| }, | |
| ); | |
| program | |
| .command("schema") | |
| .description("Print JourneyTest authoring JSON schemas.") | |
| .argument("[kind]", "profile, journey, lifecycle, or all.", "all") | |
| .option("--out <path>", "Write schema JSON to a file.") | |
| .action(async (kindValue: string, options: { out?: string }) => { | |
| const kind: SchemaKind = parseSchemaKind(kindValue); | |
| const document = createJsonSchemaDocument(kind); | |
| if (options.out) { | |
| const outPath = resolve(options.out); | |
| await writeJsonDocument(outPath, document, { force: true }); | |
| console.log(`Wrote schema: ${outPath}`); | |
| return; | |
| } | |
| process.stdout.write(stringifyJsonDocument(document)); | |
| }); | |
| program | |
| .command("lint") | |
| .description( | |
| "Lint JourneyTest authoring files for schema and quality issues.", | |
| ) | |
| .requiredOption("--journeys <path>", "Journey JSON file or directory.") | |
| .option("--profiles <path>", "Tester profile JSON file or directory.") | |
| .action(async (options: { journeys: string; profiles?: string }) => { | |
| const result = await lintFiles(options.journeys, options.profiles); | |
| if (result.issueCount > 0) { | |
| process.exitCode = 1; | |
| } | |
| }); | |
| program | |
| .command("validate") | |
| .description( | |
| "Validate JourneyTest user journey and tester profile JSON files.", | |
| ) | |
| .requiredOption("--journeys <path>", "Journey JSON file or directory.") | |
| .requiredOption( | |
| "--profiles <path>", | |
| "Tester profile JSON file or directory.", | |
| ) | |
| .action(async (options: { journeys: string; profiles: string }) => { | |
| const result = await validateFiles(options.journeys, options.profiles); | |
| if (result.issueCount > 0) { | |
| process.exitCode = 1; | |
| } | |
| }); | |
| program | |
| .command("auth") | |
| .description("Inspect JourneyTest Pi OAuth authentication without printing secrets.") | |
| .option( | |
| "--auth <path>", | |
| "OAuth auth file. Defaults to JOURNEYTEST_AUTH_PATH, existing ./auth.json, then user config.", | |
| ) | |
| .option("--json", "Print machine-readable JSON.") | |
| .action(async (options: { auth?: string; json?: boolean }) => { | |
| const authPath = await resolveAuthPath(options.auth); | |
| const status = await getOAuthAuthStatus(authPath); | |
| if (options.json) { | |
| process.stdout.write(`${JSON.stringify(status, null, 2)}\n`); | |
| return; | |
| } | |
| console.log(`Auth file: ${status.path}`); | |
| console.log(`Source: ${authPathSourceLabel(status.source)}`); | |
| console.log(`Exists: ${status.exists ? "yes" : "no"}`); | |
| console.log( | |
| `Configured providers: ${ | |
| status.configuredProviders.length > 0 | |
| ? status.configuredProviders.join(", ") | |
| : "none" | |
| }`, | |
| ); | |
| console.log( | |
| `Pi OAuth providers: ${ | |
| status.availableOAuthProviders.length > 0 | |
| ? status.availableOAuthProviders.join(", ") | |
| : "none" | |
| }`, | |
| ); | |
| const unknown = status.configuredProviders.filter( | |
| (provider) => !status.availableOAuthProviders.includes(provider), | |
| ); | |
| if (unknown.length > 0) { | |
| console.log( | |
| `Configured but not registered by this Pi version: ${unknown.join(", ")}`, | |
| ); | |
| } | |
| }); | |
| program | |
| .command("run") | |
| .description( | |
| "Run one JourneyTest user journey file, or all journey JSON files under a directory.", | |
| ) | |
| .argument("[journeys]", "Legacy journey JSON file or directory.") | |
| .option("--url <url>", "Start URL for a service journey.") | |
| .option("--goal <goal>", "Goal for a service journey.") | |
| .option("--max-steps <count>", "Maximum Laya steps.", "15") | |
| .option("--timeout <seconds>", "Journey timeout in seconds.", "120") | |
| .option("--no-screenshots", "Disable screenshot capture.") | |
| .option("--trace", "Request trace capture when supported.") | |
| .option("--allowed-domain <domain>", "Allowed destination domain.", collectRepeatableOption, []) | |
| .option("--laya-mode <mode>", "Laya mode: auto, remote, embedded, or mock.", "auto") | |
| .option("--cognition-profile <profile>", "Cognition profile: local-cloud, local-spark-cloud, dual-laya-cloud, or dual-laya-spark-cloud.") | |
| .option("--json", "Print only JourneyResult JSON to stdout.") | |
| .option("--output <dir>", "Service journey output directory.") | |
| .option("--verbose", "Print detailed service progress.") | |
| .option("--profile <path>", "Tester profile JSON file or directory.") | |
| .option("--profiles <path>", "Tester profile JSON file or directory.") | |
| .option("--out <dir>", "Output directory.", "runs") | |
| .option("--director <name>", "Agent director factory to use.", "pi") | |
| .option( | |
| "--browser <name>", | |
| "Browser driver factory to use.", | |
| "agent-browser", | |
| ) | |
| .option("--provider <provider>", "Pi model provider.", "anthropic") | |
| .option("--model <model>", "Pi model id or Laya checkpoint.") | |
| .option( | |
| "--auth <path>", | |
| "OAuth auth file. Defaults to JOURNEYTEST_AUTH_PATH, existing ./auth.json, then user config.", | |
| ) | |
| .option("--session <name>", "agent-browser session name.") | |
| .option("--state <path>", "agent-browser state file.") | |
| .option("--headed", "Run browser headed.") | |
| .option( | |
| "--viewport <width>x<height>", | |
| "Set browser viewport size, for example 1440x900.", | |
| ) | |
| .option( | |
| "--device <preset>", | |
| "Set a curated browser device preset: iphone-14, pixel-7, or ipad-pro-11.", | |
| ) | |
| .option( | |
| "--parallel-agents <count>", | |
| "Number of user journeys to run in parallel.", | |
| "1", | |
| ) | |
| .option( | |
| "--parallel-browser-mode <mode>", | |
| "Browser strategy for parallel agent-browser runs: shared-tabs or isolated-sessions.", | |
| "shared-tabs", | |
| ) | |
| .option( | |
| "--retries <count>", | |
| "Retry each journey up to this many times after a failing attempt.", | |
| "0", | |
| ) | |
| .option( | |
| "--journey-timeout-ms <ms>", | |
| "Maximum wall-clock time for one journey director run before it is aborted.", | |
| "1800000", | |
| ) | |
| .option("--no-video", "Disable video recording.") | |
| .option( | |
| "--no-ui-change-recording", | |
| "Disable automatic UI change timelines around browser actions.", | |
| ) | |
| .option( | |
| "--ui-change-timeout-ms <ms>", | |
| "Maximum time to watch for UI changes after each browser action.", | |
| ) | |
| .option( | |
| "--ui-change-quiet-ms <ms>", | |
| "Quiet window before ending UI change observation after changes are seen.", | |
| ) | |
| .option( | |
| "--ui-change-max-changes <count>", | |
| "Maximum UI change records to keep per browser action.", | |
| ) | |
| .option( | |
| "--ui-change-max-screenshots <count>", | |
| "Maximum change screenshots to keep per browser action.", | |
| ) | |
| .option( | |
| "--no-ui-change-screenshots", | |
| "Do not capture before/change/after screenshots for UI change timelines.", | |
| ) | |
| .option( | |
| "--no-ui-change-snapshots", | |
| "Do not capture before/after accessibility snapshots for UI change timelines.", | |
| ) | |
| .option( | |
| "--no-ui-change-dom-snapshots", | |
| "Do not capture before/after visible DOM summaries for UI change timelines.", | |
| ) | |
| .option( | |
| "--no-curate-bookmarks", | |
| "Disable post-run Pi bookmark chapter curation.", | |
| ) | |
| .option( | |
| "--bookmark-curator <name>", | |
| "Bookmark curator factory to use.", | |
| "pi", | |
| ) | |
| .option( | |
| "--bookmark-system-prompt <path>", | |
| "Path to a custom system prompt for bookmark curation.", | |
| ) | |
| .option( | |
| "--data-lifecycle <path>", | |
| "JourneyTest data lifecycle config JSON file.", | |
| ) | |
| .option( | |
| "--data-lifecycle-provider <name>", | |
| "Data lifecycle provider factory to use.", | |
| "default", | |
| ) | |
| .option("--keep-data", "Skip configured data lifecycle cleanup operations.") | |
| .option("--junit <path>", "Write a JUnit XML report for CI systems.") | |
| .option( | |
| "--github-annotations", | |
| "Emit GitHub Actions workflow-command annotations.", | |
| ) | |
| .option("--summary-json <path>", "Write a compact CI summary JSON report.") | |
| .option( | |
| "--compare-to <path>", | |
| "Previous run.json or suite run directory to compare against.", | |
| ) | |
| .option( | |
| "--tag <tag>", | |
| "Only run journeys with this tag. Repeat for OR matching.", | |
| collectRepeatableOption, | |
| [], | |
| ) | |
| .option( | |
| "--exclude-tag <tag>", | |
| "Skip journeys with this tag. Repeatable.", | |
| collectRepeatableOption, | |
| [], | |
| ) | |
| .option( | |
| "--journey-id <id>", | |
| "Only run this journey id. Repeatable.", | |
| collectRepeatableOption, | |
| [], | |
| ) | |
| .option( | |
| "--exclude-journey-id <id>", | |
| "Skip this journey id. Repeatable.", | |
| collectRepeatableOption, | |
| [], | |
| ) | |
| .option( | |
| "--rerun-failed <path>", | |
| "Prior run.json, history.json, or suite run directory; rerun journeys that were not healthy.", | |
| ) | |
| .option( | |
| "--shard <index>/<total>", | |
| "Run one deterministic shard after filtering, using 1-based shard index.", | |
| ) | |
| .action( | |
| async ( | |
| journeyPath: string | undefined, | |
| options: { | |
| url?: string; goal?: string; maxSteps: string; timeout: string; screenshots: boolean; trace?: boolean; allowedDomain: string[]; layaMode: string; cognitionProfile?: string; json?: boolean; output?: string; verbose?: boolean; | |
| profile?: string; | |
| profiles?: string; | |
| out: string; | |
| director: string; | |
| browser: string; | |
| provider: string; | |
| model: string; | |
| auth?: string; | |
| session?: string; | |
| state?: string; | |
| headed?: boolean; | |
| viewport?: string; | |
| device?: string; | |
| retries: string; | |
| journeyTimeoutMs: string; | |
| parallelAgents: string; | |
| parallelBrowserMode: string; | |
| video?: boolean; | |
| uiChangeRecording?: boolean; | |
| uiChangeTimeoutMs?: string; | |
| uiChangeQuietMs?: string; | |
| uiChangeMaxChanges?: string; | |
| uiChangeMaxScreenshots?: string; | |
| uiChangeScreenshots?: boolean; | |
| uiChangeSnapshots?: boolean; | |
| uiChangeDomSnapshots?: boolean; | |
| curateBookmarks?: boolean; | |
| bookmarkCurator: string; | |
| bookmarkSystemPrompt?: string; | |
| dataLifecycle?: string; | |
| dataLifecycleProvider: string; | |
| keepData?: boolean; | |
| junit?: string; | |
| githubAnnotations?: boolean; | |
| summaryJson?: string; | |
| compareTo?: string; | |
| tag: string[]; | |
| excludeTag: string[]; | |
| journeyId: string[]; | |
| excludeJourneyId: string[]; | |
| rerunFailed?: string; | |
| shard?: string; | |
| }, | |
| ) => { | |
| if (options.url || options.goal) { | |
| if (!options.url || !options.goal) throw new Error("Service runs require both --url and --goal."); | |
| await assertSafeJourneyNetwork(options.url, { allowedDomains: options.allowedDomain }); await assertSafeRedirectChain(options.url, { allowedDomains: options.allowedDomain }); | |
| const mode = resolveLayaMode(options.layaMode); if (mode === "mock") throw new Error("CLI does not silently substitute mock inference."); | |
| const managed = mode === "embedded" ? new ManagedLayaService() : undefined; await managed?.start(); | |
| const runtime = createJourneyRuntime({ outputDir: options.output ?? options.out, model: options.model, endpoint: managed?.endpoint, cognitionOverrides: options.cognitionProfile ? { COGNITION_PROFILE: options.cognitionProfile } : undefined }); await runtime.backend.start(); | |
| const id = await runtime.service.createJourney({ url: options.url, goal: options.goal, maxSteps: Number(options.maxSteps), timeoutMs: Number(options.timeout) * 1000, screenshots: options.screenshots, trace: Boolean(options.trace), cognitionProfile: options.cognitionProfile, context: { allowedDomains: options.allowedDomain } }); | |
| const result = await runtime.service.runJourney(id).finally(async () => { await runtime.backend.close(); await managed?.stop(); }); | |
| if (options.json) process.stdout.write(`${JSON.stringify(result)}\n`); else { for (const step of result.steps) console.log(`[${String(step.step).padStart(2, "0")}] LAYA ${step.decision?.operation ?? "INVALID"}${step.decision?.elementIndex === undefined ? "" : ` [${step.decision.elementIndex}]`} p=${step.decision?.confidence?.toFixed(3) ?? "-"}`); console.log(`${result.status === "completed" ? "✓" : "✗"} ${result.status}: ${result.termination.reason}`); console.log(`Result: ${result.artifacts.result}`); } | |
| if (result.status !== "completed") process.exitCode = 2; return; | |
| } | |
| if (!journeyPath) throw new Error("Pass a legacy journey path or --url and --goal."); | |
| if (!options.model) throw new Error("Legacy journey runs require --model."); | |
| const profilePath = options.profiles ?? options.profile; | |
| if (!profilePath) { | |
| throw new Error( | |
| "Pass --profile <path> or --profiles <path> so journeys can resolve tester profiles.", | |
| ); | |
| } | |
| const bookmarkCuratorName = bookmarkCuratorNameForOptions(options); | |
| factories.directors.assertRegistered(options.director); | |
| factories.browserDrivers.assertRegistered(options.browser); | |
| factories.bookmarkCurators.assertRegistered(bookmarkCuratorName); | |
| if (options.dataLifecycle) { | |
| factories.dataLifecycleProviders.assertRegistered( | |
| options.dataLifecycleProvider, | |
| ); | |
| } | |
| const journeyRoot = resolve(journeyPath); | |
| const collectedItems = await collectRunnableJourneys( | |
| journeyRoot, | |
| resolve(profilePath), | |
| ); | |
| if (collectedItems.length === 0) { | |
| throw new Error(`No journey JSON files found under ${journeyRoot}.`); | |
| } | |
| const rerunFailed = options.rerunFailed | |
| ? await loadRerunFailedSelection(resolve(options.rerunFailed)) | |
| : undefined; | |
| const selection = buildJourneySelection({ | |
| tags: options.tag, | |
| excludeTags: options.excludeTag, | |
| journeyIds: options.journeyId, | |
| excludeJourneyIds: options.excludeJourneyId, | |
| ...(rerunFailed ? { rerunFailed } : {}), | |
| shard: options.shard, | |
| }); | |
| const selectionResult = selectRunnableJourneys( | |
| collectedItems, | |
| selection, | |
| ); | |
| const items = selectionResult.items; | |
| if (items.length === 0) { | |
| throw new Error( | |
| `No journeys selected after applying run filters. Collected ${collectedItems.length} journey(s).`, | |
| ); | |
| } | |
| const parallelAgents = parseParallelAgents(options.parallelAgents); | |
| const retries = parseRetries(options.retries); | |
| const journeyTimeoutMs = parseJourneyTimeoutMs( | |
| options.journeyTimeoutMs, | |
| ); | |
| const parallelBrowserMode = parseParallelBrowserMode( | |
| options.parallelBrowserMode, | |
| ); | |
| const browserEnvironmentOverrides = | |
| buildBrowserEnvironmentOptions(options); | |
| const uiChangeRecordingOptions = buildUiChangeRecordingOptions(options); | |
| const authPath = await resolveAuthPath(options.auth); | |
| const authLoader = createOAuthAuthLoader(authPath.path); | |
| const bookmarkSystemPrompt = options.bookmarkSystemPrompt | |
| ? await readFile(resolve(options.bookmarkSystemPrompt), "utf8") | |
| : undefined; | |
| const directorFactoryContext = { | |
| provider: options.provider, | |
| modelId: options.model, | |
| getApiKey: authLoader, | |
| }; | |
| const bookmarkCuratorFactoryContext = { | |
| provider: options.provider, | |
| modelId: options.model, | |
| getApiKey: authLoader, | |
| systemPrompt: bookmarkSystemPrompt, | |
| }; | |
| await assertFactoryOAuthCredentialsAvailable( | |
| [ | |
| factories.directors.authProvider( | |
| options.director, | |
| directorFactoryContext, | |
| ), | |
| factories.bookmarkCurators.authProvider( | |
| bookmarkCuratorName, | |
| bookmarkCuratorFactoryContext, | |
| ), | |
| ], | |
| authPath, | |
| ); | |
| const dataLifecycleConfig = options.dataLifecycle | |
| ? await readAndParseJsonFile( | |
| resolve(options.dataLifecycle), | |
| DataLifecycleConfigSchema, | |
| ) | |
| : undefined; | |
| const dataLifecycleProvider = dataLifecycleConfig | |
| ? factories.dataLifecycleProviders.create( | |
| options.dataLifecycleProvider, | |
| {}, | |
| ) | |
| : undefined; | |
| const baseOutputDir = resolve(options.out); | |
| const comparisonBaseline = options.compareTo | |
| ? await loadComparisonBaseline(resolve(options.compareTo)) | |
| : undefined; | |
| const isDirectoryRun = (await stat(journeyRoot)).isDirectory(); | |
| const runOutputDir = isDirectoryRun | |
| ? join(baseOutputDir, `${isoTimestampForPath()}-run`) | |
| : baseOutputDir; | |
| if (isDirectoryRun || dataLifecycleConfig?.suiteLifecycle) { | |
| await mkdir(runOutputDir, { recursive: true }); | |
| } | |
| const suiteRunId = isDirectoryRun | |
| ? basename(runOutputDir) | |
| : `${isoTimestampForPath()}-suite`; | |
| console.log(`Collected ${collectedItems.length} journey(s).`); | |
| if (selectionResult.summary) { | |
| console.log(`Selected ${items.length} journey(s) after filters.`); | |
| } | |
| console.log( | |
| `Parallel agents: ${Math.min(parallelAgents, items.length)}`, | |
| ); | |
| if (retries > 0) { | |
| console.log(`Retries: ${retries}`); | |
| } | |
| const sharedBrowserTabs = shouldUseSharedBrowserTabs({ | |
| browser: options.browser, | |
| parallelAgents, | |
| journeyCount: items.length, | |
| parallelBrowserMode, | |
| }); | |
| if ( | |
| parallelBrowserMode === "shared-tabs" && | |
| options.browser !== "agent-browser" && | |
| parallelAgents > 1 && | |
| items.length > 1 | |
| ) { | |
| throw new Error( | |
| '--parallel-browser-mode shared-tabs is only supported by the "agent-browser" browser driver.', | |
| ); | |
| } | |
| const sharedBrowserSessionName = sharedBrowserTabs | |
| ? parallelBrowserSessionName(options.session, suiteRunId) | |
| : undefined; | |
| if (parallelAgents > 1 && items.length > 1) { | |
| console.log( | |
| `Browser parallel mode: ${ | |
| sharedBrowserTabs ? "shared-tabs" : "isolated-sessions" | |
| }`, | |
| ); | |
| if (sharedBrowserTabs) { | |
| console.log(`Shared browser session: ${sharedBrowserSessionName}`); | |
| if (options.video ?? true) { | |
| console.log( | |
| "Shared-tab video recording is serialized per journey because agent-browser supports one active recording per session.", | |
| ); | |
| } | |
| } | |
| } | |
| if (isDirectoryRun) { | |
| console.log(`Run output: ${runOutputDir}`); | |
| } | |
| if (comparisonBaseline) { | |
| console.log( | |
| `Comparison baseline: ${comparisonBaseline.sourcePath} (${comparisonBaseline.runs.length} run(s))`, | |
| ); | |
| } | |
| if (dataLifecycleConfig) { | |
| console.log( | |
| `Data lifecycle config: ${resolve(options.dataLifecycle as string)}`, | |
| ); | |
| if (options.keepData) { | |
| console.log("Data lifecycle cleanup: skipped by --keep-data"); | |
| } | |
| } | |
| if (browserEnvironmentOverrides) { | |
| console.log( | |
| `Browser environment overrides: ${formatBrowserEnvironment(browserEnvironmentOverrides)}`, | |
| ); | |
| } | |
| if (!sharedBrowserTabs && parallelAgents > 1 && options.session) { | |
| console.log(`Browser session prefix: ${options.session}`); | |
| } | |
| let appLifecycleController: AppLifecycleController | undefined; | |
| let removeAppLifecycleSignalHandlers: (() => void) | undefined; | |
| let runtimeItems = items; | |
| let suiteLifecycleController: DataLifecycleController | undefined; | |
| let suiteLifecycleBlocked = false; | |
| let suiteRuns: Array<{ | |
| journeyFile: string; | |
| result: Awaited<ReturnType<typeof runJourney>>; | |
| }> = []; | |
| try { | |
| if (dataLifecycleConfig?.appLifecycle) { | |
| appLifecycleController = await AppLifecycleController.create({ | |
| config: dataLifecycleConfig.appLifecycle, | |
| suiteRunId, | |
| runDir: join(runOutputDir, "_app-lifecycle"), | |
| }); | |
| removeAppLifecycleSignalHandlers = | |
| installAppLifecycleSignalCleanup(appLifecycleController); | |
| console.log(""); | |
| console.log("Running app lifecycle start"); | |
| await appLifecycleController.runStart(); | |
| console.log( | |
| `App lifecycle: ${appLifecycleController.result.status}`, | |
| ); | |
| console.log( | |
| `App lifecycle artifacts: ${join(runOutputDir, "_app-lifecycle")}`, | |
| ); | |
| if (appLifecycleController.appTarget) { | |
| runtimeItems = items.map((item) => ({ | |
| ...item, | |
| journey: applyAppTargetOverride( | |
| item.journey, | |
| appLifecycleController?.appTarget, | |
| ), | |
| })); | |
| console.log( | |
| `App target: ${appLifecycleController.appTarget.baseUrl}`, | |
| ); | |
| } | |
| } | |
| if (dataLifecycleConfig?.suiteLifecycle && dataLifecycleProvider) { | |
| suiteLifecycleController = new DataLifecycleController({ | |
| definition: dataLifecycleConfig.suiteLifecycle, | |
| environments: dataLifecycleConfig.dataEnvironments, | |
| provider: dataLifecycleProvider, | |
| scope: "suite", | |
| runId: suiteRunId, | |
| runDir: join(runOutputDir, "_suite-lifecycle"), | |
| suiteRunId, | |
| keepData: options.keepData, | |
| }); | |
| console.log(""); | |
| console.log("Running suite data lifecycle setup/preflight"); | |
| try { | |
| await suiteLifecycleController.runSetupAndPreflight(); | |
| console.log( | |
| `Suite data lifecycle: ${suiteLifecycleController.result.status}`, | |
| ); | |
| } catch (error) { | |
| suiteLifecycleBlocked = true; | |
| process.exitCode = 1; | |
| const message = | |
| error instanceof Error ? error.message : String(error); | |
| console.error(`Suite data lifecycle blocked the run: ${message}`); | |
| } | |
| } | |
| if (!suiteLifecycleBlocked) { | |
| suiteRuns = await mapConcurrent( | |
| runtimeItems, | |
| parallelAgents, | |
| async (item, index) => { | |
| console.log(""); | |
| console.log( | |
| `Running ${item.journey.id} (${index + 1}/${runtimeItems.length})`, | |
| ); | |
| console.log(`Source: ${item.journeyFile}`); | |
| const result = await runJourneyWithRetries({ | |
| item, | |
| retries, | |
| outputDir: runOutputDir, | |
| createDriver: () => | |
| factories.browserDrivers.create(options.browser, {}), | |
| createDirector: () => | |
| factories.directors.create( | |
| options.director, | |
| directorFactoryContext, | |
| ), | |
| createBookmarkCurator: () => | |
| factories.bookmarkCurators.create( | |
| bookmarkCuratorName, | |
| bookmarkCuratorFactoryContext, | |
| ), | |
| video: options.video, | |
| directorTimeoutMs: journeyTimeoutMs, | |
| uiChangeRecording: options.uiChangeRecording, | |
| uiChangeRecordingOptions, | |
| sessionName: browserSessionNameForRun({ | |
| baseSession: options.session, | |
| parallelAgents, | |
| journeyId: item.journey.id, | |
| index, | |
| suiteRunId, | |
| sharedBrowserTabs, | |
| }), | |
| tabLabel: browserTabLabelForRun({ | |
| parallelAgents, | |
| journeyId: item.journey.id, | |
| index, | |
| sharedBrowserTabs, | |
| }), | |
| statePath: options.state ? resolve(options.state) : undefined, | |
| headed: options.headed, | |
| browserEnvironment: mergeBrowserEnvironmentOptions( | |
| item.journey.browserEnvironment, | |
| browserEnvironmentOverrides, | |
| ), | |
| ...(dataLifecycleConfig && dataLifecycleProvider | |
| ? { | |
| dataLifecycle: { | |
| environments: dataLifecycleConfig.dataEnvironments, | |
| provider: dataLifecycleProvider, | |
| keepData: options.keepData, | |
| suiteRunId, | |
| suiteManifest: suiteLifecycleController?.manifest, | |
| }, | |
| } | |
| : {}), | |
| }); | |
| printRunResult(result); | |
| if (!runPassed(result)) { | |
| process.exitCode = 1; | |
| } | |
| return { journeyFile: item.journeyFile, result }; | |
| }, | |
| ); | |
| } | |
| } finally { | |
| if (suiteLifecycleController) { | |
| if (!suiteLifecycleBlocked) { | |
| await suiteLifecycleController.runPostconditions(); | |
| } | |
| await suiteLifecycleController.runCleanup(); | |
| suiteLifecycleController.finish(); | |
| console.log(""); | |
| console.log( | |
| `Suite data lifecycle: ${suiteLifecycleController.result.status}`, | |
| ); | |
| console.log( | |
| `Suite lifecycle artifacts: ${join(runOutputDir, "_suite-lifecycle")}`, | |
| ); | |
| if ( | |
| suiteLifecycleController.result.status === "failed" || | |
| suiteLifecycleController.result.status === "blocked" | |
| ) { | |
| process.exitCode = 1; | |
| } | |
| } | |
| if (appLifecycleController) { | |
| await appLifecycleController.runCleanup(); | |
| console.log(""); | |
| console.log( | |
| `App lifecycle: ${appLifecycleController.result.status}`, | |
| ); | |
| console.log( | |
| `App lifecycle artifacts: ${join(runOutputDir, "_app-lifecycle")}`, | |
| ); | |
| if (appLifecycleController.result.status === "failed") { | |
| process.exitCode = 1; | |
| } | |
| } | |
| if (removeAppLifecycleSignalHandlers) { | |
| removeAppLifecycleSignalHandlers(); | |
| } | |
| } | |
| if (suiteLifecycleBlocked) { | |
| await writeCliCiOutputs({ | |
| suiteRunId, | |
| suiteRuns, | |
| junitPath: options.junit, | |
| summaryJsonPath: options.summaryJson, | |
| githubAnnotations: options.githubAnnotations, | |
| }); | |
| return; | |
| } | |
| if (isDirectoryRun) { | |
| const suiteArtifacts = await writeSuiteArtifacts( | |
| runOutputDir, | |
| suiteRuns, | |
| comparisonBaseline, | |
| selectionResult.summary, | |
| ); | |
| console.log(""); | |
| console.log(`Run dashboard: ${suiteArtifacts.dashboard}`); | |
| console.log( | |
| `Run dashboard URL: ${pathToFileURL(suiteArtifacts.dashboard).href}`, | |
| ); | |
| console.log(`Run history: ${suiteArtifacts.history}`); | |
| console.log(""); | |
| console.log("Journey dashboards:"); | |
| for (const run of suiteRuns) { | |
| console.log( | |
| `- ${run.result.journeyId}: ${run.result.artifacts.dashboard}`, | |
| ); | |
| } | |
| } | |
| await writeCliCiOutputs({ | |
| suiteRunId, | |
| suiteRuns, | |
| junitPath: options.junit, | |
| summaryJsonPath: options.summaryJson, | |
| githubAnnotations: options.githubAnnotations, | |
| }); | |
| }, | |
| ); | |
| for (const name of ["serve", "ui", "space"] as const) program.command(name).description(`${name} the JourneyTest service.`).option("--host <host>", "Bind host.", "0.0.0.0").option("--port <port>", "Bind port.", "7860").option("--cognition-profile <profile>", "Runtime-only cognition profile override.").action(async (options: { host: string; port: string; cognitionProfile?: string }) => runServerCommand(name, options)); | |
| program.command("info").description("Print JourneyTest runtime information without secret values.").option("--json", "Print JSON.").action(async (options: { json?: boolean }) => { const config = loadRuntimeCognitionConfig(); const data = { version: "0.1.2", space: isHuggingFaceSpace(), layaMode: config.localLaya.mode, ...cognitionConfigStatus(config) }; options.json ? process.stdout.write(`${JSON.stringify(data)}\n`) : process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); }); | |
| program.command("doctor").description("Check browser, Laya, artifacts, and service dependencies.").option("--smoke-model", "Require a ready SystemOne endpoint without downloading a model.").option("--smoke-reasoning", "Make one explicit live cloud reasoning request.").option("--json", "Print machine-readable diagnostics.").action(async (options: { smokeModel?: boolean; smokeReasoning?: boolean; json?: boolean }) => runDoctor(Boolean(options.smokeModel), Boolean(options.json), Boolean(options.smokeReasoning))); | |
| await program.parseAsync(argv); | |
| } | |
| type StoredOAuthAuth = Record<string, OAuthCredentials & { type?: string }>; | |
| export type AuthPathSource = "cli" | "env" | "local" | "user-config"; | |
| export interface AuthPathResolution { | |
| path: string; | |
| source: AuthPathSource; | |
| localPath: string; | |
| userConfigPath: string; | |
| } | |
| export interface AuthPathResolutionOptions { | |
| cwd?: string; | |
| env?: NodeJS.ProcessEnv; | |
| homeDir?: string; | |
| platform?: NodeJS.Platform; | |
| fileExists?: (path: string) => Promise<boolean>; | |
| } | |
| export interface OAuthAuthStatus { | |
| path: string; | |
| source: AuthPathSource; | |
| exists: boolean; | |
| configuredProviders: string[]; | |
| availableOAuthProviders: string[]; | |
| } | |
| export interface RunnableJourney { | |
| journeyFile: string; | |
| journey: UserJourney; | |
| profile: TesterProfile; | |
| } | |
| interface ComparisonBaseline { | |
| sourcePath: string; | |
| kind: "run" | "suite"; | |
| runs: RunResult[]; | |
| } | |
| export interface RerunFailedSelection { | |
| sourcePath: string; | |
| unhealthyJourneyIds: string[]; | |
| } | |
| export interface JourneySelection { | |
| tags: string[]; | |
| excludeTags: string[]; | |
| journeyIds: string[]; | |
| excludeJourneyIds: string[]; | |
| rerunFailed?: RerunFailedSelection; | |
| shard?: ShardSelection; | |
| } | |
| export interface ShardSelection { | |
| index: number; | |
| total: number; | |
| } | |
| export interface JourneySelectionResult { | |
| items: RunnableJourney[]; | |
| summary?: SuiteSelectionSummary; | |
| } | |
| export function parseParallelAgents(value: string): number { | |
| const parsed = Number(value); | |
| if (!Number.isInteger(parsed) || parsed < 1) { | |
| throw new Error( | |
| `--parallel-agents must be a positive integer. Received "${value}".`, | |
| ); | |
| } | |
| return parsed; | |
| } | |
| export function parseParallelBrowserMode(value: string): ParallelBrowserMode { | |
| if (value === "shared-tabs" || value === "isolated-sessions") { | |
| return value; | |
| } | |
| throw new Error( | |
| `--parallel-browser-mode must be "shared-tabs" or "isolated-sessions". Received "${value}".`, | |
| ); | |
| } | |
| export function parseRetries(value: string): number { | |
| const parsed = Number(value); | |
| if (!Number.isInteger(parsed) || parsed < 0) { | |
| throw new Error(`--retries must be a non-negative integer. Received "${value}".`); | |
| } | |
| return parsed; | |
| } | |
| export function parseViewportOption( | |
| value: string, | |
| ): BrowserEnvironment["viewport"] { | |
| const match = /^(\d+)x(\d+)(?:@(\d+(?:\.\d+)?))?$/.exec(value.trim()); | |
| if (!match) { | |
| throw new Error( | |
| `--viewport must use <width>x<height>, optionally <width>x<height>@<scale>. Received "${value}".`, | |
| ); | |
| } | |
| const width = Number(match[1]); | |
| const height = Number(match[2]); | |
| const deviceScaleFactor = match[3] ? Number(match[3]) : undefined; | |
| if ( | |
| !Number.isInteger(width) || | |
| width < 1 || | |
| !Number.isInteger(height) || | |
| height < 1 || | |
| (deviceScaleFactor !== undefined && | |
| (!Number.isFinite(deviceScaleFactor) || deviceScaleFactor <= 0)) | |
| ) { | |
| throw new Error( | |
| `--viewport must use positive dimensions and scale. Received "${value}".`, | |
| ); | |
| } | |
| return { | |
| width, | |
| height, | |
| ...(deviceScaleFactor === undefined ? {} : { deviceScaleFactor }), | |
| }; | |
| } | |
| export function parseBrowserDevicePreset(value: string): BrowserDevicePreset { | |
| if (value === "iphone-14" || value === "pixel-7" || value === "ipad-pro-11") { | |
| return value; | |
| } | |
| throw new Error( | |
| `--device must be one of: iphone-14, pixel-7, ipad-pro-11. Received "${value}".`, | |
| ); | |
| } | |
| export function buildBrowserEnvironmentOptions(options: { | |
| viewport?: string; | |
| device?: string; | |
| }): BrowserEnvironment | undefined { | |
| const environment: BrowserEnvironment = { | |
| ...(options.device ? { device: parseBrowserDevicePreset(options.device) } : {}), | |
| ...(options.viewport ? { viewport: parseViewportOption(options.viewport) } : {}), | |
| }; | |
| return Object.keys(environment).length > 0 ? environment : undefined; | |
| } | |
| export function mergeBrowserEnvironmentOptions( | |
| defaults: BrowserEnvironment | undefined, | |
| overrides: BrowserEnvironment | undefined, | |
| ): BrowserEnvironment | undefined { | |
| const viewport = mergeViewportOptions(defaults?.viewport, overrides?.viewport); | |
| const environment: BrowserEnvironment = { | |
| ...(defaults ?? {}), | |
| ...(overrides ?? {}), | |
| ...(viewport ? { viewport } : {}), | |
| }; | |
| return Object.keys(environment).length > 0 ? environment : undefined; | |
| } | |
| function mergeViewportOptions( | |
| defaults: BrowserEnvironment["viewport"] | undefined, | |
| overrides: BrowserEnvironment["viewport"] | undefined, | |
| ): BrowserEnvironment["viewport"] | undefined { | |
| if (!defaults && !overrides) { | |
| return undefined; | |
| } | |
| const width = overrides?.width ?? defaults?.width; | |
| const height = overrides?.height ?? defaults?.height; | |
| if (width === undefined || height === undefined) { | |
| return undefined; | |
| } | |
| const deviceScaleFactor = overrides?.deviceScaleFactor ?? defaults?.deviceScaleFactor; | |
| return { | |
| width, | |
| height, | |
| ...(deviceScaleFactor === undefined ? {} : { deviceScaleFactor }), | |
| }; | |
| } | |
| function formatBrowserEnvironment(environment: BrowserEnvironment): string { | |
| const parts = [ | |
| environment.device ? `device=${environment.device}` : undefined, | |
| environment.viewport | |
| ? `viewport=${environment.viewport.width}x${environment.viewport.height}${ | |
| environment.viewport.deviceScaleFactor === undefined | |
| ? "" | |
| : `@${environment.viewport.deviceScaleFactor}` | |
| }` | |
| : undefined, | |
| ].filter(Boolean); | |
| return parts.join(", "); | |
| } | |
| export function buildJourneySelection(options: { | |
| tags?: string[]; | |
| excludeTags?: string[]; | |
| journeyIds?: string[]; | |
| excludeJourneyIds?: string[]; | |
| rerunFailed?: RerunFailedSelection; | |
| shard?: string; | |
| }): JourneySelection { | |
| const tags = uniqueSelectorValues("--tag", options.tags ?? []); | |
| const excludeTags = uniqueSelectorValues( | |
| "--exclude-tag", | |
| options.excludeTags ?? [], | |
| ); | |
| const journeyIds = uniqueSelectorValues( | |
| "--journey-id", | |
| options.journeyIds ?? [], | |
| ); | |
| const excludeJourneyIds = uniqueSelectorValues( | |
| "--exclude-journey-id", | |
| options.excludeJourneyIds ?? [], | |
| ); | |
| assertDisjointSelectors("--tag", tags, "--exclude-tag", excludeTags); | |
| assertDisjointSelectors( | |
| "--journey-id", | |
| journeyIds, | |
| "--exclude-journey-id", | |
| excludeJourneyIds, | |
| ); | |
| return { | |
| tags, | |
| excludeTags, | |
| journeyIds, | |
| excludeJourneyIds, | |
| ...(options.rerunFailed ? { rerunFailed: options.rerunFailed } : {}), | |
| ...(options.shard ? { shard: parseShardSelection(options.shard) } : {}), | |
| }; | |
| } | |
| export function parseShardSelection(value: string): ShardSelection { | |
| const match = /^([1-9]\d*)\/([1-9]\d*)$/.exec(value); | |
| if (!match) { | |
| throw new Error( | |
| `--shard must use the format <index>/<total> with positive integers. Received "${value}".`, | |
| ); | |
| } | |
| const index = Number(match[1]); | |
| const total = Number(match[2]); | |
| if (index > total) { | |
| throw new Error( | |
| `--shard index must be less than or equal to total. Received "${value}".`, | |
| ); | |
| } | |
| return { index, total }; | |
| } | |
| export function selectRunnableJourneys( | |
| items: RunnableJourney[], | |
| selection: JourneySelection, | |
| ): JourneySelectionResult { | |
| validateKnownJourneyIds(items, selection.journeyIds, "--journey-id"); | |
| validateKnownJourneyIds( | |
| items, | |
| selection.excludeJourneyIds, | |
| "--exclude-journey-id", | |
| ); | |
| const rerunIds = selection.rerunFailed | |
| ? new Set(selection.rerunFailed.unhealthyJourneyIds) | |
| : undefined; | |
| let selected = items.filter((item) => { | |
| const journeyTags = new Set(item.journey.tags ?? []); | |
| if ( | |
| selection.journeyIds.length > 0 && | |
| !selection.journeyIds.includes(item.journey.id) | |
| ) { | |
| return false; | |
| } | |
| if (selection.excludeJourneyIds.includes(item.journey.id)) { | |
| return false; | |
| } | |
| if ( | |
| selection.tags.length > 0 && | |
| !selection.tags.some((tag) => journeyTags.has(tag)) | |
| ) { | |
| return false; | |
| } | |
| if (selection.excludeTags.some((tag) => journeyTags.has(tag))) { | |
| return false; | |
| } | |
| if (rerunIds && !rerunIds.has(item.journey.id)) { | |
| return false; | |
| } | |
| return true; | |
| }); | |
| if (selection.shard) { | |
| selected = selected.filter( | |
| (_item, index) => | |
| index % selection.shard!.total === selection.shard!.index - 1, | |
| ); | |
| } | |
| const summary = buildSelectionSummary( | |
| items.length, | |
| selected.length, | |
| selection, | |
| ); | |
| return { items: selected, ...(summary ? { summary } : {}) }; | |
| } | |
| function buildSelectionSummary( | |
| collected: number, | |
| selected: number, | |
| selection: JourneySelection, | |
| ): SuiteSelectionSummary | undefined { | |
| const hasSelection = | |
| selection.tags.length > 0 || | |
| selection.excludeTags.length > 0 || | |
| selection.journeyIds.length > 0 || | |
| selection.excludeJourneyIds.length > 0 || | |
| Boolean(selection.rerunFailed) || | |
| Boolean(selection.shard); | |
| if (!hasSelection) { | |
| return undefined; | |
| } | |
| return { | |
| collected, | |
| selected, | |
| ...(selection.tags.length > 0 ? { tags: selection.tags } : {}), | |
| ...(selection.excludeTags.length > 0 | |
| ? { excludeTags: selection.excludeTags } | |
| : {}), | |
| ...(selection.journeyIds.length > 0 | |
| ? { journeyIds: selection.journeyIds } | |
| : {}), | |
| ...(selection.excludeJourneyIds.length > 0 | |
| ? { excludeJourneyIds: selection.excludeJourneyIds } | |
| : {}), | |
| ...(selection.rerunFailed | |
| ? { | |
| rerunFailed: { | |
| path: selection.rerunFailed.sourcePath, | |
| unhealthyJourneyIds: selection.rerunFailed.unhealthyJourneyIds, | |
| }, | |
| } | |
| : {}), | |
| ...(selection.shard ? { shard: selection.shard } : {}), | |
| }; | |
| } | |
| function uniqueSelectorValues(optionName: string, values: string[]): string[] { | |
| const trimmed = values.map((value) => value.trim()); | |
| if (trimmed.some((value) => value.length === 0)) { | |
| throw new Error(`${optionName} values must be non-empty.`); | |
| } | |
| return [...new Set(trimmed)]; | |
| } | |
| function assertDisjointSelectors( | |
| includeName: string, | |
| includeValues: string[], | |
| excludeName: string, | |
| excludeValues: string[], | |
| ): void { | |
| const excludeSet = new Set(excludeValues); | |
| const overlaps = includeValues.filter((value) => excludeSet.has(value)); | |
| if (overlaps.length > 0) { | |
| throw new Error( | |
| `${includeName} and ${excludeName} cannot contain the same value(s): ${overlaps.join(", ")}.`, | |
| ); | |
| } | |
| } | |
| function validateKnownJourneyIds( | |
| items: RunnableJourney[], | |
| ids: string[], | |
| optionName: string, | |
| ): void { | |
| if (ids.length === 0) { | |
| return; | |
| } | |
| const knownIds = new Set(items.map((item) => item.journey.id)); | |
| const unknown = ids.filter((id) => !knownIds.has(id)); | |
| if (unknown.length > 0) { | |
| throw new Error( | |
| `${optionName} referenced journey id(s) that were not collected: ${unknown.join(", ")}.`, | |
| ); | |
| } | |
| } | |
| export function parseDataOptions(values: string[]): Record<string, unknown> { | |
| const data: Record<string, unknown> = {}; | |
| for (const value of values) { | |
| const equalsIndex = value.indexOf("="); | |
| if (equalsIndex <= 0) { | |
| throw new Error( | |
| `--data values must use key=value syntax. Received "${value}".`, | |
| ); | |
| } | |
| const key = value.slice(0, equalsIndex).trim(); | |
| if (!key) { | |
| throw new Error( | |
| `--data values must include a non-empty key. Received "${value}".`, | |
| ); | |
| } | |
| const rawValue = value.slice(equalsIndex + 1); | |
| data[key] = parseJsonLikeValue(rawValue); | |
| } | |
| return data; | |
| } | |
| function parseJsonLikeValue(value: string): unknown { | |
| try { | |
| return JSON.parse(value) as unknown; | |
| } catch { | |
| return value; | |
| } | |
| } | |
| export function buildUiChangeRecordingOptions(options: { | |
| uiChangeTimeoutMs?: string; | |
| uiChangeQuietMs?: string; | |
| uiChangeMaxChanges?: string; | |
| uiChangeMaxScreenshots?: string; | |
| uiChangeScreenshots?: boolean; | |
| uiChangeSnapshots?: boolean; | |
| uiChangeDomSnapshots?: boolean; | |
| }): UiChangeRecordingOptions | undefined { | |
| const recordingOptions: UiChangeRecordingOptions = { | |
| ...(options.uiChangeTimeoutMs | |
| ? { | |
| timeoutMs: parsePositiveIntegerOption( | |
| "--ui-change-timeout-ms", | |
| options.uiChangeTimeoutMs, | |
| ), | |
| } | |
| : {}), | |
| ...(options.uiChangeQuietMs | |
| ? { | |
| quietMs: parsePositiveIntegerOption( | |
| "--ui-change-quiet-ms", | |
| options.uiChangeQuietMs, | |
| ), | |
| } | |
| : {}), | |
| ...(options.uiChangeMaxChanges | |
| ? { | |
| maxChanges: parsePositiveIntegerOption( | |
| "--ui-change-max-changes", | |
| options.uiChangeMaxChanges, | |
| ), | |
| } | |
| : {}), | |
| ...(options.uiChangeMaxScreenshots | |
| ? { | |
| maxScreenshots: parseNonNegativeIntegerOption( | |
| "--ui-change-max-screenshots", | |
| options.uiChangeMaxScreenshots, | |
| ), | |
| } | |
| : {}), | |
| ...(options.uiChangeScreenshots === undefined | |
| ? {} | |
| : { screenshots: options.uiChangeScreenshots }), | |
| ...(options.uiChangeSnapshots === undefined | |
| ? {} | |
| : { snapshots: options.uiChangeSnapshots }), | |
| ...(options.uiChangeDomSnapshots === undefined | |
| ? {} | |
| : { domSnapshots: options.uiChangeDomSnapshots }), | |
| }; | |
| return Object.keys(recordingOptions).length > 0 | |
| ? recordingOptions | |
| : undefined; | |
| } | |
| export function parseJourneyTimeoutMs(value: string): number | undefined { | |
| if (value === "0") { | |
| return undefined; | |
| } | |
| return parsePositiveIntegerOption("--journey-timeout-ms", value); | |
| } | |
| function parsePositiveIntegerOption(name: string, value: string): number { | |
| const parsed = Number(value); | |
| if (!Number.isInteger(parsed) || parsed < 1) { | |
| throw new Error(`${name} must be a positive integer. Received "${value}".`); | |
| } | |
| return parsed; | |
| } | |
| function parseNonNegativeIntegerOption(name: string, value: string): number { | |
| const parsed = Number(value); | |
| if (!Number.isInteger(parsed) || parsed < 0) { | |
| throw new Error( | |
| `${name} must be a non-negative integer. Received "${value}".`, | |
| ); | |
| } | |
| return parsed; | |
| } | |
| export function sessionNameForRun( | |
| baseSession: string | undefined, | |
| parallelAgents: number, | |
| journeyId: string, | |
| index: number, | |
| ): string | undefined { | |
| if (!baseSession) { | |
| return undefined; | |
| } | |
| if (parallelAgents <= 1) { | |
| return baseSession; | |
| } | |
| return `${baseSession}-${String(index + 1).padStart(2, "0")}-${sanitizePathSegment(journeyId)}`; | |
| } | |
| export function shouldUseSharedBrowserTabs(options: { | |
| browser: string; | |
| parallelAgents: number; | |
| journeyCount: number; | |
| parallelBrowserMode: ParallelBrowserMode; | |
| }): boolean { | |
| return ( | |
| options.browser === "agent-browser" && | |
| options.parallelAgents > 1 && | |
| options.journeyCount > 1 && | |
| options.parallelBrowserMode === "shared-tabs" | |
| ); | |
| } | |
| export function parallelBrowserSessionName( | |
| baseSession: string | undefined, | |
| suiteRunId: string, | |
| ): string { | |
| return baseSession ?? suiteRunId; | |
| } | |
| export function browserSessionNameForRun(options: { | |
| baseSession: string | undefined; | |
| parallelAgents: number; | |
| journeyId: string; | |
| index: number; | |
| suiteRunId: string; | |
| sharedBrowserTabs: boolean; | |
| }): string | undefined { | |
| if (options.sharedBrowserTabs) { | |
| return parallelBrowserSessionName(options.baseSession, options.suiteRunId); | |
| } | |
| return sessionNameForRun( | |
| options.baseSession, | |
| options.parallelAgents, | |
| options.journeyId, | |
| options.index, | |
| ); | |
| } | |
| export function browserTabLabelForRun(options: { | |
| parallelAgents: number; | |
| journeyId: string; | |
| index: number; | |
| sharedBrowserTabs: boolean; | |
| }): string | undefined { | |
| if (!options.sharedBrowserTabs) { | |
| return undefined; | |
| } | |
| return `journey-${String(options.index + 1).padStart(2, "0")}-${sanitizePathSegment(options.journeyId)}`; | |
| } | |
| export function bookmarkCuratorNameForOptions(options: { | |
| bookmarkCurator?: string; | |
| curateBookmarks?: boolean; | |
| }): string { | |
| if (options.curateBookmarks === false) { | |
| return "none"; | |
| } | |
| return options.bookmarkCurator ?? "pi"; | |
| } | |
| interface RunJourneyWithRetriesOptions { | |
| item: RunnableJourney; | |
| retries: number; | |
| outputDir: string; | |
| createDriver: () => BrowserDriver; | |
| createDirector: () => AgentDirector; | |
| createBookmarkCurator: () => BookmarkCurator | undefined; | |
| video?: boolean; | |
| directorTimeoutMs?: number; | |
| uiChangeRecording?: boolean; | |
| uiChangeRecordingOptions?: UiChangeRecordingOptions; | |
| sessionName?: string; | |
| tabLabel?: string; | |
| statePath?: string; | |
| headed?: boolean; | |
| browserEnvironment?: BrowserEnvironment; | |
| dataLifecycle?: RunJourneyOptions["dataLifecycle"]; | |
| } | |
| async function runJourneyWithRetries( | |
| options: RunJourneyWithRetriesOptions, | |
| ): Promise<RunResult> { | |
| const maxAttempts = options.retries + 1; | |
| const attempts: RunAttempt[] = []; | |
| let finalResult: RunResult | undefined; | |
| for (let attempt = 1; attempt <= maxAttempts; attempt++) { | |
| if (attempt > 1) { | |
| console.log( | |
| `Retrying ${options.item.journey.id} (${attempt}/${maxAttempts})`, | |
| ); | |
| } | |
| const result = await runJourney({ | |
| journey: options.item.journey, | |
| profile: options.item.profile, | |
| driver: options.createDriver(), | |
| director: options.createDirector(), | |
| outputDir: options.outputDir, | |
| video: options.video, | |
| directorTimeoutMs: options.directorTimeoutMs, | |
| uiChangeRecording: options.uiChangeRecording, | |
| uiChangeRecordingOptions: options.uiChangeRecordingOptions, | |
| sessionName: attemptScopedValue(options.sessionName, attempt), | |
| tabLabel: attemptScopedValue(options.tabLabel, attempt), | |
| statePath: options.statePath, | |
| headed: options.headed, | |
| browserEnvironment: options.browserEnvironment, | |
| bookmarkCurator: options.createBookmarkCurator(), | |
| dataLifecycle: options.dataLifecycle, | |
| }); | |
| attempts.push(createRunAttempt(attempt, result)); | |
| finalResult = result; | |
| if (runPassed(result)) { | |
| break; | |
| } | |
| } | |
| if (!finalResult) { | |
| throw new Error(`No attempts were run for ${options.item.journey.id}.`); | |
| } | |
| const annotated = annotateFinalRunResult({ | |
| result: finalResult, | |
| attempts, | |
| retries: options.retries, | |
| quarantined: options.item.journey.quarantined ?? false, | |
| }); | |
| await persistRunResult(annotated); | |
| return annotated; | |
| } | |
| function annotateFinalRunResult(options: { | |
| result: RunResult; | |
| attempts: RunAttempt[]; | |
| retries: number; | |
| quarantined: boolean; | |
| }): RunResult { | |
| const passedAttempt = options.attempts.find((attempt) => attempt.passed); | |
| const failedAttemptsBeforePass = passedAttempt | |
| ? options.attempts.filter( | |
| (attempt) => attempt.attempt < passedAttempt.attempt && !attempt.passed, | |
| ).length | |
| : 0; | |
| return { | |
| ...options.result, | |
| attempts: options.attempts, | |
| flake: { | |
| isFlaky: Boolean(passedAttempt && failedAttemptsBeforePass > 0), | |
| retries: options.retries, | |
| attempts: options.attempts.length, | |
| ...(passedAttempt ? { passedAttempt: passedAttempt.attempt } : {}), | |
| failedAttemptsBeforePass, | |
| }, | |
| ...(options.quarantined | |
| ? { quarantine: { quarantined: true } } | |
| : options.result.quarantine | |
| ? { quarantine: options.result.quarantine } | |
| : {}), | |
| }; | |
| } | |
| function createRunAttempt(attempt: number, result: RunResult): RunAttempt { | |
| return { | |
| attempt, | |
| runId: result.runId, | |
| runStatus: result.runStatus, | |
| ...(result.verdict?.status ? { verdictStatus: result.verdict.status } : {}), | |
| ...(result.dataLifecycle?.status | |
| ? { dataLifecycleStatus: result.dataLifecycle.status } | |
| : {}), | |
| passed: runPassed(result), | |
| startedAt: result.startedAt, | |
| endedAt: result.endedAt, | |
| durationMs: result.durationMs, | |
| dashboard: result.artifacts.dashboard, | |
| result: result.artifacts.result, | |
| ...(result.verdict?.summary || result.error?.message | |
| ? { summary: result.verdict?.summary ?? result.error?.message } | |
| : {}), | |
| }; | |
| } | |
| function runPassed(result: RunResult): boolean { | |
| return ( | |
| result.runStatus === "completed" && | |
| result.verdict?.status === "passed" && | |
| result.dataLifecycle?.status !== "failed" && | |
| result.dataLifecycle?.status !== "blocked" | |
| ); | |
| } | |
| function attemptScopedValue( | |
| value: string | undefined, | |
| attempt: number, | |
| ): string | undefined { | |
| if (!value || attempt === 1) { | |
| return value; | |
| } | |
| return `${value}-attempt-${attempt}`; | |
| } | |
| async function persistRunResult(result: RunResult): Promise<void> { | |
| await writeFile( | |
| result.artifacts.result, | |
| `${JSON.stringify(result, null, 2)}\n`, | |
| "utf8", | |
| ); | |
| } | |
| async function mapConcurrent<T, TResult>( | |
| items: T[], | |
| concurrency: number, | |
| mapper: (item: T, index: number) => Promise<TResult>, | |
| ): Promise<TResult[]> { | |
| const results = new Array<TResult>(items.length); | |
| let nextIndex = 0; | |
| const workerCount = Math.min(concurrency, items.length); | |
| await Promise.all( | |
| Array.from({ length: workerCount }, async () => { | |
| while (nextIndex < items.length) { | |
| const index = nextIndex++; | |
| results[index] = await mapper(items[index] as T, index); | |
| } | |
| }), | |
| ); | |
| return results; | |
| } | |
| function createOAuthAuthLoader( | |
| authPath: string, | |
| ): (provider: string) => Promise<string | undefined> { | |
| let cachedAuth: StoredOAuthAuth | undefined; | |
| let queue: Promise<void> = Promise.resolve(); | |
| return async (provider: string) => { | |
| const operation = queue.then(async () => { | |
| const auth = cachedAuth ?? (cachedAuth = await readOAuthAuth(authPath)); | |
| const result = await getOAuthApiKey(provider, auth); | |
| if (!result) { | |
| return undefined; | |
| } | |
| if (auth[provider] !== result.newCredentials) { | |
| auth[provider] = { type: "oauth", ...result.newCredentials }; | |
| cachedAuth = auth; | |
| await mkdir(dirname(authPath), { recursive: true }); | |
| await writeFile(authPath, `${JSON.stringify(auth, null, 2)}\n`, "utf8"); | |
| } | |
| return result.apiKey; | |
| }); | |
| queue = operation.then( | |
| () => undefined, | |
| () => undefined, | |
| ); | |
| return operation; | |
| }; | |
| } | |
| export async function getOAuthAuthStatus( | |
| authPath: AuthPathResolution, | |
| ): Promise<OAuthAuthStatus> { | |
| const exists = await pathExists(authPath.path); | |
| const auth = await readOAuthAuth(authPath.path); | |
| return { | |
| path: authPath.path, | |
| source: authPath.source, | |
| exists, | |
| configuredProviders: Object.keys(auth).sort(), | |
| availableOAuthProviders: getOAuthProviders() | |
| .map((provider) => provider.id) | |
| .sort(), | |
| }; | |
| } | |
| async function assertFactoryOAuthCredentialsAvailable( | |
| providers: Array<string | undefined>, | |
| authPath: AuthPathResolution, | |
| ): Promise<void> { | |
| const authProviders = new Set( | |
| providers.filter((provider): provider is string => Boolean(provider)), | |
| ); | |
| await Promise.all( | |
| [...authProviders].map((provider) => | |
| assertOAuthCredentialsAvailable(provider, authPath), | |
| ), | |
| ); | |
| } | |
| async function assertOAuthCredentialsAvailable( | |
| provider: string, | |
| authPath: AuthPathResolution, | |
| ): Promise<void> { | |
| if (!getOAuthProvider(provider)) { | |
| return; | |
| } | |
| const auth = await readOAuthAuth(authPath.path); | |
| if (auth[provider]) { | |
| return; | |
| } | |
| throw new Error(formatMissingOAuthCredentialsMessage(provider, authPath)); | |
| } | |
| async function readOAuthAuth(authPath: string): Promise<StoredOAuthAuth> { | |
| try { | |
| return JSON.parse(await readFile(authPath, "utf8")) as StoredOAuthAuth; | |
| } catch (error) { | |
| const code = | |
| typeof error === "object" && error && "code" in error | |
| ? error.code | |
| : undefined; | |
| if (code === "ENOENT") { | |
| return {}; | |
| } | |
| const message = error instanceof Error ? error.message : String(error); | |
| if (error instanceof SyntaxError) { | |
| throw new Error( | |
| `Could not parse OAuth auth file at ${authPath}: ${message}`, | |
| ); | |
| } | |
| throw new Error( | |
| `Could not read OAuth auth file at ${authPath}: ${message}`, | |
| ); | |
| } | |
| } | |
| export async function resolveAuthPath( | |
| authPathOption?: string, | |
| options: AuthPathResolutionOptions = {}, | |
| ): Promise<AuthPathResolution> { | |
| const cwd = options.cwd ?? process.cwd(); | |
| const env = options.env ?? process.env; | |
| const localPath = resolve(cwd, "auth.json"); | |
| const userConfigPath = defaultUserAuthPath({ | |
| env, | |
| homeDir: options.homeDir, | |
| platform: options.platform, | |
| }); | |
| if (authPathOption) { | |
| return { | |
| path: resolve(cwd, authPathOption), | |
| source: "cli", | |
| localPath, | |
| userConfigPath, | |
| }; | |
| } | |
| if (env.JOURNEYTEST_AUTH_PATH) { | |
| return { | |
| path: resolve(cwd, env.JOURNEYTEST_AUTH_PATH), | |
| source: "env", | |
| localPath, | |
| userConfigPath, | |
| }; | |
| } | |
| const fileExists = options.fileExists ?? pathExists; | |
| if (await fileExists(localPath)) { | |
| return { | |
| path: localPath, | |
| source: "local", | |
| localPath, | |
| userConfigPath, | |
| }; | |
| } | |
| return { | |
| path: userConfigPath, | |
| source: "user-config", | |
| localPath, | |
| userConfigPath, | |
| }; | |
| } | |
| export function defaultUserAuthPath( | |
| options: { | |
| env?: NodeJS.ProcessEnv; | |
| homeDir?: string; | |
| platform?: NodeJS.Platform; | |
| } = {}, | |
| ): string { | |
| const env = options.env ?? process.env; | |
| const home = options.homeDir ?? homedir(); | |
| const platform = options.platform ?? process.platform; | |
| if (platform === "darwin") { | |
| return join( | |
| home, | |
| "Library", | |
| "Application Support", | |
| "JourneyTest", | |
| "auth.json", | |
| ); | |
| } | |
| if (platform === "win32") { | |
| return join( | |
| env.APPDATA ?? join(home, "AppData", "Roaming"), | |
| "JourneyTest", | |
| "auth.json", | |
| ); | |
| } | |
| return join( | |
| env.XDG_CONFIG_HOME ?? join(home, ".config"), | |
| "journeytest", | |
| "auth.json", | |
| ); | |
| } | |
| export function formatMissingOAuthCredentialsMessage( | |
| provider: string, | |
| authPath: AuthPathResolution, | |
| ): string { | |
| const authDir = dirname(authPath.path); | |
| const customFileNote = | |
| basename(authPath.path) === "auth.json" | |
| ? "" | |
| : `\nThe Pi login CLI writes auth.json; move it to ${authPath.path} after login if you keep this custom filename.`; | |
| return ( | |
| [ | |
| `No OAuth credentials found for "${provider}" in ${authPath.path} (${authPathSourceLabel(authPath.source)}).`, | |
| `Create credentials with:`, | |
| ` mkdir -p "${authDir}"`, | |
| ` (cd "${authDir}" && npx @earendil-works/pi-ai login ${provider})`, | |
| `Override the auth file with --auth <path> or JOURNEYTEST_AUTH_PATH.`, | |
| `Existing local auth files are still supported at ${authPath.localPath}.`, | |
| ].join("\n") + customFileNote | |
| ); | |
| } | |
| function authPathSourceLabel(source: AuthPathSource): string { | |
| if (source === "cli") { | |
| return "--auth"; | |
| } | |
| if (source === "env") { | |
| return "JOURNEYTEST_AUTH_PATH"; | |
| } | |
| if (source === "local") { | |
| return "existing local auth.json"; | |
| } | |
| return "user config"; | |
| } | |
| async function pathExists(path: string): Promise<boolean> { | |
| try { | |
| await access(path); | |
| return true; | |
| } catch (error) { | |
| const code = | |
| typeof error === "object" && error && "code" in error | |
| ? error.code | |
| : undefined; | |
| if (code === "ENOENT") { | |
| return false; | |
| } | |
| throw error; | |
| } | |
| } | |
| async function validateFiles( | |
| journeyPath: string, | |
| profilePath: string, | |
| ): Promise<{ issueCount: number }> { | |
| const profileFiles = await collectJsonFiles(resolve(profilePath)); | |
| const journeyFiles = await collectJsonFiles(resolve(journeyPath)); | |
| const profiles = new Map<string, TesterProfile>(); | |
| let issueCount = 0; | |
| for (const file of profileFiles) { | |
| try { | |
| const profile = await readAndParseJsonFile(file, TesterProfileSchema); | |
| profiles.set(profile.id, profile); | |
| console.log(`profile ok: ${file}`); | |
| } catch (error) { | |
| issueCount++; | |
| console.error(formatError(file, error)); | |
| } | |
| } | |
| for (const file of journeyFiles) { | |
| try { | |
| const journey = await readAndParseJsonFile(file, UserJourneySchema); | |
| const profile = profiles.get(journey.testerProfile); | |
| const issues: ValidationIssue[] = [...validateAllowedOrigins(journey)]; | |
| if (!profile) { | |
| issues.push({ | |
| path: "testerProfile", | |
| message: `No profile with id "${journey.testerProfile}" was loaded.`, | |
| }); | |
| } else { | |
| issues.push(...validateJourneyProfileMatch(journey, profile)); | |
| } | |
| if (issues.length > 0) { | |
| issueCount += issues.length; | |
| console.error(`journey invalid: ${file}`); | |
| for (const issue of issues) { | |
| console.error(` ${issue.path}: ${issue.message}`); | |
| } | |
| } else { | |
| console.log(`journey ok: ${file}`); | |
| } | |
| } catch (error) { | |
| issueCount++; | |
| console.error(formatError(file, error)); | |
| } | |
| } | |
| console.log( | |
| `Validated ${profileFiles.length} profile(s), ${journeyFiles.length} journey file(s).`, | |
| ); | |
| if (issueCount > 0) { | |
| console.error(`${issueCount} validation issue(s).`); | |
| } | |
| return { issueCount }; | |
| } | |
| export async function lintFiles( | |
| journeyPath: string, | |
| profilePath?: string, | |
| ): Promise<{ issueCount: number }> { | |
| const journeyFiles = await collectJsonFiles(resolve(journeyPath)); | |
| const profileFiles = profilePath | |
| ? await collectJsonFiles(resolve(profilePath)) | |
| : []; | |
| const profiles = new Map<string, TesterProfile>(); | |
| let issueCount = 0; | |
| for (const file of profileFiles) { | |
| try { | |
| const profile = await readAndParseJsonFile(file, TesterProfileSchema); | |
| profiles.set(profile.id, profile); | |
| console.log(`profile lint ok: ${file}`); | |
| } catch (error) { | |
| issueCount++; | |
| console.error(formatError(file, error)); | |
| } | |
| } | |
| for (const file of journeyFiles) { | |
| try { | |
| const journey = await readAndParseJsonFile(file, UserJourneySchema); | |
| const issues: LintIssue[] = []; | |
| const profile = profilePath | |
| ? profiles.get(journey.testerProfile) | |
| : undefined; | |
| if (profilePath && !profile) { | |
| issues.push({ | |
| severity: "error", | |
| code: "missing-profile", | |
| path: "testerProfile", | |
| message: `No profile with id "${journey.testerProfile}" was loaded.`, | |
| }); | |
| } | |
| issues.push(...lintUserJourney(journey, profile ? { profile } : {})); | |
| if (issues.length > 0) { | |
| issueCount += issues.length; | |
| console.error(`journey lint issues: ${file}`); | |
| for (const issue of issues) { | |
| console.error(` ${formatLintIssue(issue)}`); | |
| } | |
| } else { | |
| console.log(`journey lint ok: ${file}`); | |
| } | |
| } catch (error) { | |
| issueCount++; | |
| console.error(formatError(file, error)); | |
| } | |
| } | |
| console.log( | |
| `Linted ${profileFiles.length} profile(s), ${journeyFiles.length} journey file(s).`, | |
| ); | |
| if (issueCount > 0) { | |
| console.error(`${issueCount} lint issue(s).`); | |
| } | |
| return { issueCount }; | |
| } | |
| async function collectRunnableJourneys( | |
| journeyPath: string, | |
| profilePath: string, | |
| ): Promise<RunnableJourney[]> { | |
| const journeyFiles = await collectJsonFiles(journeyPath); | |
| const profiles = await loadProfiles(profilePath); | |
| const items: RunnableJourney[] = []; | |
| const issues: string[] = []; | |
| for (const journeyFile of journeyFiles) { | |
| try { | |
| const journey = await readAndParseJsonFile( | |
| journeyFile, | |
| UserJourneySchema, | |
| ); | |
| const profile = profiles.get(journey.testerProfile); | |
| if (!profile) { | |
| issues.push( | |
| `${journeyFile}: no profile with id "${journey.testerProfile}" was loaded from ${profilePath}.`, | |
| ); | |
| continue; | |
| } | |
| const validationIssues = [ | |
| ...validateAllowedOrigins(journey), | |
| ...validateJourneyProfileMatch(journey, profile), | |
| ]; | |
| if (validationIssues.length > 0) { | |
| for (const issue of validationIssues) { | |
| issues.push(`${journeyFile}: ${issue.path}: ${issue.message}`); | |
| } | |
| continue; | |
| } | |
| items.push({ journeyFile, journey, profile }); | |
| } catch (error) { | |
| issues.push(formatError(journeyFile, error)); | |
| } | |
| } | |
| if (issues.length > 0) { | |
| throw new Error( | |
| `Cannot run journeys until collection issues are fixed:\n${issues.join("\n")}`, | |
| ); | |
| } | |
| return items; | |
| } | |
| async function loadProfiles( | |
| profilePath: string, | |
| ): Promise<Map<string, TesterProfile>> { | |
| const profileFiles = await collectJsonFiles(profilePath); | |
| const profiles = new Map<string, TesterProfile>(); | |
| const issues: string[] = []; | |
| for (const file of profileFiles) { | |
| try { | |
| const profile = await readAndParseJsonFile(file, TesterProfileSchema); | |
| profiles.set(profile.id, profile); | |
| } catch (error) { | |
| issues.push(formatError(file, error)); | |
| } | |
| } | |
| if (issues.length > 0) { | |
| throw new Error(`Cannot load tester profiles:\n${issues.join("\n")}`); | |
| } | |
| return profiles; | |
| } | |
| async function loadComparisonBaseline( | |
| compareToPath: string, | |
| ): Promise<ComparisonBaseline> { | |
| const info = await stat(compareToPath); | |
| if (info.isFile()) { | |
| return { | |
| sourcePath: compareToPath, | |
| kind: "run", | |
| runs: [await readAndParseJsonFile(compareToPath, RunResultSchema)], | |
| }; | |
| } | |
| const runFiles = await collectRunJsonFiles(compareToPath); | |
| if (runFiles.length === 0) { | |
| throw new Error( | |
| `No run.json files found under --compare-to directory ${compareToPath}.`, | |
| ); | |
| } | |
| return { | |
| sourcePath: compareToPath, | |
| kind: "suite", | |
| runs: await Promise.all( | |
| runFiles.map((runFile) => readAndParseJsonFile(runFile, RunResultSchema)), | |
| ), | |
| }; | |
| } | |
| export async function loadRerunFailedSelection( | |
| rerunFailedPath: string, | |
| ): Promise<RerunFailedSelection> { | |
| const snapshots = await loadRunSnapshotsForSelection(rerunFailedPath); | |
| const unhealthyJourneyIds = [ | |
| ...new Set( | |
| snapshots | |
| .filter((snapshot) => runHealth(snapshot) !== "passed") | |
| .map((snapshot) => snapshot.journeyId), | |
| ), | |
| ]; | |
| if (unhealthyJourneyIds.length === 0) { | |
| throw new Error( | |
| `--rerun-failed found no unhealthy journeys in ${rerunFailedPath}.`, | |
| ); | |
| } | |
| return { | |
| sourcePath: rerunFailedPath, | |
| unhealthyJourneyIds, | |
| }; | |
| } | |
| async function loadRunSnapshotsForSelection(path: string): Promise<RunSnapshot[]> { | |
| const info = await stat(path); | |
| if (info.isFile()) { | |
| if (basename(path) === "history.json") { | |
| return readHistorySnapshots(path); | |
| } | |
| return [snapshotRun(await readAndParseJsonFile(path, RunResultSchema))]; | |
| } | |
| const historyPath = join(path, "history.json"); | |
| if (await pathExists(historyPath)) { | |
| return readHistorySnapshots(historyPath); | |
| } | |
| const runFiles = await collectRunJsonFiles(path); | |
| if (runFiles.length === 0) { | |
| throw new Error( | |
| `No run.json or history.json files found under --rerun-failed directory ${path}.`, | |
| ); | |
| } | |
| return Promise.all( | |
| runFiles.map(async (runFile) => | |
| snapshotRun(await readAndParseJsonFile(runFile, RunResultSchema)), | |
| ), | |
| ); | |
| } | |
| async function readHistorySnapshots(path: string): Promise<RunSnapshot[]> { | |
| let parsed: unknown; | |
| try { | |
| parsed = JSON.parse(await readFile(path, "utf8")); | |
| } catch (error) { | |
| const message = error instanceof Error ? error.message : String(error); | |
| throw new Error(`Could not parse history.json at ${path}: ${message}`); | |
| } | |
| if (!isRecord(parsed) || !Array.isArray(parsed.runs)) { | |
| throw new Error(`history.json at ${path} must contain a runs array.`); | |
| } | |
| return parsed.runs.map((run, index) => | |
| parseHistoryRunSnapshot(run, `${path}: runs[${index}]`), | |
| ); | |
| } | |
| function parseHistoryRunSnapshot(value: unknown, label: string): RunSnapshot { | |
| if (!isRecord(value)) { | |
| throw new Error(`${label} must be an object.`); | |
| } | |
| const snapshot = { | |
| journeyId: readRequiredString(value, "journeyId", label), | |
| runId: readRequiredString(value, "runId", label), | |
| runStatus: readRequiredString(value, "runStatus", label), | |
| verdictStatus: readRequiredString(value, "verdictStatus", label), | |
| dataLifecycleStatus: readRequiredString( | |
| value, | |
| "dataLifecycleStatus", | |
| label, | |
| ), | |
| startedAt: readRequiredString(value, "startedAt", label), | |
| endedAt: readRequiredString(value, "endedAt", label), | |
| durationMs: | |
| typeof value.durationMs === "number" && Number.isFinite(value.durationMs) | |
| ? value.durationMs | |
| : 0, | |
| ...(typeof value.summary === "string" ? { summary: value.summary } : {}), | |
| ...(typeof value.dashboard === "string" | |
| ? { dashboard: value.dashboard } | |
| : {}), | |
| ...(typeof value.result === "string" ? { result: value.result } : {}), | |
| } as RunSnapshot; | |
| assertRunSnapshotEnums(snapshot, label); | |
| return snapshot; | |
| } | |
| function readRequiredString( | |
| value: Record<string, unknown>, | |
| key: string, | |
| label: string, | |
| ): string { | |
| const field = value[key]; | |
| if (typeof field !== "string" || field.length === 0) { | |
| throw new Error(`${label}.${key} must be a non-empty string.`); | |
| } | |
| return field; | |
| } | |
| function assertRunSnapshotEnums(snapshot: RunSnapshot, label: string): void { | |
| if ( | |
| !["completed", "error", "cancelled", "blocked"].includes( | |
| snapshot.runStatus, | |
| ) | |
| ) { | |
| throw new Error(`${label}.runStatus is not a valid run status.`); | |
| } | |
| if ( | |
| !["passed", "failed", "blocked", "inconclusive", "none"].includes( | |
| snapshot.verdictStatus, | |
| ) | |
| ) { | |
| throw new Error(`${label}.verdictStatus is not a valid verdict status.`); | |
| } | |
| if ( | |
| !["passed", "failed", "blocked", "skipped", "none"].includes( | |
| snapshot.dataLifecycleStatus, | |
| ) | |
| ) { | |
| throw new Error( | |
| `${label}.dataLifecycleStatus is not a valid data lifecycle status.`, | |
| ); | |
| } | |
| } | |
| function isRecord(value: unknown): value is Record<string, unknown> { | |
| return typeof value === "object" && value !== null && !Array.isArray(value); | |
| } | |
| async function writeSuiteArtifacts( | |
| outputDir: string, | |
| suiteRuns: SuiteDashboardRun[], | |
| comparisonBaseline?: ComparisonBaseline, | |
| selection?: SuiteSelectionSummary, | |
| ): Promise<{ dashboard: string; history: string }> { | |
| const suiteId = | |
| outputDir.split(/[\\/]/).at(-1) ?? `${isoTimestampForPath()}-run`; | |
| const generatedAt = new Date().toISOString(); | |
| const suitePath = join(outputDir, "dashboard.html"); | |
| const historyPath = join(outputDir, "history.json"); | |
| const history = buildSuiteRunHistory({ | |
| suiteId, | |
| generatedAt, | |
| runs: suiteRuns, | |
| ...(selection ? { selection } : {}), | |
| ...(comparisonBaseline | |
| ? { | |
| compareTo: { | |
| path: comparisonBaseline.sourcePath, | |
| kind: comparisonBaseline.kind, | |
| runs: comparisonBaseline.runs, | |
| }, | |
| } | |
| : {}), | |
| }); | |
| await writeHistoryArtifact(historyPath, history); | |
| await writeFile( | |
| suitePath, | |
| renderSuiteDashboard({ | |
| suiteId, | |
| generatedAt, | |
| baseDir: outputDir, | |
| runs: suiteRuns, | |
| history, | |
| ...(selection ? { selection } : {}), | |
| }), | |
| "utf8", | |
| ); | |
| return { dashboard: suitePath, history: historyPath }; | |
| } | |
| async function writeHistoryArtifact( | |
| path: string, | |
| history: SuiteRunHistory, | |
| ): Promise<void> { | |
| await writeFile(path, `${JSON.stringify(history, null, 2)}\n`, "utf8"); | |
| } | |
| async function writeCliCiOutputs(options: { | |
| suiteRunId: string; | |
| suiteRuns: SuiteDashboardRun[]; | |
| junitPath?: string; | |
| summaryJsonPath?: string; | |
| githubAnnotations?: boolean; | |
| }): Promise<void> { | |
| if ( | |
| !options.junitPath && | |
| !options.summaryJsonPath && | |
| !options.githubAnnotations | |
| ) { | |
| return; | |
| } | |
| const generatedAt = new Date().toISOString(); | |
| const written = await writeCiReports({ | |
| suiteName: options.suiteRunId, | |
| generatedAt, | |
| runs: options.suiteRuns, | |
| ...(options.junitPath ? { junitPath: resolve(options.junitPath) } : {}), | |
| ...(options.summaryJsonPath | |
| ? { summaryJsonPath: resolve(options.summaryJsonPath) } | |
| : {}), | |
| }); | |
| if (written.junitPath) { | |
| console.log(`JUnit report: ${written.junitPath}`); | |
| } | |
| if (written.summaryJsonPath) { | |
| console.log(`Summary JSON: ${written.summaryJsonPath}`); | |
| } | |
| if (options.githubAnnotations) { | |
| emitGitHubAnnotations(options.suiteRuns, process.stderr); | |
| } | |
| } | |
| function installAppLifecycleSignalCleanup( | |
| controller: AppLifecycleController, | |
| ): () => void { | |
| const signals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"]; | |
| let handling = false; | |
| const handler = async (signal: NodeJS.Signals) => { | |
| if (handling) { | |
| return; | |
| } | |
| handling = true; | |
| console.error(`Received ${signal}; running app lifecycle cleanup.`); | |
| await controller.runCleanup().catch((error: unknown) => { | |
| const message = error instanceof Error ? error.message : String(error); | |
| console.error(`App lifecycle cleanup failed: ${message}`); | |
| }); | |
| process.exit(signal === "SIGINT" ? 130 : 143); | |
| }; | |
| for (const signal of signals) { | |
| process.once(signal, handler); | |
| } | |
| return () => { | |
| for (const signal of signals) { | |
| process.off(signal, handler); | |
| } | |
| }; | |
| } | |
| function printRunResult(result: Awaited<ReturnType<typeof runJourney>>): void { | |
| console.log(`Run: ${result.runId}`); | |
| console.log(`Run status: ${result.runStatus}`); | |
| if (result.verdict) { | |
| console.log(`Verdict: ${result.verdict.status}`); | |
| } | |
| if (result.dataLifecycle) { | |
| console.log(`Data lifecycle: ${result.dataLifecycle.status}`); | |
| } | |
| console.log(`Dashboard: ${result.artifacts.dashboard}`); | |
| console.log( | |
| `Dashboard URL: ${pathToFileURL(result.artifacts.dashboard).href}`, | |
| ); | |
| console.log(`Report: ${result.artifacts.report}`); | |
| console.log(`Result: ${result.artifacts.result}`); | |
| } | |
| async function collectJsonFiles(path: string): Promise<string[]> { | |
| const info = await stat(path); | |
| if (info.isFile()) { | |
| return path.endsWith(".json") ? [path] : []; | |
| } | |
| const entries = await readdir(path, { withFileTypes: true }); | |
| const files = await Promise.all( | |
| entries.map(async (entry) => { | |
| const child = resolve(path, entry.name); | |
| if (entry.isDirectory()) { | |
| return collectJsonFiles(child); | |
| } | |
| return child.endsWith(".json") ? [child] : []; | |
| }), | |
| ); | |
| return files.flat().sort(); | |
| } | |
| async function collectRunJsonFiles(path: string): Promise<string[]> { | |
| const info = await stat(path); | |
| if (info.isFile()) { | |
| return basename(path) === "run.json" ? [path] : []; | |
| } | |
| const entries = await readdir(path, { withFileTypes: true }); | |
| const files = await Promise.all( | |
| entries.map(async (entry) => { | |
| const child = resolve(path, entry.name); | |
| if (entry.isDirectory()) { | |
| return collectRunJsonFiles(child); | |
| } | |
| return entry.name === "run.json" ? [child] : []; | |
| }), | |
| ); | |
| return files.flat().sort(); | |
| } | |
| function formatError(file: string, error: unknown): string { | |
| const message = error instanceof Error ? error.message : String(error); | |
| return `${file} is invalid:\n${message}`; | |
| } | |
| if (import.meta.url === `file://${process.argv[1]}`) { | |
| runCli().catch((error) => { | |
| console.error(error instanceof Error ? error.message : String(error)); | |
| process.exitCode = 1; | |
| }); | |
| } | |
| async function runServerCommand(name: "serve" | "ui" | "space", options: { host: string; port: string; cognitionProfile?: string }) { | |
| const port = Number(options.port); if (!Number.isInteger(port) || port <= 0) throw new Error("--port must be a positive integer"); | |
| const loadedConfig = loadRuntimeCognitionConfig({ overrides: options.cognitionProfile ? { COGNITION_PROFILE: options.cognitionProfile } : undefined }); | |
| assertCloudReasoningConfiguration(loadedConfig); | |
| const mode = resolveLayaMode(loadedConfig.localLaya.mode); let managed: ManagedLayaService | undefined; | |
| console.error("JourneyTest starting"); console.error(`Environment: ${isHuggingFaceSpace() ? "Hugging Face Space" : "local"}`); console.error(`Laya mode: ${mode}`); if (loadedConfig.cloud.enabled) { const resolution = reasoningModelStatus(loadedConfig); console.error(`Reasoning transport: ${loadedConfig.cloud.transport}`); if (loadedConfig.cloud.openAiCompatibleUrl) console.error(`Reasoning endpoint: ${new URL(loadedConfig.cloud.openAiCompatibleUrl).host}`); else console.error(`Reasoning provider: ${loadedConfig.cloud.provider}`); console.error(`Reasoning model: ${loadedConfig.cloud.model}`); console.error(`Credential configured: ${cognitionConfigStatus(loadedConfig).reasoning.credential_configured ? "yes" : "no"}`); console.error(`Reasoning model resolved: ${resolution.provider}/${resolution.model}`); } | |
| if (name === "space" && mode === "embedded") { console.error("Starting localdecide"); managed = new ManagedLayaService(); await managed.start(); console.error("Laya ready"); } | |
| if (name === "space") { await checkBrowserLaunch(); console.error("Browser ready"); } | |
| const runtime = createJourneyRuntime({ endpoint: managed?.endpoint, cognitionOverrides: options.cognitionProfile ? { COGNITION_PROFILE: options.cognitionProfile } : undefined }); await runtime.backend.start(); | |
| const useGradio = name !== "serve"; const apiPort = useGradio ? port + 1 : port; const server = createApiServer({ service: runtime.service, backend: runtime.backend, model: runtime.config.localLaya.model, cognitionStatus: cognitionConfigStatus(runtime.config) }); | |
| await new Promise<void>((resolveReady, reject) => { server.once("error", reject); server.listen(apiPort, useGradio ? "127.0.0.1" : options.host, resolveReady); }); | |
| console.error("API mounted"); | |
| let child: ReturnType<typeof spawn> | undefined; | |
| if (useGradio) { const script = resolve(dirname(fileURLToPath(import.meta.url)), "../python/journeytest_web.py"); child = spawn(process.env.JOURNEYTEST_PYTHON ?? "python3", [script], { stdio: "inherit", env: { ...process.env, JOURNEYTEST_API_BASE: `http://127.0.0.1:${apiPort}`, JOURNEYTEST_HOST: options.host, JOURNEYTEST_PORT: String(port) } }); } | |
| try { if (useGradio && child) { await waitForGradioReadiness(`http://127.0.0.1:${port}/api/v1/health`, 30_000, child); console.error("Gradio ready"); } } | |
| catch (error) { child?.kill("SIGTERM"); await new Promise<void>(resolveClose => server.close(() => resolveClose())); await runtime.backend.close(); await managed?.stop(); throw error; } | |
| console.error(`JourneyTest ready on :${port}`); | |
| await new Promise<void>(resolveStop => { const stop = () => resolveStop(); process.once("SIGINT", stop); process.once("SIGTERM", stop); child?.once("exit", () => resolveStop()); }); | |
| child?.kill("SIGTERM"); await new Promise<void>(resolveClose => server.close(() => resolveClose())); await runtime.backend.close(); await managed?.stop(); | |
| } | |
| async function runDoctor(smokeModel = false, json = false, smokeReasoning = false) { | |
| const checks: Array<{ name: string; ok: boolean; detail: string }> = []; | |
| checks.push({ name: "JourneyTest version", ok: true, detail: "0.1.2" }); checks.push({ name: "Node version", ok: true, detail: process.version }); checks.push({ name: "environment", ok: true, detail: isHuggingFaceSpace() ? "Hugging Face Space" : "local" }); checks.push({ name: "Space detection", ok: true, detail: isHuggingFaceSpace() ? "yes" : "no" }); | |
| try { const { stdout } = await promisify(execFile)(process.env.JOURNEYTEST_PYTHON ?? "python3", ["--version"]); checks.push({ name: "Python", ok: true, detail: stdout.trim() }); } catch (error) { checks.push({ name: "Python", ok: false, detail: String(error) }); } | |
| try { const { stdout } = await promisify(execFile)("agent-browser", ["--version"]); checks.push({ name: "browser executable", ok: true, detail: stdout.trim() }); } catch (error) { checks.push({ name: "browser executable", ok: false, detail: String(error) }); } | |
| try { await checkBrowserLaunch(); checks.push({ name: "browser launch", ok: true, detail: "about:blank" }); } catch (error) { checks.push({ name: "browser launch", ok: false, detail: String(error) }); } | |
| try { await promisify(execFile)("localdecide", ["--help"]); checks.push({ name: "localdecide availability", ok: true, detail: "installed" }); } catch (error) { checks.push({ name: "localdecide availability", ok: false, detail: String(error) }); } | |
| try { await mkdir(resolve(process.env.JOURNEYTEST_OUTPUT ?? "runs"), { recursive: true }); await access(resolve(process.env.JOURNEYTEST_OUTPUT ?? "runs")); checks.push({ name: "artifact directory", ok: true, detail: resolve(process.env.JOURNEYTEST_OUTPUT ?? "runs") }); } catch (error) { checks.push({ name: "artifact directory", ok: false, detail: String(error) }); } | |
| checks.push({ name: "Laya backend", ok: true, detail: process.env.LAYA_BACKEND ?? "torch" }); checks.push({ name: "Laya checkpoint", ok: true, detail: process.env.LAYA_MODEL_REPO ?? "ichenney/laya-browser-v32b" }); checks.push({ name: "checkpoint revision", ok: true, detail: process.env.LAYA_MODEL_REVISION ?? "unpinned at runtime" }); | |
| const cognition = loadRuntimeCognitionConfig(); const modelStatus = reasoningModelStatus(cognition); const cognitionStatus = cognitionConfigStatus(cognition); checks.push({ name: "cloud reasoning configuration", ok: !cognition.cloud.enabled || (modelStatus.modelResolved && (cognitionStatus.reasoning.credential_configured || (cognition.cloud.transport === "openai-compatible" && !cognition.cloud.authRequired))), detail: cognition.cloud.enabled ? `${cognition.cloud.provider}/${cognition.cloud.model}; resolved=${modelStatus.modelResolved}; credential=${cognitionStatus.reasoning.credential_configured ? "configured" : "missing"}` : "disabled" }); | |
| let runtime: ReturnType<typeof createJourneyRuntime> | undefined; try { runtime = createJourneyRuntime({ config: cognition }); } catch (error) { checks.push({ name: "cloud reasoning startup validation", ok: false, detail: error instanceof Error ? error.message : String(error) }); } | |
| const health = runtime ? await runtime.backend.health() : { ok: false, detail: "runtime configuration invalid" }; checks.push({ name: "SystemOne endpoint", ok: health.ok, detail: `${runtime?.endpoint ?? cognition.localLaya.endpoint} (${health.backend ?? health.detail ?? "unavailable"})` }); | |
| if (smokeModel && health.ok && runtime) { try { const smoke = await runtime.backend.infer({ state: { page: { url: "about:blank", title: "Doctor", text: "Ready" }, recent_actions: [] }, questions: { operation: { type: "choice", instructions: "Readiness smoke test", criteria: { DONE: "Ready", BLOCKED: "Not ready" } } } }); checks.push({ name: "model readiness", ok: true, detail: `${smoke.backend}, ${smoke.latencyMs} ms` }); } catch (error) { checks.push({ name: "model readiness", ok: false, detail: error instanceof Error ? error.message : String(error) }); } } else checks.push({ name: "model readiness", ok: !smokeModel, detail: smokeModel ? "SystemOne endpoint unavailable" : "not loaded (no unexpected download)" }); | |
| if (smokeReasoning && runtime && cognition.cloud.enabled) { const started = Date.now(); try { const smoke = await runtime.cognitionRouter.initializeJourney({ goal: "Confirm reasoning readiness", successCriteria: [], context: {}, signal: new AbortController().signal }); checks.push({ name: "cloud reasoning live smoke", ok: true, detail: `${smoke.metadata.provider}/${smoke.metadata.model}, ${Date.now() - started} ms` }); } catch (error) { checks.push({ name: "cloud reasoning live smoke", ok: false, detail: error instanceof Error ? error.message : String(error) }); } } else checks.push({ name: "cloud reasoning live smoke", ok: true, detail: smokeReasoning ? "cloud reasoning disabled or invalid" : "not requested" }); checks.push({ name: "API dependencies", ok: true, detail: "node:http; Gradio/FastAPI checked by ui/space startup" }); | |
| const disk = await statfs(resolve(process.env.JOURNEYTEST_OUTPUT ?? "runs")); checks.push({ name: "available disk", ok: disk.bavail > 0, detail: `${Math.round(Number(disk.bavail * disk.bsize) / 1024 ** 3)} GiB` }); checks.push({ name: "available memory", ok: freemem() > 0, detail: `${Math.round(freemem() / 1024 ** 3)} / ${Math.round(totalmem() / 1024 ** 3)} GiB` }); | |
| if (json) process.stdout.write(`${JSON.stringify({ ok: checks.every(check => check.ok), checks })}\n`); else for (const check of checks) console.log(`${check.ok ? "OK" : "FAIL"} ${check.name}: ${check.detail}`); if (checks.some(check => !check.ok)) process.exitCode = 1; | |
| } | |
| async function checkBrowserLaunch() { const run = promisify(execFile); await run("agent-browser", ["--session", `journeytest-health-${process.pid}`, "open", "about:blank"], { timeout: 30_000 }); await run("agent-browser", ["--session", `journeytest-health-${process.pid}`, "close"], { timeout: 30_000 }).catch(() => undefined); } | |
| async function waitForHttp(url: string, timeoutMs: number) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { if ((await fetch(url)).ok) return; } catch {} await new Promise(resolveWait => setTimeout(resolveWait, 250)); } throw new Error(`Timed out waiting for ${url}`); } | |
| export async function waitForGradioReadiness(url: string, timeoutMs: number, child: Pick<ReturnType<typeof spawn>, "once">, wait = waitForHttp) { const exited = new Promise<never>((_, reject) => child.once("exit", (code, signal) => reject(new Error(`Gradio process exited before readiness (exit code ${code ?? "unknown"}${signal ? `, signal ${signal}` : ""})`)))); await Promise.race([wait(url, timeoutMs), exited]); } | |