diff --git a/.github/AGENTS.md b/.github/AGENTS.md index 47d9def56..616e63eb3 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -101,6 +101,9 @@ their failures and perform expiry. current response. - Code CI runs on `pull_request` with read-only repository permissions and checks out all public submodules recursively. +- Desktop journey authoring runs on a maintainer machine. GitHub workflows never + receive Codex credentials or publish author output; an ordinary PR exposes the + reviewed patch to the existing read-only code checks. ## Other automation diff --git a/.github/scripts/e2e-daily-failure.mjs b/.github/scripts/e2e-daily-failure.mjs new file mode 100644 index 000000000..a32f9d866 --- /dev/null +++ b/.github/scripts/e2e-daily-failure.mjs @@ -0,0 +1,259 @@ +#!/usr/bin/env node + +import { lstat, mkdir, readFile, realpath, writeFile } from 'node:fs/promises'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const MAX_FAILURE_INDEX_ENTRIES = 500; +export const MAX_FAILURE_INDEX_BYTES = 1024 * 1024; +export const MAX_VIDEO_BYTES = 10 * 1024 * 1024; + +function parseArgs(argv) { + const values = new Map(); + for (let index = 0; index < argv.length; index += 1) { + const name = argv[index]; + if (!name.startsWith('--')) throw new Error(`Unexpected argument: ${name}`); + const value = argv[index + 1]; + if (!value || value.startsWith('--')) throw new Error(`${name} requires a value`); + values.set(name.slice(2), value); + index += 1; + } + for (const required of ['evidence-root', 'output-root', 'run-id', 'run-url', 'head-sha']) { + if (!values.has(required)) throw new Error(`--${required} is required`); + } + return Object.fromEntries(values); +} + +function isInside(parent, child) { + const candidate = relative(parent, child); + return candidate === '' || (candidate !== '..' && !candidate.startsWith(`..${sep}`)); +} + +function portablePath(path) { + return path.split(sep).join('/'); +} + +function markdownText(value, limit = 240) { + return String(value) + .replaceAll('\\', '\\\\') + .replaceAll('`', "'") + .replaceAll('|', '\\|') + .replaceAll('\r', ' ') + .replaceAll('\n', ' ') + .replaceAll('<', '<') + .replaceAll('>', '>') + .slice(0, limit); +} + +async function readFailureIndex(evidenceRoot) { + const indexPath = resolve(evidenceRoot, 'failure-index.json'); + let stat; + try { + stat = await lstat(indexPath); + } catch (error) { + if (error?.code === 'ENOENT') return { entries: [], problem: 'failure-index.json is missing' }; + throw error; + } + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error('failure-index.json must be a regular file'); + } + if (stat.size > MAX_FAILURE_INDEX_BYTES) { + throw new Error(`failure-index.json exceeds ${MAX_FAILURE_INDEX_BYTES} bytes`); + } + const value = JSON.parse(await readFile(indexPath, 'utf8')); + if (!Array.isArray(value)) throw new Error('failure-index.json must contain an array'); + if (value.length > MAX_FAILURE_INDEX_ENTRIES) { + throw new Error(`failure-index.json exceeds ${MAX_FAILURE_INDEX_ENTRIES} entries`); + } + return { entries: value, problem: null }; +} + +function validateFailureEntry(entry, seen) { + if (!entry || typeof entry !== 'object') throw new Error('Invalid failure-index entry'); + const { stableId, path } = entry; + if (typeof stableId !== 'string' || !/^LODY-[A-Z0-9-]+-\d{3}$/u.test(stableId)) { + throw new Error('Invalid failure-index stableId'); + } + const expectedPath = `scenarios/${stableId.toLowerCase()}`; + if (path !== expectedPath) throw new Error(`Invalid failure-index path for ${stableId}`); + if (seen.has(stableId)) return null; + seen.add(stableId); + return { stableId, path }; +} + +async function pathContainsSymlink(root, target) { + let current = root; + for (const segment of relative(root, target).split(sep)) { + current = join(current, segment); + if ((await lstat(current)).isSymbolicLink()) return true; + } + return false; +} + +export async function prepareDailyFailureReport({ + evidenceRoot, + outputRoot, + runId, + runUrl, + headSha, + workingDirectory = process.cwd(), + maxVideoBytes = MAX_VIDEO_BYTES, + channel = 'daily', + suite = 'full', +}) { + if (!/^\d+$/u.test(String(runId))) throw new Error('runId must be numeric'); + if ( + !/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/actions\/runs\/\d+$/u.test(runUrl) + ) { + throw new Error('runUrl must be a GitHub Actions run URL'); + } + if (!/^[0-9a-f]{40}$/u.test(headSha)) throw new Error('headSha must be a full commit SHA'); + if (channel !== 'daily' && channel !== 'pr') throw new Error('channel must be daily or pr'); + if (channel === 'pr' && suite !== 'smoke' && suite !== 'full' && suite !== 'unknown') { + throw new Error('PR suite must be smoke, full, or unknown'); + } + + const workspace = resolve(workingDirectory); + const root = resolve(evidenceRoot); + const reports = resolve(outputRoot); + if (!isInside(workspace, root) || !isInside(workspace, reports)) { + throw new Error('Evidence and report directories must stay inside the workspace'); + } + await mkdir(root, { recursive: true }); + await mkdir(reports, { recursive: true }); + if ((await lstat(root)).isSymbolicLink() || (await lstat(reports)).isSymbolicLink()) { + throw new Error('Evidence and report directories must not be symbolic links'); + } + const canonicalRoot = await realpath(root); + const { entries, problem } = await readFailureIndex(root); + const seen = new Set(); + const failures = entries + .map((entry) => validateFailureEntry(entry, seen)) + .filter((entry) => entry !== null); + const videos = []; + const omitted = problem ? [{ stableId: null, reason: problem }] : []; + + for (const failure of failures) { + const videoPath = resolve(root, failure.path, 'failure.webm'); + if (!isInside(root, videoPath)) + throw new Error(`Video escaped evidence root: ${failure.stableId}`); + let stat; + try { + stat = await lstat(videoPath); + } catch (error) { + if (error?.code === 'ENOENT') { + omitted.push({ stableId: failure.stableId, reason: 'failure.webm is missing' }); + continue; + } + throw error; + } + if (!stat.isFile() || (await pathContainsSymlink(root, videoPath))) { + omitted.push({ stableId: failure.stableId, reason: 'failure.webm is not a regular file' }); + continue; + } + const canonicalVideo = await realpath(videoPath); + if (!isInside(canonicalRoot, canonicalVideo)) { + omitted.push({ + stableId: failure.stableId, + reason: 'failure.webm resolves outside evidence root', + }); + continue; + } + if (stat.size === 0) { + omitted.push({ stableId: failure.stableId, reason: 'failure.webm is empty' }); + continue; + } + if (stat.size > maxVideoBytes) { + omitted.push({ + stableId: failure.stableId, + reason: `failure.webm exceeds ${maxVideoBytes} bytes`, + }); + continue; + } + const attachmentPath = portablePath(relative(workspace, videoPath)); + if (isAbsolute(attachmentPath) || attachmentPath.startsWith('../')) { + throw new Error(`Video path is not workspace-relative: ${failure.stableId}`); + } + videos.push({ stableId: failure.stableId, path: attachmentPath, bytes: stat.size }); + } + + const groups = videos.length > 0 ? videos.map((video) => [video]) : [[]]; + const markerScope = channel === 'pr' ? 'desktop-e2e-pr-failure' : 'desktop-e2e-daily-failure'; + const subject = + channel === 'pr' + ? suite === 'unknown' + ? 'Desktop PR regression' + : `Desktop PR ${suite} regression` + : 'Desktop Daily regression'; + const batches = []; + for (let index = 0; index < groups.length; index += 1) { + const batchNumber = index + 1; + const bodyPath = resolve(reports, `comment-${String(batchNumber).padStart(3, '0')}.md`); + const marker = groups[index][0] + ? `` + : ``; + const lines = [ + marker, + `${subject} failed on commit \`${markdownText(headSha, 40)}\`.`, + '', + `- Workflow run: ${runUrl}`, + `- Failed scenarios indexed: ${failures.length}`, + `- Recording: ${batchNumber}/${groups.length}`, + ]; + for (const video of groups[index]) { + lines.push('', `### \`${markdownText(video.stableId)}\``, '', `![](${video.path})`); + } + if (index === 0 && omitted.length > 0) { + lines.push('', '### Recordings not attached'); + for (const entry of omitted) { + const label = entry.stableId ? `\`${markdownText(entry.stableId)}\`` : 'Run evidence'; + lines.push(`- ${label}: ${markdownText(entry.reason)}`); + } + } + lines.push( + '', + 'The Actions artifact retains the complete trace, screenshots, logs, and runtime evidence.' + ); + await writeFile(bodyPath, `${lines.join('\n')}\n`, { mode: 0o600 }); + batches.push({ + number: batchNumber, + marker, + bodyPath: portablePath(relative(workspace, bodyPath)), + videos: groups[index].map((video) => video.path), + }); + } + + const manifest = { + schemaVersion: 1, + runId: String(runId), + runUrl, + headSha, + channel, + suite, + failures, + videos, + omitted, + batches, + }; + const manifestPath = resolve(reports, 'manifest.json'); + await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 }); + return { ...manifest, manifestPath }; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const report = await prepareDailyFailureReport({ + evidenceRoot: options['evidence-root'], + outputRoot: options['output-root'], + runId: options['run-id'], + runUrl: options['run-url'], + headSha: options['head-sha'], + channel: options.channel, + suite: options.suite, + }); + process.stdout.write(`${report.manifestPath}\n`); +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) { + await main(); +} diff --git a/.github/scripts/e2e-daily-failure.test.mjs b/.github/scripts/e2e-daily-failure.test.mjs new file mode 100644 index 000000000..ed0994a9b --- /dev/null +++ b/.github/scripts/e2e-daily-failure.test.mjs @@ -0,0 +1,220 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { prepareDailyFailureReport } from './e2e-daily-failure.mjs'; + +const RUN = { + runId: '123456', + runUrl: 'https://github.com/LodyAI/Lody/actions/runs/123456', + headSha: 'a'.repeat(40), +}; + +async function withWorkspace(callback) { + const workspace = await mkdtemp(join(tmpdir(), 'lody-daily-failure-')); + try { + await callback(workspace); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +} + +async function writeFailures(workspace, ids) { + const root = join(workspace, 'daily-evidence'); + await mkdir(root, { recursive: true }); + const entries = []; + for (const id of ids) { + const path = `scenarios/${id.toLowerCase()}`; + await mkdir(join(root, path), { recursive: true }); + await writeFile(join(root, path, 'failure.webm'), `video:${id}`); + entries.push({ stableId: id, path }); + } + await writeFile(join(root, 'failure-index.json'), `${JSON.stringify(entries)}\n`); + return root; +} + +void test('builds one inline player per failed scenario', async () => { + await withWorkspace(async (workspace) => { + const ids = ['LODY-SESSION-001', 'LODY-WORK-001']; + const evidenceRoot = await writeFailures(workspace, ids); + const result = await prepareDailyFailureReport({ + ...RUN, + evidenceRoot, + outputRoot: join(workspace, 'daily-report'), + workingDirectory: workspace, + }); + assert.equal(result.videos.length, 2); + assert.equal(result.batches.length, 2); + assert.deepEqual( + result.batches.map((batch) => batch.videos), + [ + ['daily-evidence/scenarios/lody-session-001/failure.webm'], + ['daily-evidence/scenarios/lody-work-001/failure.webm'], + ] + ); + for (const batch of result.batches) { + const body = await readFile(join(workspace, batch.bodyPath), 'utf8'); + assert.match(body, new RegExp(`!\\[\\]\\(${batch.videos[0]}\\)`)); + } + }); +}); + +void test('deduplicates repeated failure-index rows from the same scenario', async () => { + await withWorkspace(async (workspace) => { + const id = 'LODY-SESSION-001'; + const evidenceRoot = await writeFailures(workspace, [id]); + const duplicate = { stableId: id, path: `scenarios/${id.toLowerCase()}` }; + await writeFile( + join(evidenceRoot, 'failure-index.json'), + `${JSON.stringify([duplicate, duplicate])}\n` + ); + const result = await prepareDailyFailureReport({ + ...RUN, + evidenceRoot, + outputRoot: join(workspace, 'daily-report'), + workingDirectory: workspace, + }); + assert.equal(result.failures.length, 1); + assert.equal(result.videos.length, 1); + }); +}); + +void test('gives every recording an independently retryable comment', async () => { + await withWorkspace(async (workspace) => { + const ids = Array.from( + { length: 51 }, + (_, index) => `LODY-BATCH-${String(index + 1).padStart(3, '0')}` + ); + const evidenceRoot = await writeFailures(workspace, ids); + const result = await prepareDailyFailureReport({ + ...RUN, + evidenceRoot, + outputRoot: join(workspace, 'daily-report'), + workingDirectory: workspace, + }); + assert.equal(result.batches.length, 51); + assert.equal( + result.batches.every((batch) => batch.videos.length === 1), + true + ); + assert.notEqual(result.batches[0].marker, result.batches[1].marker); + }); +}); + +void test('reports missing and oversized videos without attaching them', async () => { + await withWorkspace(async (workspace) => { + const evidenceRoot = await writeFailures(workspace, ['LODY-SESSION-001', 'LODY-WORK-001']); + await writeFile( + join(evidenceRoot, 'scenarios/lody-session-001/failure.webm'), + 'too-large-for-test' + ); + await rm(join(evidenceRoot, 'scenarios/lody-work-001/failure.webm')); + const result = await prepareDailyFailureReport({ + ...RUN, + evidenceRoot, + outputRoot: join(workspace, 'daily-report'), + workingDirectory: workspace, + maxVideoBytes: 4, + }); + assert.equal(result.videos.length, 0); + assert.deepEqual( + result.omitted.map((entry) => entry.stableId), + ['LODY-SESSION-001', 'LODY-WORK-001'] + ); + assert.equal(result.batches.length, 1); + }); +}); + +void test('rejects a video that resolves outside the evidence root', async () => { + await withWorkspace(async (workspace) => { + const evidenceRoot = await writeFailures(workspace, ['LODY-SESSION-001']); + const videoPath = join(evidenceRoot, 'scenarios/lody-session-001/failure.webm'); + await rm(videoPath); + const outside = join(workspace, 'outside.webm'); + await writeFile(outside, 'video'); + await symlink(outside, videoPath); + const result = await prepareDailyFailureReport({ + ...RUN, + evidenceRoot, + outputRoot: join(workspace, 'daily-report'), + workingDirectory: workspace, + }); + assert.equal(result.videos.length, 0); + assert.equal(result.omitted[0].reason, 'failure.webm is not a regular file'); + }); +}); + +void test('rejects a video reached through a symbolic-link directory', async () => { + await withWorkspace(async (workspace) => { + const evidenceRoot = join(workspace, 'daily-evidence'); + const outside = join(workspace, 'outside'); + await mkdir(join(evidenceRoot, 'scenarios'), { recursive: true }); + await mkdir(outside); + await writeFile(join(outside, 'failure.webm'), 'video'); + await symlink(outside, join(evidenceRoot, 'scenarios/lody-session-001')); + await writeFile( + join(evidenceRoot, 'failure-index.json'), + `${JSON.stringify([{ stableId: 'LODY-SESSION-001', path: 'scenarios/lody-session-001' }])}\n` + ); + const result = await prepareDailyFailureReport({ + ...RUN, + evidenceRoot, + outputRoot: join(workspace, 'daily-report'), + workingDirectory: workspace, + }); + assert.equal(result.videos.length, 0); + assert.equal(result.omitted[0].reason, 'failure.webm is not a regular file'); + }); +}); + +void test('creates a report for infrastructure failures without a failure index', async () => { + await withWorkspace(async (workspace) => { + const result = await prepareDailyFailureReport({ + ...RUN, + evidenceRoot: join(workspace, 'daily-evidence'), + outputRoot: join(workspace, 'daily-report'), + workingDirectory: workspace, + }); + assert.equal(result.videos.length, 0); + assert.equal(result.omitted[0].reason, 'failure-index.json is missing'); + const body = await readFile(join(workspace, result.batches[0].bodyPath), 'utf8'); + assert.match(body, /failure-index\.json is missing/u); + }); +}); + +void test('builds PR-specific markers and failure copy', async () => { + await withWorkspace(async (workspace) => { + const evidenceRoot = await writeFailures(workspace, ['LODY-REVIEW-001']); + const result = await prepareDailyFailureReport({ + ...RUN, + evidenceRoot, + outputRoot: join(workspace, 'pr-report'), + workingDirectory: workspace, + channel: 'pr', + suite: 'full', + }); + const body = await readFile(join(workspace, result.batches[0].bodyPath), 'utf8'); + assert.match(body, /desktop-e2e-pr-failure-run:123456:video:LODY-REVIEW-001/u); + assert.match(body, /Desktop PR full regression failed/u); + assert.doesNotMatch(body, /Desktop Daily/u); + }); +}); + +void test('still reports a PR infrastructure failure before its suite artifact exists', async () => { + await withWorkspace(async (workspace) => { + const result = await prepareDailyFailureReport({ + ...RUN, + evidenceRoot: join(workspace, 'pr-evidence'), + outputRoot: join(workspace, 'pr-report'), + workingDirectory: workspace, + channel: 'pr', + suite: 'unknown', + }); + const body = await readFile(join(workspace, result.batches[0].bodyPath), 'utf8'); + assert.match(body, /Desktop PR regression failed/u); + assert.match(body, /failure-index\.json is missing/u); + assert.doesNotMatch(body, /Desktop PR unknown regression/u); + }); +}); diff --git a/.github/scripts/e2e-daily-policy.mjs b/.github/scripts/e2e-daily-policy.mjs new file mode 100644 index 000000000..082db6d55 --- /dev/null +++ b/.github/scripts/e2e-daily-policy.mjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node + +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ACTIONS_BOT_LOGIN = 'github-actions[bot]'; + +export function isActionsBot(record) { + return record?.user?.login === ACTIONS_BOT_LOGIN && record.user.type === 'Bot'; +} + +export function findOwnedDailyFailureIssue(issues, marker) { + return issues + .filter( + (issue) => + !issue.pull_request && isActionsBot(issue) && String(issue.body ?? '').includes(marker) + ) + .sort((left, right) => left.number - right.number)[0]; +} + +export function hasCompleteOwnedComment(comments, marker, expectedVideos) { + if (!Number.isInteger(expectedVideos) || expectedVideos < 0 || expectedVideos > 1) { + throw new Error('expectedVideos must be zero or one'); + } + return comments.some((comment) => { + const body = String(comment?.body ?? ''); + if (!isActionsBot(comment) || !body.includes(marker)) return false; + const uploadedVideos = + body.match(/https:\/\/github\.com\/user-attachments\/assets\//gu)?.length ?? 0; + return uploadedVideos >= expectedVideos; + }); +} + +export function findDailyEvidenceArtifact(artifacts, runId) { + const supported = new Map([ + [`desktop-e2e-daily-full-${runId}`, 'full'], + [`desktop-e2e-daily-smoke-${runId}`, 'smoke'], + [`desktop-e2e-daily-${runId}`, 'unknown'], + ]); + const matches = artifacts.filter( + (artifact) => !artifact.expired && supported.has(String(artifact.name ?? '')) + ); + if (matches.length !== 1) return undefined; + return { + artifact: matches[0], + suite: supported.get(matches[0].name), + }; +} + +export function findPrEvidenceArtifact(artifacts, runId) { + const supported = new Map([ + [`desktop-e2e-full-${runId}`, 'full'], + [`desktop-e2e-smoke-${runId}`, 'smoke'], + ]); + const matches = artifacts.filter( + (artifact) => !artifact.expired && supported.has(String(artifact.name ?? '')) + ); + if (matches.length !== 1) return undefined; + return { + artifact: matches[0], + suite: supported.get(matches[0].name), + }; +} + +export function canCloseDailyFailureIssue(conclusion, suite) { + return conclusion === 'success' && suite === 'full'; +} + +function parseCliArgs(argv) { + if (argv[0] !== 'comment-complete') throw new Error('Expected comment-complete command'); + const values = new Map(); + for (let index = 1; index < argv.length; index += 1) { + const name = argv[index]; + const value = argv[index + 1]; + if (!name?.startsWith('--') || !value) throw new Error(`Invalid argument: ${name ?? ''}`); + values.set(name.slice(2), value); + index += 1; + } + for (const required of ['comments', 'marker', 'expected']) { + if (!values.has(required)) throw new Error(`--${required} is required`); + } + return Object.fromEntries(values); +} + +async function main() { + const options = parseCliArgs(process.argv.slice(2)); + const pages = JSON.parse(await readFile(resolve(options.comments), 'utf8')); + if (!Array.isArray(pages) || !pages.every(Array.isArray)) { + throw new Error('Comments must be a JSON array of API pages'); + } + const expectedVideos = Number.parseInt(options.expected, 10); + process.stdout.write( + `${hasCompleteOwnedComment(pages.flat(), options.marker, expectedVideos)}\n` + ); +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) { + await main(); +} diff --git a/.github/scripts/e2e-daily-policy.test.mjs b/.github/scripts/e2e-daily-policy.test.mjs new file mode 100644 index 000000000..043414614 --- /dev/null +++ b/.github/scripts/e2e-daily-policy.test.mjs @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + canCloseDailyFailureIssue, + findDailyEvidenceArtifact, + findOwnedDailyFailureIssue, + findPrEvidenceArtifact, + hasCompleteOwnedComment, +} from './e2e-daily-policy.mjs'; + +const bot = { login: 'github-actions[bot]', type: 'Bot' }; +const outsider = { login: 'outside-reporter', type: 'User' }; + +void test('selects only the Actions-owned Daily failure Issue', () => { + const marker = ''; + const issue = findOwnedDailyFailureIssue( + [ + { number: 1, body: marker, user: outsider }, + { number: 3, body: marker, user: bot }, + { number: 2, body: marker, user: bot, pull_request: {} }, + ], + marker + ); + assert.equal(issue.number, 3); +}); + +void test('does not let an outsider spoof a completed attachment marker', () => { + const marker = ''; + const uploaded = 'https://github.com/user-attachments/assets/example'; + assert.equal( + hasCompleteOwnedComment([{ body: `${marker}\n${uploaded}`, user: outsider }], marker, 1), + false + ); +}); + +void test('retries a bot comment until its video reference was uploaded', () => { + const marker = ''; + const localReference = '![](daily-evidence/scenarios/lody-test-001/failure.webm)'; + assert.equal( + hasCompleteOwnedComment([{ body: `${marker}\n${localReference}`, user: bot }], marker, 1), + false + ); + assert.equal( + hasCompleteOwnedComment( + [ + { + body: `${marker}\nhttps://github.com/user-attachments/assets/example`, + user: bot, + }, + ], + marker, + 1 + ), + true + ); +}); + +void test('accepts one bot-owned summary comment when no video exists', () => { + const marker = ''; + assert.equal(hasCompleteOwnedComment([{ body: marker, user: bot }], marker, 0), true); +}); + +void test('identifies the suite from the exact Daily evidence artifact', () => { + assert.deepEqual( + findDailyEvidenceArtifact([{ id: 7, name: 'desktop-e2e-daily-full-123', expired: false }], 123), + { + artifact: { id: 7, name: 'desktop-e2e-daily-full-123', expired: false }, + suite: 'full', + } + ); + assert.equal( + findDailyEvidenceArtifact([{ id: 8, name: 'desktop-e2e-daily-smoke-123', expired: false }], 123) + ?.suite, + 'smoke' + ); +}); + +void test('treats legacy Daily artifacts as evidence without assuming their suite', () => { + assert.equal( + findDailyEvidenceArtifact([{ id: 9, name: 'desktop-e2e-daily-123', expired: false }], 123) + ?.suite, + 'unknown' + ); +}); + +void test('rejects ambiguous and expired Daily evidence artifacts', () => { + assert.equal( + findDailyEvidenceArtifact( + [ + { id: 7, name: 'desktop-e2e-daily-full-123', expired: false }, + { id: 8, name: 'desktop-e2e-daily-smoke-123', expired: false }, + ], + 123 + ), + undefined + ); + assert.equal( + findDailyEvidenceArtifact([{ id: 7, name: 'desktop-e2e-daily-full-123', expired: true }], 123), + undefined + ); +}); + +void test('only a successful full Daily can close the shared failure Issue', () => { + assert.equal(canCloseDailyFailureIssue('success', 'full'), true); + assert.equal(canCloseDailyFailureIssue('success', 'smoke'), false); + assert.equal(canCloseDailyFailureIssue('success', 'unknown'), false); + assert.equal(canCloseDailyFailureIssue('failure', 'full'), false); +}); + +void test('identifies one exact PR evidence artifact and rejects ambiguity', () => { + assert.equal( + findPrEvidenceArtifact([{ id: 10, name: 'desktop-e2e-full-123', expired: false }], 123)?.suite, + 'full' + ); + assert.equal( + findPrEvidenceArtifact( + [ + { id: 10, name: 'desktop-e2e-full-123', expired: false }, + { id: 11, name: 'desktop-e2e-smoke-123', expired: false }, + ], + 123 + ), + undefined + ); +}); diff --git a/.github/scripts/journey-author-package.mjs b/.github/scripts/journey-author-package.mjs new file mode 100644 index 000000000..f50a4671b --- /dev/null +++ b/.github/scripts/journey-author-package.mjs @@ -0,0 +1,501 @@ +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { lstat, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, relative, resolve, sep } from 'node:path'; + +import { + journeyFingerprint, + renderCoverage, + validateRegistry, +} from '../../e2e/scripts/journey-registry.mjs'; + +const MAX_FILES = 24; +const MAX_FILE_BYTES = 300_000; +const MAX_TOTAL_BYTES = 1_500_000; +const INDEX_FILES = new Set([ + 'e2e/src/features/README.md', + 'e2e/src/steps/README.md', + 'e2e/src/support/README.md', +]); +const ALLOWED_PATTERNS = [ + /^e2e\/src\/features\/.+\.feature$/u, + /^e2e\/src\/steps\/.+\.steps\.ts$/u, + /^e2e\/src\/support\/pages\/.+\.ts$/u, + /^e2e\/src\/support\/fixtures\/.+\.(?:ts|mjs|json|txt)$/u, +]; +const FORBIDDEN_SUFFIXES = ['package.json', 'pnpm-lock.yaml', 'package-lock.json', 'yarn.lock']; + +function sha256(value) { + return createHash('sha256').update(value).digest('hex'); +} + +function parseArgs(argv) { + const values = new Map(); + for (let index = 0; index < argv.length; index += 2) { + if (!argv[index]?.startsWith('--') || argv[index + 1] === undefined) { + throw new Error('Expected --name value arguments'); + } + values.set(argv[index].slice(2), argv[index + 1]); + } + return values; +} + +export function matchesAllowedPath(path) { + if (path.startsWith('/') || path.includes('..') || path.includes('\\') || path.includes('\0')) + return false; + if (path.startsWith('.github/') || FORBIDDEN_SUFFIXES.some((suffix) => path.endsWith(suffix))) + return false; + return INDEX_FILES.has(path) || ALLOWED_PATTERNS.some((pattern) => pattern.test(path)); +} + +function validateTask(task) { + if (task?.schemaVersion !== 1 || task.kind !== 'lody-e2e-journey-author-task') { + throw new Error('Invalid journey author task schema'); + } + const unsigned = { ...task }; + delete unsigned.digest; + if (task.digest !== sha256(JSON.stringify(unsigned))) throw new Error('Task digest mismatch'); + if (task.disposition !== 'claimed') throw new Error('Only claimed tasks can produce candidates'); + if (!/^LODY-[A-Z0-9-]+-\d{3}$/.test(task.candidate?.id ?? '')) + throw new Error('Invalid candidate id'); + if (!/^[a-f0-9]{40}$/.test(task.baseSha)) throw new Error('Invalid base SHA'); + if (!Array.isArray(task.candidate.ownerPaths)) + throw new Error('Candidate ownerPaths are missing'); +} + +function validateReadyCandidate(task, candidate, { allowGenerated = false } = {}) { + if ( + candidate?.schemaVersion !== 1 || + candidate.kind !== 'lody-e2e-journey-candidate' || + candidate.status !== 'ready' + ) { + throw new Error('Candidate is not ready'); + } + const unsigned = { ...candidate }; + delete unsigned.digest; + if (candidate.digest !== sha256(JSON.stringify(unsigned))) + throw new Error('Candidate digest mismatch'); + if ( + candidate.taskDigest !== task.digest || + candidate.candidateId !== task.candidate.id || + candidate.fingerprint !== task.candidate.fingerprint || + candidate.baseSha !== task.baseSha || + candidate.leaseId !== task.claim.leaseId + ) { + throw new Error('Candidate does not match its task'); + } + if ( + !Array.isArray(candidate.files) || + candidate.files.length === 0 || + candidate.files.length > MAX_FILES + ) { + throw new Error('Candidate files are missing or exceed the file limit'); + } + const seen = new Set(); + let totalBytes = 0; + for (const file of candidate.files) { + const generated = file.path === 'e2e/COVERAGE.md' || file.path === 'e2e/journeys/registry.json'; + if ((!allowGenerated || !generated) && !matchesAllowedPath(file.path)) + throw new Error(`Candidate path is outside scope: ${file.path}`); + if (seen.has(file.path)) throw new Error(`Candidate path is duplicated: ${file.path}`); + seen.add(file.path); + const content = Buffer.from(file.content, 'utf8'); + totalBytes += content.length; + if ( + content.includes(0) || + content.length > MAX_FILE_BYTES || + totalBytes > MAX_TOTAL_BYTES || + content.length !== file.bytes || + sha256(content) !== file.sha256 + ) { + throw new Error(`Candidate file digest or size is invalid: ${file.path}`); + } + } +} + +function blockedCandidate(task, code, summary) { + const allowedClasses = new Set(['product-defect', 'test-capability', 'infra']); + const failureClass = allowedClasses.has(code) ? code : 'test-capability'; + const detail = allowedClasses.has(code) ? summary : `${code}: ${summary}`; + const core = { + schemaVersion: 1, + kind: 'lody-e2e-journey-candidate', + status: 'blocked', + taskDigest: task.digest, + candidateId: task.candidate.id, + fingerprint: task.candidate.fingerprint, + baseSha: task.baseSha, + leaseId: task.claim.leaseId, + classification: { code: failureClass, summary: String(detail).slice(0, 2_000) }, + files: [], + }; + return { ...core, digest: sha256(JSON.stringify(core)) }; +} + +function parseAuthorResult(finalMessage, task) { + let result; + try { + result = JSON.parse(finalMessage); + } catch { + return { + status: 'blocked', + failureClass: 'infra', + summary: 'The author did not return valid structured output.', + }; + } + if (result?.status === 'blocked') { + const allowedClasses = new Set(['product-defect', 'test-capability', 'infra']); + return { + status: 'blocked', + failureClass: allowedClasses.has(result.failureClass) ? result.failureClass : 'infra', + summary: + typeof result.summary === 'string' && result.summary.trim() + ? result.summary.slice(0, 2_000) + : 'The author blocked the candidate without a summary.', + }; + } + if (result?.status !== 'ready' || result.failureClass !== 'none') { + return { + status: 'blocked', + failureClass: 'infra', + summary: 'The author result has an unsupported status or failure class.', + }; + } + const ablation = result.ablation; + const sentinel = `__LODY_COUNTERFACTUAL_${task.candidate.id.replaceAll('-', '_')}__`; + if ( + typeof ablation?.path !== 'string' || + typeof ablation.search !== 'string' || + typeof ablation.replacement !== 'string' || + typeof ablation.expectedFailure !== 'string' || + ablation.search.includes('\n') || + ablation.replacement !== JSON.stringify(sentinel) || + ablation.expectedFailure !== sentinel + ) { + return { + status: 'blocked', + failureClass: 'test-capability', + summary: 'The author did not provide a bounded counterfactual assertion replacement.', + }; + } + return { + status: 'ready', + failureClass: 'none', + summary: typeof result.summary === 'string' ? result.summary.slice(0, 2_000) : '', + ablation, + }; +} + +export async function packageCandidate({ root, task, finalMessage = '' }) { + validateTask(task); + const authorResult = parseAuthorResult(finalMessage, task); + if (authorResult.status === 'blocked') { + return blockedCandidate(task, authorResult.failureClass, authorResult.summary); + } + const status = execFileSync('git', ['status', '--porcelain=v1', '-z', '--untracked-files=all'], { + cwd: root, + encoding: 'utf8', + }); + const entries = status.split('\0').filter(Boolean); + if (entries.length === 0) + return blockedCandidate( + task, + 'no_changes', + finalMessage || 'The author produced no file changes.' + ); + + const paths = []; + for (const entry of entries) { + const code = entry.slice(0, 2); + const path = entry.slice(3); + if (code.includes('D') || code.includes('R') || code.includes('C') || path.includes(' -> ')) { + return blockedCandidate( + task, + 'unsupported_change', + `Deletion, rename, or copy is not allowed: ${path}` + ); + } + if (!matchesAllowedPath(path)) { + return blockedCandidate( + task, + 'path_outside_candidate_scope', + `Candidate changed a path outside its registry scope: ${path}` + ); + } + paths.push(path); + } + const uniquePaths = [...new Set(paths)].sort(); + if (uniquePaths.length > MAX_FILES - 2) { + return blockedCandidate( + task, + 'candidate_too_large', + `Candidate changed ${uniquePaths.length} files; author limit is ${MAX_FILES - 2}.` + ); + } + + if (!uniquePaths.includes(authorResult.ablation.path)) { + return blockedCandidate( + task, + 'test-capability', + 'The counterfactual must target a file changed by this one-journey candidate.' + ); + } + + const addedFeatureLines = []; + for (const path of uniquePaths.filter((candidatePath) => candidatePath.endsWith('.feature'))) { + let tracked = true; + try { + execFileSync('git', ['ls-files', '--error-unmatch', '--', path], { + cwd: root, + stdio: 'ignore', + }); + } catch { + tracked = false; + } + if (tracked) { + const diff = execFileSync('git', ['diff', '--no-ext-diff', '--unified=0', '--', path], { + cwd: root, + encoding: 'utf8', + maxBuffer: 1_000_000, + }); + addedFeatureLines.push( + ...diff.split('\n').filter((line) => line.startsWith('+') && !line.startsWith('+++')) + ); + } else { + const content = await readFile(resolve(root, path), 'utf8'); + addedFeatureLines.push(...content.split('\n').map((line) => `+${line}`)); + } + } + const addedScenarioLines = addedFeatureLines.filter((line) => + /^\+\s*(?:Scenario|场景):/u.test(line) + ); + const addedIdLines = addedFeatureLines.filter((line) => line.includes(`@${task.candidate.id}`)); + if (addedScenarioLines.length !== 1 || addedIdLines.length !== 1) { + return blockedCandidate( + task, + 'not_one_journey', + `Expected one added scenario and one @${task.candidate.id} tag; found ${addedScenarioLines.length} scenarios and ${addedIdLines.length} tags.` + ); + } + + const files = []; + let totalBytes = 0; + for (const path of uniquePaths) { + const absolute = resolve(root, path); + const relativePath = relative(root, absolute); + if (relativePath.startsWith(`..${sep}`) || relativePath === '..') { + return blockedCandidate(task, 'path_escape', `Candidate path escapes the workspace: ${path}`); + } + const stat = await lstat(absolute); + if (!stat.isFile() || stat.isSymbolicLink()) { + return blockedCandidate( + task, + 'unsupported_file_type', + `Candidate path is not a regular file: ${path}` + ); + } + const content = await readFile(absolute); + if (content.includes(0) || content.length > MAX_FILE_BYTES) { + return blockedCandidate( + task, + 'unsupported_file_content', + `Candidate file is binary or too large: ${path}` + ); + } + totalBytes += content.length; + if (totalBytes > MAX_TOTAL_BYTES) { + return blockedCandidate( + task, + 'candidate_too_large', + `Candidate content exceeds ${MAX_TOTAL_BYTES} bytes.` + ); + } + files.push({ + path, + bytes: content.length, + sha256: sha256(content), + content: content.toString('utf8'), + }); + } + + const ablationFile = files.find((file) => file.path === authorResult.ablation.path); + if ( + !/^e2e\/src\/(?:steps\/.+\.steps\.ts|support\/pages\/.+\.ts)$/u.test(authorResult.ablation.path) + ) { + return blockedCandidate( + task, + 'test-capability', + 'The counterfactual must target an assertion in a changed step or Page Object.' + ); + } + if (files.some((file) => file.content.includes(authorResult.ablation.expectedFailure))) { + return blockedCandidate( + task, + 'test-capability', + 'Candidate source must not contain the validator-only counterfactual sentinel.' + ); + } + const occurrenceCount = ablationFile?.content.split(authorResult.ablation.search).length - 1; + if (occurrenceCount !== 1) { + return blockedCandidate( + task, + 'test-capability', + `The counterfactual search must occur exactly once in ${authorResult.ablation.path}.` + ); + } + + const core = { + schemaVersion: 1, + kind: 'lody-e2e-journey-candidate', + status: 'ready', + taskDigest: task.digest, + candidateId: task.candidate.id, + fingerprint: task.candidate.fingerprint, + baseSha: task.baseSha, + leaseId: task.claim.leaseId, + title: task.candidate.title, + ablation: authorResult.ablation, + files, + }; + return { ...core, digest: sha256(JSON.stringify(core)) }; +} + +export async function validateAndApplyCandidate({ root, task, candidate }) { + validateTask(task); + validateReadyCandidate(task, candidate); + for (const file of candidate.files) { + const content = Buffer.from(file.content, 'utf8'); + const absolute = resolve(root, file.path); + if (!absolute.startsWith(`${resolve(root)}${sep}`)) + throw new Error(`Candidate path escapes workspace: ${file.path}`); + try { + const existing = await lstat(absolute); + if (existing.isSymbolicLink() || (!existing.isFile() && !existing.isDirectory())) { + throw new Error(`Candidate target is not a regular path: ${file.path}`); + } + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + await mkdir(dirname(absolute), { recursive: true }); + await writeFile(absolute, content, { mode: 0o644 }); + } +} + +function withDigest(candidate, files) { + const core = { ...candidate, files }; + delete core.digest; + return { ...core, digest: sha256(JSON.stringify(core)) }; +} + +export async function promoteCandidate({ root, task, candidate }) { + validateTask(task); + validateReadyCandidate(task, candidate); + const featureFiles = candidate.files.filter( + (file) => file.path.endsWith('.feature') && file.content.includes(`@${task.candidate.id}`) + ); + if (featureFiles.length !== 1) { + throw new Error(`Expected exactly one feature file for ${task.candidate.id}`); + } + const registryPath = resolve(root, 'e2e/journeys/registry.json'); + const registry = JSON.parse(await readFile(registryPath, 'utf8')); + const row = registry.journeys?.find((journey) => journey.id === task.candidate.id); + if ( + row?.state !== 'backlog' || + row.fingerprint !== task.candidate.fingerprint || + journeyFingerprint(row) !== task.candidate.fingerprint + ) { + throw new Error(`Registry row ${task.candidate.id} no longer matches the claimed backlog`); + } + row.state = 'active'; + row.feature = featureFiles[0].path.slice('e2e/'.length); + row.blockedReason = null; + const failures = validateRegistry(registry); + if (failures.length > 0) { + throw new Error(`Promoted registry is invalid:\n- ${failures.join('\n- ')}`); + } + const registryContent = `${JSON.stringify(registry, null, 2)}\n`; + const coverageContent = renderCoverage(registry); + await writeFile(registryPath, registryContent, 'utf8'); + await writeFile(resolve(root, 'e2e/COVERAGE.md'), coverageContent, 'utf8'); + + const generatedFiles = [ + ['e2e/COVERAGE.md', coverageContent], + ['e2e/journeys/registry.json', registryContent], + ].map(([path, content]) => ({ + path, + bytes: Buffer.byteLength(content), + sha256: sha256(content), + content, + })); + const promoted = withDigest( + candidate, + [...candidate.files, ...generatedFiles].sort((a, b) => a.path.localeCompare(b.path)) + ); + validateReadyCandidate(task, promoted, { allowGenerated: true }); + return promoted; +} + +export async function applyAblation({ root, task, candidate }) { + validateTask(task); + validateReadyCandidate(task, candidate, { allowGenerated: true }); + const file = candidate.files.find((entry) => entry.path === candidate.ablation?.path); + if (!file) throw new Error('Ablation path is not part of the candidate'); + const occurrenceCount = file.content.split(candidate.ablation.search).length - 1; + if (occurrenceCount !== 1) throw new Error('Ablation search is not unique'); + const content = file.content.replace(candidate.ablation.search, candidate.ablation.replacement); + await writeFile(resolve(root, file.path), content, 'utf8'); +} + +export async function restoreAblation({ root, task, candidate }) { + validateTask(task); + validateReadyCandidate(task, candidate, { allowGenerated: true }); + const file = candidate.files.find((entry) => entry.path === candidate.ablation?.path); + if (!file) throw new Error('Ablation path is not part of the candidate'); + await writeFile(resolve(root, file.path), file.content, 'utf8'); +} + +async function main() { + const [command, ...rest] = process.argv.slice(2); + const args = parseArgs(rest); + const root = resolve(args.get('root') ?? process.cwd()); + const task = JSON.parse(await readFile(resolve(args.get('task')), 'utf8')); + if (command === 'package') { + const finalMessage = args.get('final-message') + ? await readFile(resolve(args.get('final-message')), 'utf8') + : ''; + const candidate = await packageCandidate({ root, task, finalMessage }); + await writeFile(resolve(args.get('output')), `${JSON.stringify(candidate, null, 2)}\n`, 'utf8'); + process.stdout.write(`${candidate.status}\n`); + return; + } + if (command === 'block') { + const candidate = blockedCandidate( + task, + args.get('code') ?? 'author_failed', + args.get('summary') ?? 'The author did not complete the candidate.' + ); + await writeFile(resolve(args.get('output')), `${JSON.stringify(candidate, null, 2)}\n`, 'utf8'); + return; + } + if (command === 'apply') { + const candidate = JSON.parse(await readFile(resolve(args.get('candidate')), 'utf8')); + await validateAndApplyCandidate({ root, task, candidate }); + return; + } + if (command === 'promote') { + const candidate = JSON.parse(await readFile(resolve(args.get('candidate')), 'utf8')); + const promoted = await promoteCandidate({ root, task, candidate }); + await writeFile(resolve(args.get('output')), `${JSON.stringify(promoted, null, 2)}\n`, 'utf8'); + return; + } + if (command === 'ablate' || command === 'restore') { + const candidate = JSON.parse(await readFile(resolve(args.get('candidate')), 'utf8')); + await (command === 'ablate' ? applyAblation : restoreAblation)({ root, task, candidate }); + return; + } + throw new Error(`Unknown command: ${command}`); +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname)) { + await main(); +} diff --git a/.github/scripts/journey-author-package.test.mjs b/.github/scripts/journey-author-package.test.mjs new file mode 100644 index 000000000..5c9ab90bc --- /dev/null +++ b/.github/scripts/journey-author-package.test.mjs @@ -0,0 +1,230 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { journeyFingerprint, renderCoverage } from '../../e2e/scripts/journey-registry.mjs'; +import { + applyAblation, + matchesAllowedPath, + packageCandidate, + promoteCandidate, + restoreAblation, +} from './journey-author-package.mjs'; + +function digest(value) { + return createHash('sha256').update(JSON.stringify(value)).digest('hex'); +} + +function fixture() { + const journey = { + id: 'LODY-TEST-001', + state: 'backlog', + priority: 'P1', + runtime: 'none', + title: 'Exercise a synthetic visible outcome', + owner: 'test', + fixture: 'synthetic', + ownerPaths: ['e2e/src/features/'], + actions: [{ id: 'test.open' }], + checkpoints: ['visible outcome'], + cleanup: ['desktop exits'], + coverage: { + renderer: 'Test UI', + electronIpc: 'Real IPC', + bundledCli: 'Real CLI', + durableState: 'Synthetic state', + externalWire: 'None', + }, + gap: 'No journey exists.', + evidence: ['e2e/src/features/onboarding.feature'], + signals: { criticality: 3, boundaryRisk: 3, changeFrequency: 3, escapedDefect: false }, + estimatedMinutes: 4, + freshness: 3, + scoutJourneys: [], + blockedReason: null, + }; + journey.fingerprint = journeyFingerprint(journey); + const taskCore = { + schemaVersion: 1, + kind: 'lody-e2e-journey-author-task', + disposition: 'claimed', + baseSha: 'a'.repeat(40), + candidate: journey, + claim: { leaseId: 'lease' }, + }; + const task = { ...taskCore, digest: digest(taskCore) }; + const featureContent = + '# language: zh-CN\n@lody @P1 @essence @runtime-none @LODY-TEST-001\n功能: Test\n\n 场景: Test outcome\n 那么 visible outcome\n'; + const pageContent = 'export const expected = "visible outcome";\n'; + const files = [ + { + path: 'e2e/src/features/test.feature', + content: featureContent, + bytes: Buffer.byteLength(featureContent), + sha256: createHash('sha256').update(featureContent).digest('hex'), + }, + { + path: 'e2e/src/support/pages/test-page.ts', + content: pageContent, + bytes: Buffer.byteLength(pageContent), + sha256: createHash('sha256').update(pageContent).digest('hex'), + }, + ]; + const candidateCore = { + schemaVersion: 1, + kind: 'lody-e2e-journey-candidate', + status: 'ready', + taskDigest: task.digest, + candidateId: journey.id, + fingerprint: journey.fingerprint, + baseSha: task.baseSha, + leaseId: 'lease', + title: journey.title, + ablation: { + path: 'e2e/src/support/pages/test-page.ts', + search: '"visible outcome"', + replacement: '"__LODY_COUNTERFACTUAL_LODY_TEST_001__"', + expectedFailure: '__LODY_COUNTERFACTUAL_LODY_TEST_001__', + }, + files, + }; + return { + journey, + task, + candidate: { ...candidateCore, digest: digest(candidateCore) }, + }; +} + +void test('allows only the documented author boundary', () => { + for (const path of [ + 'e2e/src/features/session.feature', + 'e2e/src/steps/session.steps.ts', + 'e2e/src/support/pages/session.ts', + 'e2e/src/support/fixtures/session.json', + 'e2e/src/features/README.md', + 'e2e/src/steps/README.md', + 'e2e/src/support/README.md', + ]) + assert.equal(matchesAllowedPath(path), true, path); +}); + +void test('rejects product, broad support, policy, and owner paths', () => { + for (const path of [ + 'apps/electron/src/main.ts', + 'e2e/src/support/world.ts', + 'e2e/README.md', + 'e2e/COVERAGE.md', + 'e2e/journeys/registry.json', + '.github/workflows/ci.yml', + ]) + assert.equal(matchesAllowedPath(path), false, path); +}); + +void test('promotes only the claimed row and regenerates coverage', async () => { + const root = await mkdtemp(join(tmpdir(), 'lody-journey-package-')); + try { + const { journey, task, candidate } = fixture(); + const registry = { + schemaVersion: 1, + scoring: { + criticality: 100, + boundaryRisk: 20, + changeFrequency: 5, + freshness: 5, + escapedDefect: 40, + scoutSignal: 35, + changedPath: 50, + estimatedMinutePenalty: 2, + }, + journeys: [journey], + }; + await mkdir(join(root, 'e2e/journeys'), { recursive: true }); + await writeFile( + join(root, 'e2e/journeys/registry.json'), + `${JSON.stringify(registry, null, 2)}\n` + ); + const promoted = await promoteCandidate({ root, task, candidate }); + const actualRegistry = JSON.parse( + await readFile(join(root, 'e2e/journeys/registry.json'), 'utf8') + ); + assert.equal(actualRegistry.journeys[0].state, 'active'); + assert.equal(actualRegistry.journeys[0].feature, 'src/features/test.feature'); + assert.equal( + await readFile(join(root, 'e2e/COVERAGE.md'), 'utf8'), + renderCoverage(actualRegistry) + ); + assert.deepEqual( + promoted.files.map((file) => file.path), + [ + 'e2e/COVERAGE.md', + 'e2e/journeys/registry.json', + 'e2e/src/features/test.feature', + 'e2e/src/support/pages/test-page.ts', + ] + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +void test('applies and exactly restores the bounded counterfactual', async () => { + const root = await mkdtemp(join(tmpdir(), 'lody-journey-ablation-')); + try { + const { task, candidate } = fixture(); + const target = join(root, candidate.ablation.path); + await mkdir(join(root, 'e2e/src/support/pages'), { recursive: true }); + await writeFile( + target, + candidate.files.find((file) => file.path === candidate.ablation.path).content + ); + await applyAblation({ root, task, candidate }); + assert.match(await readFile(target, 'utf8'), /__LODY_COUNTERFACTUAL_LODY_TEST_001__/u); + await restoreAblation({ root, task, candidate }); + assert.equal( + await readFile(target, 'utf8'), + candidate.files.find((file) => file.path === candidate.ablation.path).content + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +void test('packages one structured journey and rejects a pre-seeded sentinel', async () => { + const root = await mkdtemp(join(tmpdir(), 'lody-journey-author-')); + try { + const { task, candidate: expected } = fixture(); + execFileSync('git', ['init', '--quiet'], { cwd: root }); + for (const file of expected.files) { + await mkdir(join(root, file.path, '..'), { recursive: true }); + await writeFile(join(root, file.path), file.content); + } + const finalMessage = JSON.stringify({ + status: 'ready', + failureClass: 'none', + summary: 'One synthetic journey is ready.', + ablation: expected.ablation, + }); + const candidate = await packageCandidate({ root, task, finalMessage }); + assert.equal(candidate.status, 'ready'); + assert.deepEqual( + candidate.files.map((file) => file.path), + expected.files.map((file) => file.path) + ); + + const pagePath = join(root, expected.ablation.path); + await writeFile( + pagePath, + `${expected.files.find((file) => file.path === expected.ablation.path).content}// ${expected.ablation.expectedFailure}\n` + ); + const seeded = await packageCandidate({ root, task, finalMessage }); + assert.equal(seeded.status, 'blocked'); + assert.equal(seeded.classification.code, 'test-capability'); + assert.match(seeded.classification.summary, /validator-only/u); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/.github/scripts/journey-author-task.mjs b/.github/scripts/journey-author-task.mjs new file mode 100644 index 000000000..ea7655710 --- /dev/null +++ b/.github/scripts/journey-author-task.mjs @@ -0,0 +1,169 @@ +import { createHash } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; + +import { + loadJourneyRegistry, + selectJourneyCandidate, +} from '../../e2e/scripts/journey-registry.mjs'; + +function parseArgs(argv) { + const values = new Map(); + for (let index = 0; index < argv.length; index += 2) { + const name = argv[index]; + const value = argv[index + 1]; + if (!name?.startsWith('--') || value === undefined) { + throw new Error(`Expected --name value arguments; received ${name ?? ''}`); + } + values.set(name.slice(2), value); + } + return values; +} + +function required(args, name, maximum = 500) { + const value = args.get(name); + if (typeof value !== 'string' || value.length === 0 || value.length > maximum) { + throw new Error(`--${name} must be a non-empty string no longer than ${maximum} characters`); + } + return value; +} + +function clampBudget(value) { + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed < 15 || parsed > 120) { + throw new Error('budget-minutes must be an integer from 15 through 120'); + } + return parsed; +} + +function digest(value) { + return createHash('sha256').update(JSON.stringify(value)).digest('hex'); +} + +export function createJourneyAuthorTask(input) { + const excluded = new Set(input.excludedCandidateIds); + const selectionRegistry = { + ...input.registry, + journeys: input.registry.journeys.map((journey) => + excluded.has(journey.id) + ? { ...journey, blockedReason: 'An active claim or Draft PR already owns this candidate.' } + : journey + ), + }; + const selection = selectJourneyCandidate(selectionRegistry, input.signals); + let selected = selection.selected; + if (input.requestedCandidateId !== 'next') { + selected = + selection.ranked.find((candidate) => candidate.id === input.requestedCandidateId) ?? null; + if (!selected) { + throw new Error( + `Requested candidate ${input.requestedCandidateId} is not an eligible backlog row` + ); + } + } + + const issuedAt = new Date(input.now).toISOString(); + if (!Number.isFinite(Date.parse(issuedAt))) throw new Error('now must be a valid ISO date'); + const common = { + schemaVersion: 1, + kind: 'lody-e2e-journey-author-task', + repository: input.repository, + baseRef: input.baseRef, + baseSha: input.baseSha, + runId: input.runId, + trigger: input.trigger, + issuedAt, + selection: { + considered: selection.considered, + ranked: selection.ranked, + skippedBlocked: selection.skippedBlocked, + skippedDuplicates: selection.skippedDuplicates, + }, + }; + if (!selected) { + const task = { ...common, disposition: 'queue-empty' }; + return { ...task, digest: digest(task) }; + } + + const journey = input.registry.journeys.find((candidate) => candidate.id === selected.id); + const expiresAt = new Date(input.now + input.budgetMinutes * 60_000).toISOString(); + const task = { + ...common, + disposition: 'claimed', + candidate: { + ...journey, + score: selected.score, + scoreBreakdown: selected.breakdown, + changedPathMatch: selected.changedPathMatch, + escapedDefectMatch: selected.escapedDefectMatch, + scoutSignalMatch: selected.scoutSignalMatch, + }, + claim: { + leaseId: `${selected.id}-${input.runId}`, + issuedAt, + expiresAt, + budgetMinutes: input.budgetMinutes, + }, + successContract: { + unit: 'one registry candidate', + completion: + 'One deterministic Electron scenario closes every checkpoint and cleanup obligation, then passes three focused rounds and the full suite.', + notCompletion: + 'Added file count, added scenario count, elapsed effort, or a weakened assertion never constitutes success.', + }, + authorInstructions: [ + `Implement exactly one user journey with stable id @${selected.id}.`, + 'Read and follow e2e/journeys/AUTHORING.md before editing.', + 'Launch the built OSS Electron app with its bundled CLI; simulate only an uncontrollable external agent/provider wire.', + 'Reuse the existing harness, synthetic fixtures, Page Objects, and thin Cucumber steps.', + 'Do not change workflows, package manifests, lockfiles, suite policy scripts, or unrelated product behavior.', + 'Write only the Feature, step, Page Object, fixture, and three index paths allowed by e2e/journeys/AUTHORING.md.', + 'Do not edit the registry or generated coverage; trusted packaging owns that transition.', + 'Do not commit, push, create a PR, use network services, or read credentials.', + `For a ready result, name one unique quoted expectation to replace with ${JSON.stringify(`__LODY_COUNTERFACTUAL_${selected.id.replaceAll('-', '_')}__`)} during independent validation.`, + 'Do not run generated test code. After human review, the local maintainer validator owns build, counterfactual, focused, and full validation.', + 'If the boundary cannot be deterministic inside the lease, return a blocked classification; never use sleeps, retries, or live services.', + ], + }; + return { ...task, digest: digest(task) }; +} + +async function readStringArray(path) { + if (!path) return []; + const value = JSON.parse(await readFile(resolve(path), 'utf8')); + if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) { + throw new Error(`${path} must contain a JSON string array`); + } + return value; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const registry = loadJourneyRegistry(resolve(required(args, 'registry', 2_000))); + const now = args.has('now') ? Date.parse(args.get('now')) : Date.now(); + const task = createJourneyAuthorTask({ + registry, + excludedCandidateIds: await readStringArray(args.get('excluded')), + requestedCandidateId: args.get('candidate') ?? 'next', + budgetMinutes: clampBudget(args.get('budget-minutes') ?? '90'), + repository: required(args, 'repository', 200), + baseRef: required(args, 'base-ref', 200), + baseSha: required(args, 'base-sha', 64), + runId: required(args, 'run-id', 80), + trigger: required(args, 'trigger', 40), + now, + signals: { + changedFiles: await readStringArray(args.get('changed-files')), + escapedDefectIds: await readStringArray(args.get('escaped-defects')), + scoutJourneys: await readStringArray(args.get('scout-journeys')), + }, + }); + const outputPath = resolve(required(args, 'output', 2_000)); + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, `${JSON.stringify(task, null, 2)}\n`, 'utf8'); + process.stdout.write(`${task.disposition === 'claimed' ? task.candidate.id : 'queue-empty'}\n`); +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname)) { + await main(); +} diff --git a/.github/scripts/journey-author-task.test.mjs b/.github/scripts/journey-author-task.test.mjs new file mode 100644 index 000000000..ea4396513 --- /dev/null +++ b/.github/scripts/journey-author-task.test.mjs @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { journeyFingerprint } from '../../e2e/scripts/journey-registry.mjs'; +import { createJourneyAuthorTask } from './journey-author-task.mjs'; + +function journey(overrides = {}) { + const value = { + id: 'LODY-SESSION-002', + state: 'backlog', + priority: 'P1', + runtime: 'none', + title: 'Restore an archived Session', + owner: 'session', + fixture: 'seeded-session', + ownerPaths: ['packages/components/src/components/sessions/'], + actions: [{ id: 'session.restore' }], + checkpoints: ['Session appears in Archive', 'Session returns to the active list'], + cleanup: ['Session is deleted'], + coverage: { + renderer: 'Archive', + electronIpc: 'Session RPC', + bundledCli: 'Local runtime', + durableState: 'Archive state', + externalWire: 'None', + }, + gap: 'No desktop journey covers restore.', + evidence: ['session.tsx'], + signals: { criticality: 4, boundaryRisk: 4, changeFrequency: 3, escapedDefect: false }, + estimatedMinutes: 4, + freshness: 3, + scoutJourneys: [], + blockedReason: null, + ...overrides, + }; + return { ...value, fingerprint: journeyFingerprint(value) }; +} + +function create(journeys, overrides = {}) { + return createJourneyAuthorTask({ + registry: { + schemaVersion: 1, + scoring: { + criticality: 100, + boundaryRisk: 20, + changeFrequency: 5, + freshness: 10, + escapedDefect: 40, + scoutSignal: 25, + changedPath: 50, + estimatedMinutePenalty: 2, + }, + journeys, + }, + excludedCandidateIds: [], + requestedCandidateId: 'next', + budgetMinutes: 60, + repository: 'LodyAI/Lody', + baseRef: 'main', + baseSha: 'a'.repeat(40), + runId: '123', + trigger: 'schedule', + now: Date.parse('2026-09-04T00:00:00.000Z'), + signals: { changedFiles: [], escapedDefectIds: [], scoutJourneys: [] }, + ...overrides, + }); +} + +void test('claims the risk-ranked candidate with a fixed id and bounded lease', () => { + const task = create([ + journey(), + journey({ + id: 'LODY-MCP-001', + title: 'MCP selection', + signals: { criticality: 5, boundaryRisk: 5, changeFrequency: 4, escapedDefect: false }, + }), + ]); + assert.equal(task.candidate.id, 'LODY-MCP-001'); + assert.equal(task.claim.leaseId, 'LODY-MCP-001-123'); + assert.equal(task.claim.expiresAt, '2026-09-04T01:00:00.000Z'); +}); + +void test('excludes an active claim and advances to the next candidate', () => { + const task = create([journey(), journey({ id: 'LODY-MCP-001', title: 'MCP selection' })], { + excludedCandidateIds: ['LODY-MCP-001'], + }); + assert.equal(task.candidate.id, 'LODY-SESSION-002'); +}); + +void test('returns queue-empty instead of inventing a journey', () => { + const task = create([journey({ state: 'active', feature: 'src/features/lifecycle.feature' })]); + assert.equal(task.disposition, 'queue-empty'); + assert.equal(task.candidate, undefined); +}); diff --git a/.github/workflows/e2e-daily-reconcile.yml b/.github/workflows/e2e-daily-reconcile.yml new file mode 100644 index 000000000..e5c826b0c --- /dev/null +++ b/.github/workflows/e2e-daily-reconcile.yml @@ -0,0 +1,222 @@ +name: Desktop E2E Daily failure + +on: + workflow_run: + workflows: [Desktop E2E Daily] + types: [completed] + +permissions: + actions: read + contents: write + issues: write + +concurrency: + group: desktop-e2e-daily-failure + cancel-in-progress: false + +jobs: + reconcile: + name: Reconcile Daily failure evidence + if: >- + github.event.workflow_run.head_repository.full_name == github.repository && + github.event.workflow_run.head_branch == github.event.repository.default_branch && + (github.event.workflow_run.event == 'schedule' || github.event.workflow_run.event == 'workflow_dispatch') + runs-on: ubuntu-latest + steps: + - name: Checkout trusted reporting code + uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + + - name: Find Daily evidence + id: evidence + uses: actions/github-script@v7 + with: + script: | + const policy = await import( + `${process.env.GITHUB_WORKSPACE}/.github/scripts/e2e-daily-policy.mjs` + ) + const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.payload.workflow_run.id, + per_page: 100 + }) + const evidence = policy.findDailyEvidenceArtifact( + artifacts, + context.payload.workflow_run.id + ) + core.setOutput('available', String(Boolean(evidence))) + core.setOutput('suite', evidence?.suite ?? 'unknown') + core.setOutput('artifact-name', evidence?.artifact.name ?? '') + if (!evidence) core.warning('No unique, unexpired Daily evidence artifact is available.') + + - name: Reconcile Daily failure Issue + id: issue + uses: actions/github-script@v7 + env: + DAILY_SUITE: ${{ steps.evidence.outputs.suite }} + with: + script: | + const policy = await import( + `${process.env.GITHUB_WORKSPACE}/.github/scripts/e2e-daily-policy.mjs` + ) + const marker = '' + const title = '[E2E] Daily desktop journey failure' + const conclusion = context.payload.workflow_run.conclusion + const issues = await github.paginate(github.rest.issues.listForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'all', + per_page: 100 + }) + const existing = policy.findOwnedDailyFailureIssue(issues, marker) + + if (policy.canCloseDailyFailureIssue(conclusion, process.env.DAILY_SUITE)) { + if (existing?.state === 'open') { + const runUrl = context.payload.workflow_run.html_url + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existing.number, + body: `Desktop Daily recovered: ${runUrl}` + }) + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existing.number, + state: 'closed', + state_reason: 'completed' + }) + } + core.setOutput('report', 'false') + return + } + if (conclusion === 'success') { + core.notice( + `Daily ${process.env.DAILY_SUITE} succeeded without covering the full failure state.` + ) + core.setOutput('report', 'false') + return + } + if (conclusion === 'cancelled' || conclusion === 'skipped') { + core.setOutput('report', 'false') + return + } + + const body = [ + marker, + 'The default-branch Desktop Daily regression is failing.', + '', + 'Each failed round is appended as a comment with inline WebM recordings when available. Full traces, screenshots, logs, and runtime evidence remain in the linked Actions artifact.' + ].join('\n') + let issueNumber + if (existing) { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existing.number, + title, + body, + state: 'open' + }) + issueNumber = existing.number + } else { + const created = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title, + body + }) + issueNumber = created.data.number + } + core.setOutput('issue-number', String(issueNumber)) + core.setOutput('report', 'true') + + - name: Download Daily evidence + if: steps.issue.outputs.report == 'true' && steps.evidence.outputs.available == 'true' + uses: actions/download-artifact@v4 + with: + name: ${{ steps.evidence.outputs.artifact-name }} + path: daily-evidence + github-token: ${{ github.token }} + run-id: ${{ github.event.workflow_run.id }} + + - name: Prepare bounded failure comments + if: steps.issue.outputs.report == 'true' + id: report + shell: bash + env: + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + RUN_ID: ${{ github.event.workflow_run.id }} + RUN_URL: ${{ github.event.workflow_run.html_url }} + run: | + mkdir -p daily-evidence daily-report + manifest=$(node .github/scripts/e2e-daily-failure.mjs \ + --evidence-root daily-evidence \ + --output-root daily-report \ + --run-id "$RUN_ID" \ + --run-url "$RUN_URL" \ + --head-sha "$HEAD_SHA") + echo "manifest=$manifest" >> "$GITHUB_OUTPUT" + + - name: Install GitHub CLI attachment support + if: steps.issue.outputs.report == 'true' + id: gh + shell: bash + env: + GH_ARCHIVE_SHA256: e4d4bb4498e8d007abe545b6568926793ace1b6447da598294a610018cb164be + GH_VERSION: 2.100.0 + run: | + archive="$RUNNER_TEMP/gh_${GH_VERSION}_linux_amd64.tar.gz" + curl --fail --location --retry 3 \ + "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_amd64.tar.gz" \ + --output "$archive" + printf '%s %s\n' "$GH_ARCHIVE_SHA256" "$archive" | sha256sum --check --status + tar -xzf "$archive" -C "$RUNNER_TEMP" + binary="$RUNNER_TEMP/gh_${GH_VERSION}_linux_amd64/bin/gh" + "$binary" issue comment --help | grep --fixed-strings --quiet -- '--attach' + echo "binary=$binary" >> "$GITHUB_OUTPUT" + + - name: Attach all failed scenario videos + if: steps.issue.outputs.report == 'true' + shell: bash + env: + GH_ATTACH_BIN: ${{ steps.gh.outputs.binary }} + GH_TOKEN: ${{ github.token }} + ISSUE_NUMBER: ${{ steps.issue.outputs.issue-number }} + MANIFEST: ${{ steps.report.outputs.manifest }} + run: | + comments="$RUNNER_TEMP/daily-failure-comments.json" + "$GH_ATTACH_BIN" api --paginate --slurp \ + "repos/$GITHUB_REPOSITORY/issues/$ISSUE_NUMBER/comments" > "$comments" + batch_count=$(jq '.batches | length' "$MANIFEST") + for ((index = 0; index < batch_count; index += 1)); do + marker=$(jq -r --argjson index "$index" '.batches[$index].marker' "$MANIFEST") + expected_videos=$( + jq --argjson index "$index" '.batches[$index].videos | length' "$MANIFEST" + ) + complete=$( + node .github/scripts/e2e-daily-policy.mjs comment-complete \ + --comments "$comments" \ + --marker "$marker" \ + --expected "$expected_videos" + ) + if [[ "$complete" == 'true' ]]; then + echo "Skipping previously attached batch: $marker" + continue + fi + body_path=$(jq -r --argjson index "$index" '.batches[$index].bodyPath' "$MANIFEST") + mapfile -t videos < <( + jq -r --argjson index "$index" '.batches[$index].videos[]' "$MANIFEST" + ) + attachment_args=() + for video in "${videos[@]}"; do + attachment_args+=(--attach "$video") + done + "$GH_ATTACH_BIN" issue comment "$ISSUE_NUMBER" \ + --repo "$GITHUB_REPOSITORY" \ + --body-file "$body_path" \ + "${attachment_args[@]}" + done diff --git a/.github/workflows/e2e-daily.yml b/.github/workflows/e2e-daily.yml new file mode 100644 index 000000000..d85c7b026 --- /dev/null +++ b/.github/workflows/e2e-daily.yml @@ -0,0 +1,78 @@ +name: Desktop E2E Daily + +on: + schedule: + - cron: '0 2 * * *' + workflow_dispatch: + inputs: + suite: + description: Regression suite to run + required: true + type: choice + options: [full, smoke] + default: full + +permissions: + contents: read + +concurrency: + group: desktop-e2e-daily + cancel-in-progress: true + +env: + NODE_VERSION: 22 + COREPACK_ENABLE_DOWNLOAD_PROMPT: 0 + +jobs: + regression: + name: Desktop E2E daily (${{ github.event.inputs.suite == 'smoke' && 'smoke' || 'full' }}) + runs-on: macos-15 + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Check suite contract + run: pnpm --filter @lody/e2e check + + - name: Build OSS desktop and bundled CLI + run: pnpm --dir apps/electron build + + - name: Run desktop journeys + shell: bash + env: + SUITE: ${{ github.event.inputs.suite == 'smoke' && 'smoke' || 'full' }} + run: | + set -o pipefail + mkdir -p e2e/artifacts + pnpm --filter @lody/e2e "$SUITE" 2>&1 | tee e2e/artifacts/cucumber-output.log + + - name: Render failed journey videos + if: failure() + run: | + pnpm --filter @lody/e2e exec playwright install ffmpeg + pnpm --filter @lody/e2e failure:videos + + - name: Upload desktop E2E evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: desktop-e2e-daily-${{ github.event.inputs.suite == 'smoke' && 'smoke' || 'full' }}-${{ github.run_id }} + path: e2e/artifacts/ + if-no-files-found: warn + retention-days: 30 diff --git a/.github/workflows/e2e-pr-reconcile.yml b/.github/workflows/e2e-pr-reconcile.yml new file mode 100644 index 000000000..6a40f50f9 --- /dev/null +++ b/.github/workflows/e2e-pr-reconcile.yml @@ -0,0 +1,174 @@ +name: Desktop E2E PR failure + +on: + workflow_run: + workflows: [Desktop E2E PR] + types: [completed] + +permissions: + actions: read + contents: write + pull-requests: write + +concurrency: + group: desktop-e2e-pr-failure-${{ github.event.workflow_run.id }} + cancel-in-progress: false + +jobs: + report: + name: Report PR failure evidence + if: >- + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion != 'success' && + github.event.workflow_run.conclusion != 'cancelled' && + github.event.workflow_run.conclusion != 'skipped' + runs-on: ubuntu-latest + steps: + - name: Checkout trusted reporting code + uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + + - name: Resolve current pull request + id: target + uses: actions/github-script@v7 + with: + script: | + const associated = context.payload.workflow_run.pull_requests ?? [] + if (associated.length !== 1) { + core.warning(`Expected one associated pull request, found ${associated.length}.`) + core.setOutput('report', 'false') + return + } + const pull = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: associated[0].number + }) + if (pull.data.state !== 'open') { + core.notice(`Pull request #${pull.data.number} is no longer open.`) + core.setOutput('report', 'false') + return + } + if (pull.data.head.sha !== context.payload.workflow_run.head_sha) { + core.notice(`Skipping stale failure from ${context.payload.workflow_run.head_sha}.`) + core.setOutput('report', 'false') + return + } + core.setOutput('number', String(pull.data.number)) + core.setOutput('report', 'true') + + - name: Find PR evidence + if: steps.target.outputs.report == 'true' + id: evidence + uses: actions/github-script@v7 + with: + script: | + const policy = await import( + `${process.env.GITHUB_WORKSPACE}/.github/scripts/e2e-daily-policy.mjs` + ) + const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.payload.workflow_run.id, + per_page: 100 + }) + const evidence = policy.findPrEvidenceArtifact( + artifacts, + context.payload.workflow_run.id + ) + core.setOutput('available', String(Boolean(evidence))) + core.setOutput('suite', evidence?.suite ?? 'unknown') + core.setOutput('artifact-name', evidence?.artifact.name ?? '') + if (!evidence) core.warning('No unique, unexpired PR evidence artifact is available.') + + - name: Download PR evidence + if: steps.target.outputs.report == 'true' && steps.evidence.outputs.available == 'true' + uses: actions/download-artifact@v4 + with: + name: ${{ steps.evidence.outputs.artifact-name }} + path: pr-evidence + github-token: ${{ github.token }} + run-id: ${{ github.event.workflow_run.id }} + + - name: Prepare bounded failure comments + if: steps.target.outputs.report == 'true' + id: report + shell: bash + env: + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + RUN_ID: ${{ github.event.workflow_run.id }} + RUN_URL: ${{ github.event.workflow_run.html_url }} + SUITE: ${{ steps.evidence.outputs.suite }} + run: | + mkdir -p pr-evidence pr-report + manifest=$(node .github/scripts/e2e-daily-failure.mjs \ + --evidence-root pr-evidence \ + --output-root pr-report \ + --run-id "$RUN_ID" \ + --run-url "$RUN_URL" \ + --head-sha "$HEAD_SHA" \ + --channel pr \ + --suite "$SUITE") + echo "manifest=$manifest" >> "$GITHUB_OUTPUT" + + - name: Install GitHub CLI attachment support + if: steps.target.outputs.report == 'true' + id: gh + shell: bash + env: + GH_ARCHIVE_SHA256: e4d4bb4498e8d007abe545b6568926793ace1b6447da598294a610018cb164be + GH_VERSION: 2.100.0 + run: | + archive="$RUNNER_TEMP/gh_${GH_VERSION}_linux_amd64.tar.gz" + curl --fail --location --retry 3 \ + "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_amd64.tar.gz" \ + --output "$archive" + printf '%s %s\n' "$GH_ARCHIVE_SHA256" "$archive" | sha256sum --check --status + tar -xzf "$archive" -C "$RUNNER_TEMP" + binary="$RUNNER_TEMP/gh_${GH_VERSION}_linux_amd64/bin/gh" + "$binary" pr comment --help | grep --fixed-strings --quiet -- '--attach' + echo "binary=$binary" >> "$GITHUB_OUTPUT" + + - name: Attach all failed scenario videos + if: steps.target.outputs.report == 'true' + shell: bash + env: + GH_ATTACH_BIN: ${{ steps.gh.outputs.binary }} + GH_TOKEN: ${{ github.token }} + MANIFEST: ${{ steps.report.outputs.manifest }} + PR_NUMBER: ${{ steps.target.outputs.number }} + run: | + comments="$RUNNER_TEMP/pr-failure-comments.json" + "$GH_ATTACH_BIN" api --paginate --slurp \ + "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" > "$comments" + batch_count=$(jq '.batches | length' "$MANIFEST") + for ((index = 0; index < batch_count; index += 1)); do + marker=$(jq -r --argjson index "$index" '.batches[$index].marker' "$MANIFEST") + expected_videos=$( + jq --argjson index "$index" '.batches[$index].videos | length' "$MANIFEST" + ) + complete=$( + node .github/scripts/e2e-daily-policy.mjs comment-complete \ + --comments "$comments" \ + --marker "$marker" \ + --expected "$expected_videos" + ) + if [[ "$complete" == 'true' ]]; then + echo "Skipping previously attached batch: $marker" + continue + fi + body_path=$(jq -r --argjson index "$index" '.batches[$index].bodyPath' "$MANIFEST") + mapfile -t videos < <( + jq -r --argjson index "$index" '.batches[$index].videos[]' "$MANIFEST" + ) + attachment_args=() + for video in "${videos[@]}"; do + attachment_args+=(--attach "$video") + done + "$GH_ATTACH_BIN" pr comment "$PR_NUMBER" \ + --repo "$GITHUB_REPOSITORY" \ + --body-file "$body_path" \ + "${attachment_args[@]}" + done diff --git a/.github/workflows/e2e-scout-reconcile.yml b/.github/workflows/e2e-scout-reconcile.yml new file mode 100644 index 000000000..79ce5164e --- /dev/null +++ b/.github/workflows/e2e-scout-reconcile.yml @@ -0,0 +1,218 @@ +name: Desktop E2E Scout candidate + +on: + workflow_run: + workflows: [Desktop E2E Scout] + types: [completed] + +permissions: + actions: read + contents: read + issues: write + +concurrency: + group: desktop-e2e-scout-candidate + cancel-in-progress: false + +jobs: + reconcile: + name: Reconcile resource trend candidate + if: >- + github.event.workflow_run.head_repository.full_name == github.repository && + github.event.workflow_run.head_branch == github.event.repository.default_branch && + (github.event.workflow_run.event == 'schedule' || github.event.workflow_run.event == 'workflow_dispatch') + runs-on: ubuntu-latest + steps: + - name: Find Scout evidence + id: evidence + uses: actions/github-script@v7 + with: + script: | + const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.payload.workflow_run.id, + per_page: 100 + }) + const expectedName = `desktop-e2e-scout-${context.payload.workflow_run.id}` + const artifact = artifacts.find((candidate) => + !candidate.expired && candidate.name === expectedName + ) + core.setOutput('available', String(Boolean(artifact))) + if (!artifact) { + core.notice(`No unexpired ${expectedName} artifact is available; no Issue will be changed.`) + } + + - name: Download Scout evidence + if: steps.evidence.outputs.available == 'true' + uses: actions/download-artifact@v4 + with: + name: desktop-e2e-scout-${{ github.event.workflow_run.id }} + path: scout-evidence + github-token: ${{ github.token }} + run-id: ${{ github.event.workflow_run.id }} + + - name: Reconcile candidate Issue + if: steps.evidence.outputs.available == 'true' + uses: actions/github-script@v7 + env: + SCOUT_EVIDENCE_ROOT: ${{ github.workspace }}/scout-evidence + with: + script: | + const fs = require('node:fs/promises') + const path = require('node:path') + + const marker = '' + const title = '[Scout] Candidate desktop resource trend' + const root = path.resolve(process.env.SCOUT_EVIDENCE_ROOT) + const maxFiles = 10_000 + const maxSummaryBytes = 2 * 1024 * 1024 + const summaries = [] + let visitedFiles = 0 + + async function collect(directory) { + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const candidate = path.join(directory, entry.name) + if (entry.isDirectory()) { + await collect(candidate) + continue + } + if (!entry.isFile()) continue + visitedFiles += 1 + if (visitedFiles > maxFiles) throw new Error('Scout artifact contains too many files') + if (entry.name !== 'summary.json') continue + + const stat = await fs.stat(candidate) + if (stat.size > maxSummaryBytes) { + core.warning(`Ignoring oversized summary: ${path.relative(root, candidate)}`) + continue + } + + try { + const value = JSON.parse(await fs.readFile(candidate, 'utf8')) + if ( + value?.schemaVersion !== 1 || + typeof value.roundId !== 'string' || + typeof value.createdAt !== 'string' || + typeof value.options !== 'object' || + value.options === null || + !Array.isArray(value.journeys) || + !Array.isArray(value.suspectedTrends) + ) { + core.warning(`Ignoring invalid summary: ${path.relative(root, candidate)}`) + continue + } + const trendsAreValid = value.suspectedTrends.every((entry) => + entry !== null && + typeof entry === 'object' && + typeof entry.journey === 'string' && + typeof entry.metric === 'string' && + entry.trend !== null && + typeof entry.trend === 'object' && + typeof entry.reason === 'string' + ) + if (!trendsAreValid) { + core.warning(`Ignoring summary with invalid trends: ${path.relative(root, candidate)}`) + continue + } + const createdAt = Date.parse(value.createdAt) + if (!Number.isFinite(createdAt)) { + core.warning(`Ignoring summary with invalid createdAt: ${path.relative(root, candidate)}`) + continue + } + summaries.push({ createdAt, file: candidate, value }) + } catch (error) { + core.warning(`Ignoring unreadable summary: ${path.relative(root, candidate)} (${error.message})`) + } + } + } + + await collect(root) + summaries.sort((left, right) => + right.createdAt - left.createdAt || right.file.localeCompare(left.file) + ) + const latest = summaries[0]?.value + if (!latest) { + core.notice('No valid Scout summary was found; no Issue will be changed.') + return + } + if (latest.suspectedTrends.length === 0) { + core.info(`Scout round ${latest.roundId} reported no suspected trends.`) + return + } + + function cell(value, limit = 240) { + const text = typeof value === 'string' ? value : JSON.stringify(value) + return String(text ?? '') + .replaceAll('|', '\\|') + .replaceAll('\r', ' ') + .replaceAll('\n', ' ') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('`', "'") + .slice(0, limit) + } + + const trends = latest.suspectedTrends.slice(0, 25).map((entry) => ({ + journey: cell(entry.journey, 120), + metric: cell(entry.metric, 120), + trend: cell(entry.trend, 320), + reason: cell(entry.reason, 320) + })) + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.payload.workflow_run.id}` + const body = [ + marker, + 'The non-blocking desktop resource Scout found repeatable candidate trends. This Issue is evidence for triage, not a confirmed leak.', + '', + `- Latest round: \`${cell(latest.roundId, 120)}\``, + `- Captured: \`${new Date(Date.parse(latest.createdAt)).toISOString()}\``, + `- Workflow run: ${runUrl}`, + `- Options: \`${cell(latest.options, 500)}\``, + '', + '| Journey | Metric | Trend | Reason |', + '| --- | --- | --- | --- |', + ...trends.map((entry) => + `| ${entry.journey} | ${entry.metric} | ${entry.trend} | ${entry.reason} |` + ), + '', + latest.suspectedTrends.length > trends.length + ? `Only the first ${trends.length} of ${latest.suspectedTrends.length} trends are shown. Download the artifact for the complete report.` + : 'Download the workflow artifact for metrics, traces, and heap diagnostics.', + '', + 'A maintainer should confirm the slope across independent rounds before promoting a narrow regression to a blocking gate.' + ].join('\n') + + const issues = await github.paginate(github.rest.issues.listForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100 + }) + const existing = issues + .filter((issue) => + !issue.pull_request && + issue.user?.login === 'github-actions[bot]' && + issue.user?.type === 'Bot' && + issue.body?.includes(marker) + ) + .sort((left, right) => left.number - right.number)[0] + + if (existing) { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existing.number, + title, + body + }) + core.info(`Updated candidate Issue #${existing.number}.`) + return + } + + const created = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title, + body + }) + core.info(`Created candidate Issue #${created.data.number}.`) diff --git a/.github/workflows/e2e-scout.yml b/.github/workflows/e2e-scout.yml new file mode 100644 index 000000000..07b4b2665 --- /dev/null +++ b/.github/workflows/e2e-scout.yml @@ -0,0 +1,91 @@ +name: Desktop E2E Scout + +on: + schedule: + - cron: '0 4 * * *' + workflow_dispatch: + inputs: + journey: + description: Resource journey to exercise + required: true + type: choice + options: [all, session, review, work] + default: all + iterations: + description: Measured iterations per journey + required: true + type: number + default: 30 + +permissions: + contents: read + +concurrency: + group: desktop-e2e-scout + cancel-in-progress: true + +env: + NODE_VERSION: 22 + COREPACK_ENABLE_DOWNLOAD_PROMPT: 0 + +jobs: + scout: + name: Desktop E2E resource scout + runs-on: macos-15 + timeout-minutes: 180 + steps: + - name: Checkout trusted default branch + uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + submodules: recursive + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Check suite contract + run: pnpm e2e:check + + - name: Build OSS desktop and bundled CLI + run: pnpm e2e:build + + - name: Run resource scout + id: scout + shell: bash + env: + SCOUT_ITERATIONS: ${{ github.event.inputs.iterations || '30' }} + SCOUT_JOURNEY: ${{ github.event.inputs.journey || 'all' }} + run: | + set -o pipefail + mkdir -p e2e/artifacts/scout + pnpm e2e:scout -- --journey "$SCOUT_JOURNEY" --iterations "$SCOUT_ITERATIONS" \ + 2>&1 | tee e2e/artifacts/scout/scout-output.log + + - name: Record informational outcome + if: always() + shell: bash + env: + SCOUT_OUTCOME: ${{ steps.scout.outcome }} + run: | + mkdir -p e2e/artifacts/scout + printf '%s\n' "$SCOUT_OUTCOME" > e2e/artifacts/scout/workflow-outcome.txt + printf "### Desktop E2E Scout\n\nProbe outcome: \`%s\`\n" "$SCOUT_OUTCOME" >> "$GITHUB_STEP_SUMMARY" + + - name: Upload Scout evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: desktop-e2e-scout-${{ github.run_id }} + path: e2e/artifacts/scout/ + if-no-files-found: warn + retention-days: 30 diff --git a/.github/workflows/e2e-smoke.yml b/.github/workflows/e2e-smoke.yml new file mode 100644 index 000000000..32b4f48e2 --- /dev/null +++ b/.github/workflows/e2e-smoke.yml @@ -0,0 +1,129 @@ +name: Desktop E2E PR + +on: + pull_request: + types: [opened, reopened, synchronize, labeled, unlabeled] + workflow_dispatch: + inputs: + suite: + description: Regression suite to run + required: true + type: choice + options: [smoke, full] + default: smoke + +permissions: + contents: read + pull-requests: read + +concurrency: + group: desktop-e2e-pr-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + NODE_VERSION: 22 + COREPACK_ENABLE_DOWNLOAD_PROMPT: 0 + +jobs: + gate: + name: Select desktop E2E suite + runs-on: ubuntu-latest + outputs: + run: ${{ steps.select.outputs.run }} + suite: ${{ steps.select.outputs.suite }} + steps: + - name: Select suite from trusted event metadata + id: select + uses: actions/github-script@v7 + with: + script: | + if (context.eventName === 'workflow_dispatch') { + core.setOutput('run', 'true') + core.setOutput('suite', context.payload.inputs?.suite === 'full' ? 'full' : 'smoke') + return + } + + const labels = new Set((context.payload.pull_request?.labels ?? []).map((label) => label.name)) + const files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + per_page: 100 + }) + const criticalPrefixes = [ + 'e2e/', + 'apps/electron/', + 'apps/cli/', + 'packages/components/', + 'packages/platform/', + 'packages/shared/', + 'packages/cli-supervisor/' + ] + const criticalFiles = new Set([ + 'package.json', + 'pnpm-lock.yaml', + 'pnpm-workspace.yaml', + '.github/workflows/e2e-smoke.yml', + '.github/workflows/e2e-pr-reconcile.yml', + '.github/workflows/e2e-daily.yml', + '.github/workflows/e2e-daily-reconcile.yml' + ]) + const critical = files.some(({ filename }) => + criticalFiles.has(filename) || criticalPrefixes.some((prefix) => filename.startsWith(prefix)) + ) + core.setOutput('run', String(critical || labels.has('e2e') || labels.has('e2e-full'))) + core.setOutput('suite', labels.has('e2e-full') ? 'full' : 'smoke') + + regression: + name: Desktop E2E (${{ needs.gate.outputs.suite }}) + needs: gate + if: needs.gate.outputs.run == 'true' + runs-on: macos-15 + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + submodules: recursive + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Check suite contract + run: pnpm --filter @lody/e2e check + + - name: Build OSS desktop and bundled CLI + run: pnpm --dir apps/electron build + + - name: Run selected desktop journeys + shell: bash + run: | + set -o pipefail + mkdir -p e2e/artifacts + pnpm --filter @lody/e2e ${{ needs.gate.outputs.suite }} 2>&1 | tee e2e/artifacts/cucumber-output.log + + - name: Render failed journey videos + if: failure() + run: | + pnpm --filter @lody/e2e exec playwright install ffmpeg + pnpm --filter @lody/e2e failure:videos + + - name: Upload desktop E2E evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: desktop-e2e-${{ needs.gate.outputs.suite }}-${{ github.run_id }} + path: e2e/artifacts/ + if-no-files-found: warn + retention-days: 14 diff --git a/.gitignore b/.gitignore index ebb9477a6..691dbfe3b 100644 --- a/.gitignore +++ b/.gitignore @@ -120,6 +120,7 @@ dist *~ logs/ .lody-e2e/ +/e2e/artifacts/ # OS .DS_Store diff --git a/apps/electron/src/main/deep-link.ts b/apps/electron/src/main/deep-link.ts index 5163e207c..08ed11874 100644 --- a/apps/electron/src/main/deep-link.ts +++ b/apps/electron/src/main/deep-link.ts @@ -80,6 +80,10 @@ export function handleDeepLink(url: string): void { export function acquireSingleInstanceLock(): boolean { const deepLinkFromArgv = extractDeepLinkFromArgv(process.argv) + if (!app.isPackaged && process.env.LODY_E2E === '1') { + logAuthDebug('skipping single-instance lock for isolated Electron E2E') + return true + } const gotSingleInstanceLock = app.requestSingleInstanceLock() logAuthDebug('requestSingleInstanceLock completed', { gotSingleInstanceLock, diff --git a/apps/electron/src/main/index.ts b/apps/electron/src/main/index.ts index ceecf86ee..0485c97c0 100644 --- a/apps/electron/src/main/index.ts +++ b/apps/electron/src/main/index.ts @@ -1,6 +1,7 @@ import { app, BrowserWindow, safeStorage } from 'electron' import { electronApp, optimizer } from '@electron-toolkit/utils' import dns from 'node:dns' +import { writeHeapSnapshot } from 'node:v8' import icon from '../../resources/icon.png?asset' import macIcon from '../../build/icon-mac.padded.png?asset' import { acquireSingleInstanceLock, registerOpenUrlHandler } from './deep-link' @@ -75,6 +76,27 @@ const LODY_PROTOCOL = desktopInstallationProfile.desktopProtocol const PRODUCT_NAME = desktopInstallationProfile.desktopProductName const DESKTOP_FILE_NAME = `${desktopInstallationProfile.desktopAppId}.desktop` const DEEP_LINK_DEBUG_PREFIX = '[electron-auth-debug]' +const IS_E2E = !app.isPackaged && process.env.LODY_E2E === '1' + +type E2EBootDiagnostic = { stage: string; error?: string } +type E2EGlobal = typeof globalThis & { + __LODY_E2E_BOOT_DIAGNOSTIC__?: E2EBootDiagnostic + __LODY_E2E_WRITE_HEAP_SNAPSHOT__?: (path: string) => string +} + +if (IS_E2E) { + ;(globalThis as E2EGlobal).__LODY_E2E_WRITE_HEAP_SNAPSHOT__ = (path) => writeHeapSnapshot(path) +} + +function recordE2EBootDiagnostic(stage: string, error?: unknown): void { + if (!IS_E2E) return + const diagnostic: E2EBootDiagnostic = { stage } + if (error !== undefined) { + diagnostic.error = error instanceof Error ? (error.stack ?? error.message) : String(error) + } + const e2eGlobal = globalThis as E2EGlobal + e2eGlobal.__LODY_E2E_BOOT_DIAGNOSTIC__ = diagnostic +} function logDeepLinkDebug(message: string, meta?: Record): void { if (meta) { @@ -154,7 +176,9 @@ if (hasSingleInstanceLock) { } if (hasSingleInstanceLock) { - void app.whenReady().then(() => { + recordE2EBootDiagnostic('waiting-for-app-ready') + const appReady = app.whenReady().then(() => { + recordE2EBootDiagnostic('initializing-services') if (process.platform === 'darwin' && !app.isPackaged) app.dock?.setIcon(macIcon) logDeepLinkDebug('app.whenReady resolved', { @@ -251,7 +275,9 @@ if (hasSingleInstanceLock) { initialPath, hasInitialDeepLink: Boolean(extractDeepLinkFromArgv(process.argv)) }) + recordE2EBootDiagnostic('opening-main-window') openMainWindow({ icon, initialPath, hideWindowOnAutoLaunch }) + recordE2EBootDiagnostic('main-window-opened') console.info('[Electron] Initial desktop surface selected', { initialPath, hideWindowOnAutoLaunch @@ -310,6 +336,11 @@ if (hasSingleInstanceLock) { publicBrowserService.destroyAll() }) }) + void appReady.catch((error: unknown) => { + recordE2EBootDiagnostic('failed', error) + console.error('[Electron] Fatal error while creating the main window', error) + if (!IS_E2E) app.exit(1) + }) } app.on('window-all-closed', () => { diff --git a/apps/electron/src/main/protocol-client.ts b/apps/electron/src/main/protocol-client.ts index 697c7b10f..8b40efed3 100644 --- a/apps/electron/src/main/protocol-client.ts +++ b/apps/electron/src/main/protocol-client.ts @@ -264,6 +264,10 @@ function registerLinuxAppImageProtocolHandler({ export function registerLodyProtocolClient(options: RegisterProtocolClientOptions): void { const { protocol, log } = options + if (!app.isPackaged && process.env.LODY_E2E === '1') { + log('registerLodyProtocolClient skipped for E2E', { protocol }) + return + } const appEntry = resolveDefaultAppEntryPath() let registrationResult = false diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md new file mode 100644 index 000000000..5d23199e6 --- /dev/null +++ b/e2e/AGENTS.md @@ -0,0 +1,76 @@ +# Desktop E2E contributor guidelines + +`CLAUDE.md` is a symlink to this file. Edit `AGENTS.md` only. Root `AGENTS.md` +also applies. + +## Runtime boundary + +- Application E2E always launches the built OSS desktop through Playwright + Electron. A browser-only renderer test belongs in `packages/components`. +- Build once before a run. Scenarios consume `apps/electron/out/main/index.js` + and the synced CLI under `apps/electron/resources/cli`; they never rebuild. +- Use a real Electron main process, preload, renderer, IPC graph, and bundled + CLI. Only an external model/provider wire may be simulated. +- Every run owns a temporary Electron user-data directory, Lody data directory, + workspace, artifact directory, and CLI host endpoint. `LODY_E2E=1` and the + random TCP port on POSIX or unique named pipe on Windows must travel together + to Electron and all CLI descendants. Never kill or attach to a user's + existing Lody process. +- Run scenarios serially until every remaining fixed OS endpoint has an + explicit shared test binding. Do not raise Cucumber parallelism first. + +## Scenario contract + +- `journeys/registry.json` is the machine-readable source of truth for active + journeys and evidence-backed gaps. `COVERAGE.md` is generated from it; never + edit the matrix by hand. Every executable scenario has exactly one matching + `active` registry row with the same id, priority, runtime, and feature path. +- Backlog scoring is deterministic. A candidate's semantic fingerprint covers + its runtime, fixture, ordered actions, checkpoints, and cleanup. Keep blocked + gaps in the registry with an actionable `blockedReason`; selection skips them + instead of blocking the rest of the queue. +- Local authoring claims at most one backlog row per run. Codex works in an + ephemeral detached worktree and cannot edit product code, harness policy, the + registry, or generated coverage. It packages a candidate without executing + generated code. After explicit human review, a second ephemeral worktree + promotes the row, proves one assertion ablation fails, restores exact file + hashes, and runs three fresh focused rounds plus the full suite. Only a passed + candidate is applied to the maintainer checkout. Neither command publishes it. +- Every scenario has `@lody`, `@essence`, exactly one of `@P0` or `@P1`, + exactly one `@runtime-*` owner, and one stable `@LODY-AREA-NNN` id. +- `@P0` is a short merge-blocking journey. `@P1` is a deeper scheduled or + labeled journey. `@runtime-none` means no ACP model runtime is needed; it + does not mean the bundled CLI may be mocked. +- Do not commit `@wip` scenarios. Keep Gherkin steps thin and put selectors and + interaction policy in Page Objects. +- Prefer accessible roles and stable product-owned test ids. Never select by + generated class names or animation timing. +- Await observable state or an explicit protocol response. Real sleeps, + wall-clock races, retries that hide failure, and live network calls are + forbidden. + +## Evidence and lanes + +- Regression E2E is deterministic and blocking. On failure, retain the + screenshot, Playwright trace, renderer/main logs, CLI backlog, process and + memory snapshot, and machine-readable failure index. +- Daily regression records each scenario independently, deletes passing videos, + and retains one `failure.webm` per failed scenario. The read-only runner only + uploads evidence; a trusted default-branch reconciler validates and attaches + every bounded WebM to an independently retryable Daily failure Issue comment. + Only a successful full Daily may close that Issue; smoke success never clears + failure state that can include P1 coverage. +- Pull-request regression also records scenarios and retains failed videos. Its + read-only runner uploads evidence; a trusted default-branch reconciler may + validate that artifact and attach it only to the current matching PR head. +- Acceptance is a separate immutable round. It captures successful user-visible + checkpoints and metrics for human review; a later repair creates a new round. +- Scout is a separate non-blocking soak lane. It may reuse this harness and Page + Objects, but it owns repeated execution, explicit GC checkpoints, slope + analysis, and diagnostic heap capture. Never put soak thresholds in `@P0` or + `@P1` regression scenarios. +- Runtime artifacts under `e2e/artifacts/` are ignored. Fixtures committed to + the suite must be synthetic and contain no user or agent transcript. + +Run `pnpm e2e:check` after changing suite metadata and `pnpm e2e:build && pnpm +e2e:smoke` after changing the harness or an active P0 journey. diff --git a/e2e/ARTIFACTS.md b/e2e/ARTIFACTS.md new file mode 100644 index 000000000..fe9f7b61d --- /dev/null +++ b/e2e/ARTIFACTS.md @@ -0,0 +1,57 @@ +# Verification artifacts + +Runtime output is written below ignored `e2e/artifacts/` directories. + +| Artifact | Meaning | +| -------------------- | --------------------------------------------------------------- | +| `failure.png` | Full-window state at the failing step | +| `trace.zip` | Playwright actions, DOM snapshots, network, and screenshots | +| `failure.webm` | Daily-only recording retained for a failed scenario | +| `runtime.json` | Electron, renderer, process, DOM, and memory snapshot | +| `console.log` | Timestamped renderer, Electron main, page, and request failures | +| `cli-backlog.json` | Bundled CLI output exposed through the production IPC service | +| `failure-index.json` | Stable scenario id to artifact-directory mapping | + +Daily and pull-request regression trace every scenario with screenshots. After +a journey failure, a separate bounded renderer samples at most 600 ordered +trace frames and encodes a 640px `failure.webm`. Video generation therefore +cannot alter Electron startup or journey timing. Successful scenarios do not +produce videos. The read-only Daily job uploads all evidence as one +suite-qualified Actions artifact. +A separate trusted reconciler validates the failure index and each video, then +attaches up to one independently retryable comment per failed scenario on the +durable Daily failure Issue. Only a successful `full` artifact can resolve that +Issue; a successful `smoke` run does not cover prior P1 failures. Oversized, +missing, symbolic-link, and unexpected-path files are never attached; the +workflow run remains linked for complete trace and log retrieval. +Pull-request failures use the same bounded video contract, but attach to the +matching open PR only while its head still equals the failed workflow head. + +Acceptance rounds additionally contain `result.json`, `manifest.json`, and a +successful `checkpoint.png` for every selected scenario. Supplied before/after +JSON and retained-path evidence are copied below `evidence/`. The result is +ready for review only when every declared file exists and is non-empty; the +manifest records byte length and SHA-256. A round directory is immutable: rerun +the command to create a new round instead of editing an existing result. + +Scout rounds use `scout//summary.json` as the CI discovery contract: + +```text +scout// + summary.json + / + scout-result.json + runtime.json + console.log + ablation.json # ablation runs only + trace.zip # failure or suspected trend + failure.png # failure only + heap/*.heapsnapshot # failure or suspected trend +``` + +`summary.json` has `schemaVersion: 1`, a unique `roundId`, `createdAt`, run +`options`, per-journey status/metrics, and a top-level `suspectedTrends` array. +Each metric declares whether it is a `post-gc-candidate` or `observational` +signal. Each journey result retains its active and post-cleanup trend summaries; +raw checkpoints remain in `scout-result.json`. Heap snapshots and traces may be +large and are captured only when they add diagnostic value. diff --git a/e2e/CLAUDE.md b/e2e/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/e2e/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/e2e/COVERAGE.md b/e2e/COVERAGE.md new file mode 100644 index 000000000..270c13f13 --- /dev/null +++ b/e2e/COVERAGE.md @@ -0,0 +1,33 @@ +# Desktop journey coverage + +This file is generated from [`journeys/registry.json`](./journeys/registry.json). +Run `pnpm --filter @lody/e2e journey:coverage` after changing the registry. + +The active matrix records product boundaries exercised by implemented scenarios. +Backlog rows are evidence-backed gaps, not executable or promised scenarios. + +## Active P0 journeys + +| Stable id | Journey | Renderer | Electron / IPC | Bundled CLI | Durable state | External wire | +| --------------------- | ------------------------------------------------------------------- | --------------------- | ------------------------------ | ------------------ | ----------------------------------------------- | ------------- | +| `LODY-ONBOARDING-001` | New user enters an isolated local workspace through the bundled CLI | Intro and local entry | Real window and invoke bridge | Real owned runtime | Isolated workspace catalog and onboarding state | None | +| `LODY-SESSION-001` | Stop and permanently delete a running ACP Session | Session lifecycle | Real window and invoke bridge | Real owned runtime | Create, stop, archive, and permanent delete | Scripted ACP | +| `LODY-WORK-001` | Delete a worktree Session with ACP and Terminal resources | Work lifecycle | Real window, IPC, and Terminal | Real owned runtime | Session, worktree, and terminal cleanup | Scripted ACP | + +## Active P1 journeys + +| Stable id | Journey | Renderer | Electron / IPC | Bundled CLI | Durable state | External wire | +| ----------------- | --------------------------------------------- | --------------------------- | ------------------------ | ------------------ | --------------------------------------- | ------------- | +| `LODY-REVIEW-001` | Open, hide, and switch a synthetic large diff | Large diff Review lifecycle | Real window and diff RPC | Real owned runtime | Synthetic project and Session lifecycle | Scripted ACP | + +## Evidence-backed backlog + +| Stable id | Priority | Owner | Freshness | Proposed journey | Estimated minutes | Status | Gap | +| ------------------ | -------- | ----------------- | --------: | ----------------------------------------------------------------------------------- | ----------------: | -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `LODY-FORK-001` | P1 | session-lifecycle | 4/5 | Fork a completed Session into an independent worktree Session | 8 | Eligible | Fork durability and compensation have Node coverage but no real desktop journey across renderer, IPC, CLI, Git, and persisted history. | +| `LODY-MCP-001` | P1 | workspace-catalog | 4/5 | Create a workspace MCP server and preserve explicit turn selection through dispatch | 6 | Eligible | No desktop journey proves that an explicit MCP selection survives the renderer, IPC, durable turn input, and bundled CLI dispatch boundaries. | +| `LODY-ROLE-001` | P1 | workspace-catalog | 4/5 | Create an Agent Role and freeze its execution target into a Session | 7 | Blocked: A deterministic Agent Role revision fixture and stable Settings Page Object actions are not registered yet. | No desktop journey spans Agent Role creation, composer selection, accepted-operation freezing, and Session provenance. | +| `LODY-SESSION-002` | P1 | session-lifecycle | 3/5 | Rename, pin, archive, and restore a local Session | 4 | Eligible | The current lifecycle deletes an archived Session but never verifies common metadata edits or restoration from Archive. | + +Candidate selection is deterministic and returns at most one backlog row per run. +Scout may provide evidence for a narrow candidate, but it does not maintain a second journey implementation. diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 000000000..a068e5f30 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,130 @@ +# Lody desktop verification + +This workspace verifies the OSS desktop as one product process tree: Electron +main, preload, renderer, IPC, and the bundled local CLI. It separates repeatable +regression, exploratory resource scouting, and human acceptance so a noisy soak +signal cannot make the merge gate untrustworthy. + +| Lane | Purpose | Trigger | Decision owner | +| ---------------- | -------------------------------------------------- | --------------------------- | ----------------------- | +| Regression smoke | Short `@P0` user journeys against the real desktop | Pull request critical paths | Automated, blocking | +| Regression full | `@P0` plus `@P1` user journeys | Label, schedule, or manual | Automated, blocking | +| Scout soak | Repeated lifecycle and resource recovery analysis | Nightly or manual | Initially informational | +| Acceptance | Immutable before/after evidence for a delivery | Explicit local run | Human reviewer | + +## Architecture + +[`src/support/electron-harness.ts`](./src/support/electron-harness.ts) owns the +process boundary and isolation. Cucumber World adapts it to scenarios, Page +Objects own user interaction, and hooks own evidence retention. The harness +launches the built main entry directly with Playwright Electron; it does not +start a normal Chromium browser or an Electron Vite web server. + +Each run uses fresh durable directories and a kernel-assigned loopback port. +The test-only port override is accepted only when `LODY_E2E=1`, so Electron and +its bundled CLI cannot attach to the normal local daemon. Teardown first asks +Electron to quit through its production shutdown barrier, then verifies the +port can be rebound before deleting temporary state. + +## Commands + +```bash +pnpm install +pnpm e2e:check +pnpm e2e:build +pnpm e2e:smoke +pnpm e2e:full +pnpm e2e:scout +pnpm e2e:scout -- --journey review --iterations 50 +pnpm e2e:scout:ablation -- --iterations 12 +pnpm e2e:acceptance -- --subject desktop-local-bootstrap +pnpm e2e:acceptance -- --subject desktop-session-lifecycle \ + --before before.json --after after.json --retained-path retained-path.txt +pnpm e2e:journey:author -- --prepare-only +pnpm e2e:journey:author +pnpm e2e:journey:validate -- --artifact-dir e2e/artifacts/journey-author/RUN \ + --approve-reviewed +pnpm --filter @lody/e2e journey:candidate -- --json +pnpm --filter @lody/e2e journey:coverage +``` + +`e2e:build` prepares the renderer and synchronized CLI once. The other commands +never rebuild, which keeps scenario timing about product behavior rather than +toolchain work. `e2e:acceptance` creates a unique round under +`e2e/artifacts/acceptance/`; it never overwrites an earlier round. Supported +subjects are `desktop-local-bootstrap`, `desktop-session-lifecycle`, +`desktop-review-lifecycle`, `desktop-work-lifecycle`, and `desktop-lifecycle`. +Optional before/after JSON and a retained-path summary are copied into the +round, then covered by its checksummed manifest. +Scout operation, classification, and triage are specified in +[the Scout contract](./SCOUT.md). + +## Journey registry + +[`journeys/registry.json`](./journeys/registry.json) owns both implemented +journeys and evidence-backed product gaps. `COVERAGE.md` is generated from the +registry, while the suite checker proves that every active row and executable +scenario agree on id, priority, runtime, and feature path in both directions. + +Candidate selection is deterministic, removes semantic duplicates, skips rows +with a `blockedReason`, and emits at most one result with a complete score +breakdown. It accepts frozen discovery signals without changing the registry: + +```bash +pnpm --filter @lody/e2e journey:candidate -- --changed-files changed.txt --json +pnpm --filter @lody/e2e journey:candidate -- --escaped-defects escaped-ids.txt --json +pnpm --filter @lody/e2e journey:candidate -- --scout-summary artifacts/scout/ROUND/summary.json --json +``` + +Changed files and escaped-defect inputs are newline-delimited. Scout input uses +the existing `summary.json` schema. These signals only rank registered gaps; +they never generate selectors, shell commands, or executable product code. + +The local Journey Foundry takes one eligible backlog row at a time under the +[restricted authoring contract](./journeys/AUTHORING.md). It requires a clean +maintainer checkout, Node.js 22+, the pinned pnpm, macOS desktop prerequisites, +and an authenticated Codex CLI. `codex login` may use the maintainer's ChatGPT +account; GitHub receives neither that login nor an API key. + +The author command creates an ephemeral detached worktree and invokes Codex +there with a restricted environment, ignored user configuration, an ephemeral +session, and the `workspace-write` sandbox. Trusted local code packages the +allowlisted files into `candidate.patch` plus a readable `review/` tree. It does +not execute or apply generated code. Evidence remains under the ignored +`e2e/artifacts/journey-author/` directory. + +After reviewing every generated file, the maintainer runs the validation +command with `--approve-reviewed`. A second ephemeral worktree with a temporary +home receives the candidate, promotes the matching registry row, rejects a +bounded counterfactual, restores exact file hashes, runs three fresh focused +rounds, and runs the full suite. Failure removes that worktree and leaves the +maintainer checkout clean. Success applies the frozen validated patch only when +the checkout is still clean and at the candidate's exact base commit. + +`--prepare-only` records the selected task without invoking Codex or changing +the checkout. Neither local command commits, pushes, opens a PR, or merges. The +maintainer publishes the validated patch through the normal contribution flow, +where existing PR checks rerun the deterministic suite. A blocked result +remains local evidence and the next run can skip that id with +`--excluded `. + +## Failure model + +A failed scenario keeps the evidence described in [the artifact contract](./ARTIFACTS.md). +Evidence capture failures are appended to the scenario log and do not replace +the original product failure. Teardown failures do fail the scenario because a +surviving CLI or occupied endpoint invalidates the next result. + +Daily regression additionally records each scenario and retains only failed +WebMs. Its read-only runner uploads the complete artifact; a trusted +default-branch reconciler creates or reopens one Daily failure Issue and appends +every validated recording as its own independently retryable inline player. A +later successful full Daily closes the Issue with the recovery run link; a +successful manually dispatched smoke run cannot clear full-suite failure state. +Pull-request failures follow the same evidence validation in a trusted +default-branch reconciler. It skips stale heads and appends each failed journey +as an independently retryable inline video comment on the matching open PR. + +The current active coverage is tracked in [the coverage matrix](./COVERAGE.md). +The suite checker parses Gherkin and enforces IDs, priorities, runtime ownership, +documentation indexes, and P0 matrix entries before any application build. diff --git a/e2e/SCOUT.md b/e2e/SCOUT.md new file mode 100644 index 000000000..875aa8c08 --- /dev/null +++ b/e2e/SCOUT.md @@ -0,0 +1,69 @@ +# Desktop resource Scout + +Scout is an informational soak lane built on the same real Electron harness, +synthetic fixtures, and Page Objects as regression E2E. It is kept out of the +merge gate because process RSS and CPU contain host noise even when product +behavior is deterministic. + +## Journeys + +| Name | Repeated lifecycle | Release evidence | +| --------- | -------------------------------------------------------------------------- | ----------------------------------------- | +| `session` | Create Session, stream, stop, archive, permanently delete | ACP PID exits | +| `review` | Open Review, switch between two large diffs, hide/show, close, delete | Review surface and Session close | +| `work` | Create worktree Session, run ACP and Terminal, archive, permanently delete | ACP PID, terminal, and worktree disappear | + +The default run executes three warmup iterations and 30 measured iterations +per journey. At iterations 5, 10, 15, 20, 25, and 30 it captures the active +state before cleanup, waits for observable resource release, explicitly +collects Electron main and renderer garbage, and captures the post-GC state. + +```bash +pnpm e2e:scout +pnpm e2e:scout -- --journey review --iterations 50 +pnpm e2e:scout:ablation -- --iterations 12 +``` + +## Measurements + +Each checkpoint records Electron main heap/private/RSS, renderer RSS and JS +heap, DOM nodes/documents/listeners, CLI and ACP RSS/CPU/process counts, and +renderer long-task/layout/style/task/layer-paint counters. Process-table +commands are classified in memory and discarded; artifacts contain metrics, +PIDs, and parent PIDs but not raw command lines or environment variables. + +The report includes both Theil-Sen slope per checkpoint and a slope normalized +to one user-journey iteration. Only resources with a controllable GC or an +explicit release condition are eligible for candidate classification. CLI RSS +remains in every active and post-cleanup report as an observational working-set +trend: the bundled CLI has no test-only GC protocol, so its allocator high-water +mark cannot be described as a GC-normalized baseline. A candidate requires all +of the following: + +- at least four checkpoints; +- a positive Theil-Sen slope; +- net growth above the metric-specific noise floor; +- no decrease across at least 75 percent of adjacent checkpoints; a plateau is + neutral rather than evidence against a trend. + +There is deliberately no absolute memory ceiling. Active-state and cumulative +performance trends are evidence for comparison, not leak classifiers. Relative +growth is reported for ranking but cannot veto a stable slope on a large base. +The runner rejects configurations that produce fewer than four measured +checkpoints instead of reporting an underpowered run as clean. + +## Ablation + +The ablation command samples every iteration, then writes estimates for no +warmup, two or three discarded iterations, and strides of one, two, and five. +It compares Theil-Sen with ordinary least squares after normalizing both to one +journey. Use it when changing warmup, cadence, noise floors, or the estimator; +do not tune those controls from a single noisy nightly run. + +## Triage + +A successful Scout may still report suspected trends. CI uploads the complete +round and a separate least-privilege workflow creates or updates one candidate +Issue. The finding stays informational until it reproduces across independent +rounds and retained-path evidence identifies a specific lifecycle defect. Add +only that narrow deterministic reproduction to the blocking regression lane. diff --git a/e2e/cucumber.mjs b/e2e/cucumber.mjs new file mode 100644 index 000000000..1ecf20e8b --- /dev/null +++ b/e2e/cucumber.mjs @@ -0,0 +1,22 @@ +import { mkdirSync } from 'node:fs'; +import 'tsx'; + +const acceptanceRound = process.env.LODY_ACCEPTANCE_ROUND_ID?.trim(); +const artifactRoot = acceptanceRound ? `artifacts/acceptance/${acceptanceRound}` : 'artifacts'; +mkdirSync(artifactRoot, { recursive: true }); + +/** @type {import('@cucumber/cucumber').IConfiguration} */ +export default { + paths: ['src/features/**/*.feature'], + import: ['src/steps/**/*.ts', 'src/support/world.ts', 'src/support/hooks.ts'], + format: [ + 'progress-bar', + `html:${artifactRoot}/cucumber-report.html`, + `junit:${artifactRoot}/cucumber-junit.xml`, + `message:${artifactRoot}/cucumber-messages.ndjson`, + ], + formatOptions: { snippetInterface: 'async-await' }, + parallel: 0, + publishQuiet: true, + retry: 0, +}; diff --git a/e2e/fixtures/scripted-acp.mjs b/e2e/fixtures/scripted-acp.mjs new file mode 100644 index 000000000..f4e9dcafd --- /dev/null +++ b/e2e/fixtures/scripted-acp.mjs @@ -0,0 +1,136 @@ +import { appendFileSync, writeFileSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { Readable, Writable } from 'node:stream'; +import * as acp from '@agentclientprotocol/sdk'; + +const eventLogPath = process.argv[2]; +const sessions = new Map(); +const pendingPrompts = new Map(); + +function record(event, details = {}) { + if (!eventLogPath) return; + appendFileSync( + eventLogPath, + `${JSON.stringify({ at: new Date().toISOString(), pid: process.pid, event, ...details })}\n`, + 'utf8' + ); +} + +function finishPending(sessionId, stopReason) { + const pending = pendingPrompts.get(sessionId); + if (!pending) return; + pendingPrompts.delete(sessionId); + pending.resolve(stopReason); +} + +function promptText(prompt) { + return prompt + .filter((block) => block.type === 'text') + .map((block) => block.text) + .join('\n'); +} + +async function emitText(client, sessionId, text) { + await client.notify(acp.methods.client.session.update, { + sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text }, + }, + }); +} + +function writeSyntheticDiff(cwd, revision) { + const lines = Array.from( + { length: 1_500 }, + (_, index) => `export const syntheticLine${index + 1} = ${index + revision};` + ); + writeFileSync(`${cwd}/synthetic-large-diff.ts`, `${lines.join('\n')}\n`, 'utf8'); +} + +const agent = acp + .agent({ name: 'lody-scripted-e2e-agent' }) + .onRequest(acp.methods.agent.initialize, async ({ params }) => { + record('initialize'); + return { + protocolVersion: params.protocolVersion, + agentCapabilities: {}, + agentInfo: { name: 'Lody Scripted E2E Agent', version: '1' }, + }; + }) + .onRequest(acp.methods.agent.session.new, async ({ params }) => { + const sessionId = `scripted-${randomUUID()}`; + sessions.set(sessionId, { cwd: params.cwd, revision: 0 }); + record('session-new', { sessionId }); + return { sessionId }; + }) + .onRequest(acp.methods.agent.session.prompt, async ({ params, client, signal }) => { + const session = sessions.get(params.sessionId); + if (!session) throw new Error(`Unknown scripted session: ${params.sessionId}`); + const text = promptText(params.prompt); + const mode = text.includes('You generate titles for coding sessions.') + ? 'title' + : text.includes('[SCOUT:HOLD]') + ? 'hold' + : text.includes('[SCOUT:DIFF]') + ? 'diff' + : 'reply'; + record('prompt-start', { sessionId: params.sessionId, mode }); + + if (mode === 'diff') { + session.revision += 1; + writeSyntheticDiff(session.cwd, session.revision); + await emitText( + client, + params.sessionId, + `Synthetic diff revision ${session.revision} ready.` + ); + record('prompt-end', { sessionId: params.sessionId, mode, stopReason: 'end_turn' }); + return { stopReason: 'end_turn' }; + } + + if (mode === 'title') { + await emitText(client, params.sessionId, 'Synthetic session title'); + record('prompt-end', { sessionId: params.sessionId, mode, stopReason: 'end_turn' }); + return { stopReason: 'end_turn' }; + } + + await emitText(client, params.sessionId, 'Synthetic response started.'); + if (mode !== 'hold') { + await emitText(client, params.sessionId, ' Synthetic response complete.'); + record('prompt-end', { sessionId: params.sessionId, mode, stopReason: 'end_turn' }); + return { stopReason: 'end_turn' }; + } + + return await new Promise((resolve) => { + const finish = (stopReason) => { + signal.removeEventListener('abort', onAbort); + record('prompt-end', { sessionId: params.sessionId, mode, stopReason }); + resolve({ stopReason }); + }; + const onAbort = () => finish('cancelled'); + pendingPrompts.set(params.sessionId, { resolve: finish }); + signal.addEventListener('abort', onAbort, { once: true }); + }); + }) + .onNotification(acp.methods.agent.session.cancel, async ({ params }) => { + record('session-cancel', { sessionId: params.sessionId }); + finishPending(params.sessionId, 'cancelled'); + }) + .onRequest(acp.methods.agent.session.close, async ({ params }) => { + finishPending(params.sessionId, 'cancelled'); + sessions.delete(params.sessionId); + record('session-close', { sessionId: params.sessionId }); + return {}; + }); + +process.on('SIGTERM', () => { + record('sigterm'); + process.exit(0); +}); +process.on('SIGINT', () => { + record('sigint'); + process.exit(0); +}); +record('process-start'); +agent.connect(acp.ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin))); diff --git a/e2e/fixtures/scripted-acp.test.ts b/e2e/fixtures/scripted-acp.test.ts new file mode 100644 index 000000000..ab8369e67 --- /dev/null +++ b/e2e/fixtures/scripted-acp.test.ts @@ -0,0 +1,108 @@ +import { spawn } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { Readable, Writable } from 'node:stream'; +import assert from 'node:assert/strict'; +import { afterEach, describe, it } from 'node:test'; +import * as acp from '@agentclientprotocol/sdk'; + +const fixturePath = resolve('fixtures/scripted-acp.mjs'); +const children = new Set>(); + +afterEach(async () => { + for (const child of children) { + if (child.exitCode === null) child.kill('SIGTERM'); + if (child.exitCode === null) { + await new Promise((resolveExit) => child.once('exit', () => resolveExit())); + } + } + children.clear(); +}); + +void describe('scripted ACP fixture', () => { + void it('streams a reply and settles a held prompt through cancellation', async () => { + const root = mkdtempSync(join(tmpdir(), 'lody-scripted-acp-test-')); + const eventLog = join(root, 'events.ndjson'); + const child = spawn(process.execPath, [fixturePath, eventLog], { + cwd: root, + stdio: ['pipe', 'pipe', 'pipe'], + }); + children.add(child); + const updates: acp.SessionNotification[] = []; + let resolveHeldPromptStarted: (() => void) | undefined; + const client = acp + .client({ name: 'scripted-fixture-test' }) + .onNotification(acp.methods.client.session.update, ({ params }) => { + updates.push(params); + if ( + params.update.sessionUpdate === 'agent_message_chunk' && + params.update.content.type === 'text' && + params.update.content.text === 'Synthetic response started.' + ) { + resolveHeldPromptStarted?.(); + } + }) + .connect( + acp.ndJsonStream( + Writable.toWeb(child.stdin!), + Readable.toWeb(child.stdout!) as ReadableStream + ) + ); + + const initialized = await client.agent.request(acp.methods.agent.initialize, { + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: {}, + }); + assert.equal(initialized.agentInfo?.name, 'Lody Scripted E2E Agent'); + const session = await client.agent.request(acp.methods.agent.session.new, { + cwd: root, + mcpServers: [], + }); + const reply = await client.agent.request(acp.methods.agent.session.prompt, { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: '[SCOUT:REPLY]' }], + }); + assert.equal(reply.stopReason, 'end_turn'); + assert.ok( + updates.some( + (notification) => + notification.sessionId === session.sessionId && + notification.update.sessionUpdate === 'agent_message_chunk' + ) + ); + + const title = await client.agent.request(acp.methods.agent.session.prompt, { + sessionId: session.sessionId, + prompt: [ + { + type: 'text', + text: 'You generate titles for coding sessions.\n[SCOUT:HOLD]', + }, + ], + }); + assert.equal(title.stopReason, 'end_turn'); + + const heldPromptStarted = new Promise((resolveStarted) => { + resolveHeldPromptStarted = resolveStarted; + }); + const held = client.agent.request(acp.methods.agent.session.prompt, { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: '[SCOUT:HOLD]' }], + }); + await heldPromptStarted; + resolveHeldPromptStarted = undefined; + await client.agent.notify(acp.methods.agent.session.cancel, { sessionId: session.sessionId }); + assert.equal((await held).stopReason, 'cancelled'); + + await client.close(); + child.kill('SIGTERM'); + await new Promise((resolveExit) => child.once('exit', () => resolveExit())); + children.delete(child); + const events = readFileSync(eventLog, 'utf8'); + assert.match(events, /"event":"session-cancel"/u); + assert.match(events, /"mode":"title"/u); + assert.match(events, /"stopReason":"cancelled"/u); + rmSync(root, { recursive: true, force: true }); + }); +}); diff --git a/e2e/journeys/AUTHORING.md b/e2e/journeys/AUTHORING.md new file mode 100644 index 000000000..273a7e6f6 --- /dev/null +++ b/e2e/journeys/AUTHORING.md @@ -0,0 +1,178 @@ +# Restricted journey authoring + +This contract governs a maintainer-started local author that proposes one +deterministic desktop journey at a time. The author prepares a reviewable +candidate; it does not own the active coverage matrix, the harness, CI, product +code, or publication. + +## One-run contract + +One authoring run selects exactly one uncovered user outcome and adds exactly +one Gherkin `Scenario`. A `Scenario Outline`, multiple scenarios, opportunistic +cleanup, or a second coverage gap belongs in a later run. + +Every candidate starts as `@P1`. Promotion to `@P0` is a human decision because +P0 changes the merge-blocking contract. The scenario must otherwise satisfy the +repository contract: `@lody`, `@essence`, one stable `@LODY-AREA-NNN` id, and +one supported `@runtime-*` owner. + +The author emits structured output that validates against +[`author-result.schema.json`](./author-result.schema.json). A ready result names +one bounded assertion replacement for the validation lane to execute. A blocked +result classifies why the journey cannot be completed without weakening it. + +## Write boundary + +The author may add or edit only these paths: + +```text +e2e/src/features/**/*.feature +e2e/src/steps/**/*.steps.ts +e2e/src/support/pages/**/*.ts +e2e/src/support/fixtures/**/*.ts +e2e/src/support/fixtures/**/*.mjs +e2e/src/support/fixtures/**/*.json +e2e/src/support/fixtures/**/*.txt +e2e/src/features/README.md +e2e/src/steps/README.md +e2e/src/support/README.md +``` + +The three README files may change only to index the new Feature, step file, or +Page Object and to keep checker-owned counts accurate. Fixture contents must be +synthetic. + +Everything else is read-only. In particular, the author must not change: + +- `e2e/COVERAGE.md`, `e2e/AGENTS.md`, `e2e/README.md`, package manifests, lockfiles, + Cucumber configuration, suite-checking scripts, Scout, or Acceptance; +- Electron, CLI, shared packages, production selectors, or product behavior; +- GitHub workflows, permissions, labels, branch protection, or issue state; +- the Electron harness, World, hooks, resource probes, or process lifecycle. + +If a journey needs a new IPC capability, harness hook, selector, product change, +or a file outside the allowlist, stop with `test-capability` and name the missing +infrastructure in the summary. Do not work around the boundary and do not weaken +an existing assertion. + +## Safety boundary + +- Do not add `@skip`, `@wip`, conditional skips, retries, quarantine behavior, + or catches that convert a failed assertion into a pass. +- Do not use live network services. The owned loopback Electron/CLI connection + is part of the harness; every external model/provider response must use the + existing deterministic scripted simulator. +- Do not read or capture a developer's Lody data, home directory, credentials, + browser profile, environment secrets, or real conversation transcripts. + Test identities, repositories, prompts, diffs, and responses must be synthetic. +- Do not introduce or execute arbitrary shell. Test code must not add + `child_process`, `shell: true`, command strings, or dynamically constructed + executables. Reuse only reviewed harness and fixture APIs already in the suite. +- Do not use real sleeps, wall-clock races, live model calls, external downloads, + generated CSS selectors, or machine-load thresholds. +- Do not commit, push, merge, approve, label, close, open an Issue, or open a PR. + The maintainer reviews and publishes a validated patch through the ordinary + contribution flow. + +The coordinator starts only from a clean maintainer checkout. It verifies an +existing Codex login without reading or printing the credential, then creates +an ephemeral detached Git worktree at the exact base commit. Codex runs there +with an allowlisted process environment, ignored user configuration, an +ephemeral session, and the `workspace-write` sandbox. GitHub never receives the +maintainer's ChatGPT login, API key, or Codex home. Only a patch the maintainer +has reviewed enters GitHub through a normal PR. + +The author may inspect repository files and edit only the allowlist above. It +does not execute generated test code. Trusted coordinator code packages the +candidate, checks its task digest, changed paths, file sizes, hashes, scenario +count, stable id, and counterfactual declaration, then stops. It writes a patch +and a readable candidate tree for the maintainer to inspect. + +Validation requires the maintainer's explicit `--approve-reviewed` +acknowledgement. The validator creates a second detached worktree with a +temporary home and a scrubbed process environment. It applies and promotes the +candidate there before it owns these commands: + +```bash +pnpm e2e:build +pnpm e2e:check +pnpm --filter @lody/e2e exec cucumber-js --config cucumber.mjs --tags '@LODY-AREA-NNN' +pnpm e2e:full +git diff --check +git diff --name-only +git status --short +``` + +The stable-id placeholder in the targeted command is replaced with the single +candidate id. No pipes, redirects, command substitution, background processes, +extra Cucumber flags, or shell operators may be appended. A failed validation +removes the detached worktree and leaves the maintainer checkout unchanged. A +passed validation freezes the exact checksummed patch and applies it only when +the maintainer checkout is still clean and at the task's base commit. + +## Required implementation shape + +1. Read the active scenarios and coverage material without editing them. +2. Select one user-visible outcome that is valuable, deterministic, and possible + with the current harness. Record the gap evidence and why P1 is appropriate. +3. Reuse existing steps and Page Objects before adding narrow new ones. Steps + express intent; Page Objects own selectors and interaction policy. +4. Use a stable accessible role or product-owned test id. Missing stable access + is an infrastructure gap, not permission to edit the product. +5. Assert the completed user outcome and cleanup, not merely that a control was + clicked or a mock was called. +6. Keep the external wire deterministic and preserve the real Electron main, + preload, renderer, IPC, bundled CLI, persistence, and shutdown boundary. + +## Discrimination experiment + +A green scenario is insufficient: its key checkpoint must distinguish correct +behavior from an intentionally wrong state. The author declares exactly one +assertion ablation: a temporary replacement of the key outcome expectation in a +changed step or Page Object, without weakening the interaction or cleanup path. + +The replacement targets one unique quoted expectation in a changed candidate +file and uses the exact per-candidate sentinel requested by the task. It must +make the focused scenario fail. The validator then restores the checksummed +candidate file before continuing. A counterfactual that passes proves the check +has no discrimination and is a `test-capability` failure. + +Do not mutate product code, the harness, persisted developer state, or an +external service to manufacture the fault. Do not use a real defect as the +counterfactual: if the unmodified product fails the intended outcome, classify +that independently as `product-defect`. + +## Validation gate + +A reviewed candidate is eligible to be committed only after the local validator +records all of the following on the same candidate and built desktop: + +1. `pnpm e2e:check`. +2. One counterfactual discrimination experiment that fails at the key + checkpoint, followed by exact restoration of the candidate patch. +3. The targeted stable-id command in a fresh process three consecutive times. + Record all three attempts separately; a failed attempt cannot be erased by a + later pass. +4. `pnpm e2e:full` once after the three targeted passes. +5. `git diff --check` and an allowlist comparison over every changed path. + +Each target run must launch a fresh harness and therefore own fresh user data, +Lody data, workspace, endpoint, processes, and artifacts. Reusing one live app +for three assertions does not satisfy the gate. + +Classify a failed candidate as exactly one of: + +- `product-defect`: the intended journey exposes incorrect product behavior; +- `test-capability`: the current selectors, fixture controls, or assertions + cannot prove the outcome deterministically, including a passing ablation; +- `infra`: build, launch, runner, operating-system, or owned service failure. + +Only `ready` with `failureClass: none` and a passed local attestation may be +published by a maintainer. Every blocked candidate retains its failure class and +evidence without weakening the suite. A later run may advance to another +eligible registry row; one blocked row never stops the whole queue. + +No repository workflow authors or publishes candidates. The maintainer reviews +the local diff and evidence, commits it, and opens a normal PR. Existing PR CI +repeats the repository checks without access to Codex authentication. Neither +the local coordinator nor CI merges or promotes a candidate to P0. diff --git a/e2e/journeys/author-result.schema.json b/e2e/journeys/author-result.schema.json new file mode 100644 index 000000000..fb1d6139a --- /dev/null +++ b/e2e/journeys/author-result.schema.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "required": ["status", "failureClass", "summary", "ablation"], + "properties": { + "status": { "enum": ["ready", "blocked"] }, + "failureClass": { + "enum": ["none", "product-defect", "test-capability", "infra"] + }, + "summary": { "type": "string", "minLength": 1, "maxLength": 2000 }, + "ablation": { + "anyOf": [ + { "type": "null" }, + { + "type": "object", + "additionalProperties": false, + "required": ["path", "search", "replacement", "expectedFailure"], + "properties": { + "path": { "type": "string", "minLength": 1, "maxLength": 300 }, + "search": { "type": "string", "minLength": 1, "maxLength": 500 }, + "replacement": { "type": "string", "minLength": 1, "maxLength": 500 }, + "expectedFailure": { "type": "string", "minLength": 1, "maxLength": 500 } + } + } + ] + } + }, + "allOf": [ + { + "if": { "properties": { "status": { "const": "ready" } } }, + "then": { + "properties": { + "failureClass": { "const": "none" }, + "ablation": { "type": "object" } + } + } + }, + { + "if": { "properties": { "status": { "const": "blocked" } } }, + "then": { + "properties": { + "failureClass": { + "enum": ["product-defect", "test-capability", "infra"] + }, + "ablation": { "type": "null" } + } + } + } + ] +} diff --git a/e2e/journeys/registry.json b/e2e/journeys/registry.json new file mode 100644 index 000000000..332a4a50b --- /dev/null +++ b/e2e/journeys/registry.json @@ -0,0 +1,406 @@ +{ + "schemaVersion": 1, + "scoring": { + "criticality": 100, + "boundaryRisk": 20, + "changeFrequency": 5, + "freshness": 5, + "escapedDefect": 40, + "scoutSignal": 35, + "changedPath": 50, + "estimatedMinutePenalty": 2 + }, + "journeys": [ + { + "id": "LODY-ONBOARDING-001", + "state": "active", + "priority": "P0", + "runtime": "none", + "title": "New user enters an isolated local workspace through the bundled CLI", + "owner": "desktop-bootstrap", + "feature": "src/features/onboarding.feature", + "fixture": "fresh-local-workspace", + "fingerprint": "ccfecdbae92c8a9848a554d502beb53acc897e21b3c30ab8a45d5c5eebbee1b3", + "ownerPaths": [ + "apps/electron/", + "packages/components/src/components/onboarding/", + "packages/components/src/routes/onboarding.tsx" + ], + "actions": [ + { "id": "desktop.launchFresh" }, + { "id": "onboarding.waitForLocalBootstrap" }, + { "id": "onboarding.skipAgentConfiguration" }, + { "id": "onboarding.enterWorkspace" } + ], + "checkpoints": [ + "bundled CLI owns the isolated local runtime", + "workspace initialization completes", + "session composer is editable" + ], + "cleanup": ["desktop process tree exits", "isolated endpoint can be rebound"], + "coverage": { + "renderer": "Intro and local entry", + "electronIpc": "Real window and invoke bridge", + "bundledCli": "Real owned runtime", + "durableState": "Isolated workspace catalog and onboarding state", + "externalWire": "None" + }, + "signals": { + "criticality": 5, + "boundaryRisk": 5, + "changeFrequency": 3, + "escapedDefect": false + }, + "freshness": 5, + "scoutJourneys": [], + "blockedReason": null, + "estimatedMinutes": 1 + }, + { + "id": "LODY-SESSION-001", + "state": "active", + "priority": "P0", + "runtime": "simulator", + "title": "Stop and permanently delete a running ACP Session", + "owner": "session-lifecycle", + "feature": "src/features/lifecycle.feature", + "fixture": "scripted-acp", + "fingerprint": "beb2be11585dd90453549de89982f739c1dac077026294fcdfbd8827d6d8278c", + "ownerPaths": [ + "apps/cli/src/session/", + "packages/components/src/components/sessions/", + "packages/components/src/components/chat/" + ], + "actions": [ + { "id": "agent.configureScripted" }, + { "id": "session.createHeld" }, + { "id": "session.stop" }, + { "id": "session.archive" }, + { "id": "session.deletePermanent" } + ], + "checkpoints": [ + "Stop becomes available while ACP is running", + "ACP process exits after Stop", + "Session disappears after permanent deletion" + ], + "cleanup": ["ACP process exits", "Session durable state is deleted"], + "coverage": { + "renderer": "Session lifecycle", + "electronIpc": "Real window and invoke bridge", + "bundledCli": "Real owned runtime", + "durableState": "Create, stop, archive, and permanent delete", + "externalWire": "Scripted ACP" + }, + "signals": { + "criticality": 5, + "boundaryRisk": 5, + "changeFrequency": 5, + "escapedDefect": false + }, + "freshness": 5, + "scoutJourneys": ["session"], + "blockedReason": null, + "estimatedMinutes": 2 + }, + { + "id": "LODY-WORK-001", + "state": "active", + "priority": "P0", + "runtime": "simulator", + "title": "Delete a worktree Session with ACP and Terminal resources", + "owner": "work-lifecycle", + "feature": "src/features/lifecycle.feature", + "fixture": "synthetic-git-and-scripted-acp", + "fingerprint": "5beb37208e9e503a21177a8291f7f35472d32a9ff241ac459f070a02c413b31c", + "ownerPaths": [ + "apps/cli/src/session/worktree/", + "packages/components/src/components/terminal/", + "packages/components/src/components/sessions/" + ], + "actions": [ + { "id": "project.addSyntheticGit" }, + { "id": "session.enableWorktree" }, + { "id": "session.createCompleted" }, + { "id": "terminal.openAndRunMarker" }, + { "id": "session.archive" }, + { "id": "session.deletePermanent" } + ], + "checkpoints": [ + "worktree Session completes", + "Terminal command produces its marker", + "worktree, ACP, and Terminal resources are released" + ], + "cleanup": ["ACP process exits", "Terminal exits", "generated worktree is removed"], + "coverage": { + "renderer": "Work lifecycle", + "electronIpc": "Real window, IPC, and Terminal", + "bundledCli": "Real owned runtime", + "durableState": "Session, worktree, and terminal cleanup", + "externalWire": "Scripted ACP" + }, + "signals": { + "criticality": 5, + "boundaryRisk": 5, + "changeFrequency": 4, + "escapedDefect": false + }, + "freshness": 5, + "scoutJourneys": ["work"], + "blockedReason": null, + "estimatedMinutes": 3 + }, + { + "id": "LODY-REVIEW-001", + "state": "active", + "priority": "P1", + "runtime": "simulator", + "title": "Open, hide, and switch a synthetic large diff", + "owner": "review-lifecycle", + "feature": "src/features/lifecycle.feature", + "fixture": "synthetic-large-diff-and-scripted-acp", + "fingerprint": "b5f672ce3fafd7b8ebb8223b3b2c79a2b1ef83e0103a43508814920f26bb59f0", + "ownerPaths": [ + "packages/code-review-viewer/", + "packages/components/src/ui/diff-viewer/", + "packages/components/src/components/sessions/" + ], + "actions": [ + { "id": "project.addSyntheticLargeDiff" }, + { "id": "session.createCompleted" }, + { "id": "review.openAllChanges" }, + { "id": "review.switchLargeDiff" }, + { "id": "review.hideAndShow" }, + { "id": "review.close" }, + { "id": "session.deletePermanent" } + ], + "checkpoints": [ + "both synthetic changed files render", + "Review survives hide and restore", + "Review state is released after Session deletion" + ], + "cleanup": ["Review tabs close", "Session durable state is deleted"], + "coverage": { + "renderer": "Large diff Review lifecycle", + "electronIpc": "Real window and diff RPC", + "bundledCli": "Real owned runtime", + "durableState": "Synthetic project and Session lifecycle", + "externalWire": "Scripted ACP" + }, + "signals": { + "criticality": 4, + "boundaryRisk": 4, + "changeFrequency": 4, + "escapedDefect": false + }, + "freshness": 5, + "scoutJourneys": ["review"], + "blockedReason": null, + "estimatedMinutes": 4 + }, + { + "id": "LODY-MCP-001", + "state": "backlog", + "priority": "P1", + "runtime": "simulator", + "title": "Create a workspace MCP server and preserve explicit turn selection through dispatch", + "owner": "workspace-catalog", + "fixture": "synthetic-stdio-mcp-and-scripted-acp", + "fingerprint": "6b0a21b7f13f9d43adfa0ec34d26d4cae3a4a63871a67dbbcad1ea1eb307cba4", + "ownerPaths": [ + "apps/cli/src/agent/session-mcp-resolver.ts", + "apps/cli/src/lib/workspace-mcp-store.ts", + "apps/cli/src/mcp/", + "packages/components/src/components/settings/mcp-setting.tsx", + "packages/components/src/hooks/use-session-mcp-selection.ts" + ], + "actions": [ + { "id": "settings.openMcpCatalog" }, + { "id": "mcp.createSyntheticStdioServer" }, + { "id": "composer.selectMcpServer" }, + { "id": "session.createCompleted" }, + { "id": "mcp.deleteServer" } + ], + "checkpoints": [ + "workspace MCP catalog write is durable", + "driving turn carries the explicit MCP selection into ACP startup", + "catalog entry is removed without changing prior turn input" + ], + "cleanup": ["synthetic MCP process exits", "workspace MCP catalog entry is deleted"], + "coverage": { + "renderer": "MCP settings and composer selection", + "electronIpc": "Real window and workspace RPC", + "bundledCli": "Catalog persistence and ACP dispatch", + "durableState": "Workspace catalog plus turn input selection", + "externalWire": "Synthetic stdio MCP and scripted ACP" + }, + "gap": "No desktop journey proves that an explicit MCP selection survives the renderer, IPC, durable turn input, and bundled CLI dispatch boundaries.", + "evidence": [ + "apps/cli/src/agent/session-mcp-resolver.test.ts", + "packages/components/src/hooks/use-session-mcp-selection.ts" + ], + "signals": { + "criticality": 5, + "boundaryRisk": 5, + "changeFrequency": 4, + "escapedDefect": false + }, + "freshness": 4, + "scoutJourneys": ["session"], + "blockedReason": null, + "estimatedMinutes": 6 + }, + { + "id": "LODY-ROLE-001", + "state": "backlog", + "priority": "P1", + "runtime": "simulator", + "title": "Create an Agent Role and freeze its execution target into a Session", + "owner": "workspace-catalog", + "fixture": "scripted-agent-role", + "fingerprint": "1f2bc41853b8b774bf33944b5c7319b200f27190b39f4cd0ef2b773009a2772e", + "ownerPaths": [ + "packages/components/src/components/settings/agent-role-form.tsx", + "packages/components/src/components/settings/agent-roles-setting.tsx", + "packages/components/src/hooks/use-session-agent-role.ts", + "packages/components/src/lib/composer-agent-roles.ts" + ], + "actions": [ + { "id": "settings.openAgentRoles" }, + { "id": "role.createDeterministic" }, + { "id": "composer.selectAgentRole" }, + { "id": "session.createCompleted" }, + { "id": "role.editAfterDispatch" }, + { "id": "role.delete" } + ], + "checkpoints": [ + "Role row is durable and selectable", + "Session records Role id and revision", + "later Role edits do not alter the accepted Session execution target" + ], + "cleanup": ["Session is permanently deleted", "Agent Role catalog row is deleted"], + "coverage": { + "renderer": "Agent Role settings and composer selection", + "electronIpc": "Real window and workspace RPC", + "bundledCli": "Frozen dispatch configuration", + "durableState": "Role catalog plus Session provenance", + "externalWire": "Scripted ACP" + }, + "gap": "No desktop journey spans Agent Role creation, composer selection, accepted-operation freezing, and Session provenance.", + "evidence": [ + "packages/components/src/components/settings/agent-roles-setting.tsx", + "packages/components/src/hooks/use-session-agent-role.ts" + ], + "signals": { + "criticality": 5, + "boundaryRisk": 5, + "changeFrequency": 4, + "escapedDefect": false + }, + "freshness": 4, + "scoutJourneys": ["session"], + "blockedReason": "A deterministic Agent Role revision fixture and stable Settings Page Object actions are not registered yet.", + "estimatedMinutes": 7 + }, + { + "id": "LODY-FORK-001", + "state": "backlog", + "priority": "P1", + "runtime": "simulator", + "title": "Fork a completed Session into an independent worktree Session", + "owner": "session-lifecycle", + "fixture": "synthetic-git-and-scripted-acp", + "fingerprint": "35f80dbd4dd21492ab30c54ff6915cc715f210ad78c1659f5d911e01c1d3b8b0", + "ownerPaths": [ + "apps/cli/src/session/session-fork-service.ts", + "apps/cli/src/session/session-fork-operation-store.ts", + "packages/components/src/components/sessions/session-fork-destination-menu.tsx" + ], + "actions": [ + { "id": "project.addSyntheticGit" }, + { "id": "session.createCompleted" }, + { "id": "session.forkToWorktree" }, + { "id": "session.openFork" }, + { "id": "session.deletePermanent" } + ], + "checkpoints": [ + "fork retains the completed conversation prefix", + "fork records its origin and owns an independent worktree", + "source Session remains unchanged when the fork is deleted" + ], + "cleanup": ["fork ACP process exits", "fork worktree and Session are deleted"], + "coverage": { + "renderer": "Session fork destination and origin", + "electronIpc": "Real window and Session RPC", + "bundledCli": "Fork operation and recovery markers", + "durableState": "Independent Session history and worktree", + "externalWire": "Scripted ACP" + }, + "gap": "Fork durability and compensation have Node coverage but no real desktop journey across renderer, IPC, CLI, Git, and persisted history.", + "evidence": [ + "apps/cli/src/session/session-fork-service.test.ts", + "packages/components/src/components/sessions/session-fork-destination-menu.tsx" + ], + "signals": { + "criticality": 5, + "boundaryRisk": 5, + "changeFrequency": 3, + "escapedDefect": false + }, + "freshness": 4, + "scoutJourneys": ["session", "work"], + "blockedReason": null, + "estimatedMinutes": 8 + }, + { + "id": "LODY-SESSION-002", + "state": "backlog", + "priority": "P1", + "runtime": "none", + "title": "Rename, pin, archive, and restore a local Session", + "owner": "session-lifecycle", + "fixture": "seeded-local-session", + "fingerprint": "846a5f9696ec4ea5751872782a0a4e294aeafcf3ad30687d96ab5bef01964506", + "ownerPaths": [ + "packages/components/src/components/archive/", + "packages/components/src/components/sessions/rename-session-dialog.tsx", + "packages/components/src/components/sessions/session-pin.tsx", + "packages/components/src/lib/archived-session-tree.ts" + ], + "actions": [ + { "id": "session.seedLocal" }, + { "id": "session.rename" }, + { "id": "session.pin" }, + { "id": "session.archive" }, + { "id": "archive.restoreSession" } + ], + "checkpoints": [ + "renamed title survives navigation", + "pin state is durable", + "archived Session can be restored without losing history" + ], + "cleanup": ["Session is permanently deleted"], + "coverage": { + "renderer": "Session metadata and Archive UI", + "electronIpc": "Real window and Session RPC", + "bundledCli": "Real owned runtime", + "durableState": "Title, pin, archive, and restore", + "externalWire": "None" + }, + "gap": "The current lifecycle deletes an archived Session but never verifies common metadata edits or restoration from Archive.", + "evidence": [ + "packages/components/src/components/sessions/rename-session-dialog.tsx", + "packages/components/src/components/archive/archive-view.tsx" + ], + "signals": { + "criticality": 4, + "boundaryRisk": 3, + "changeFrequency": 3, + "escapedDefect": false + }, + "freshness": 3, + "scoutJourneys": ["session"], + "blockedReason": null, + "estimatedMinutes": 4 + } + ] +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 000000000..2ddc8ceaf --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,26 @@ +{ + "name": "@lody/e2e", + "private": true, + "type": "module", + "scripts": { + "acceptance": "node scripts/run-acceptance.mjs", + "check": "node scripts/check-suite.mjs && tsc --noEmit && node --test scripts/*.test.mjs ../.github/scripts/journey-author-*.test.mjs && node --import tsx --test src/support/*.test.ts src/support/fixtures/*.test.ts src/scout/*.test.ts fixtures/*.test.ts", + "failure:videos": "node scripts/render-failure-videos.mjs", + "full": "cucumber-js --config cucumber.mjs --tags '@P0 or @P1'", + "scout": "tsx src/scout/scout-runner.ts", + "journey:author": "node scripts/run-journey-author.mjs", + "journey:candidate": "node scripts/select-journey-candidate.mjs", + "journey:coverage": "node scripts/generate-coverage.mjs --write", + "journey:validate": "node scripts/validate-journey-candidate.mjs", + "scout:ablation": "tsx src/scout/scout-runner.ts --ablation --journey session", + "smoke": "cucumber-js --config cucumber.mjs --tags '@P0'" + }, + "devDependencies": { + "@agentclientprotocol/sdk": "catalog:", + "@cucumber/cucumber": "^13.2.1", + "@playwright/test": "^1.58.2", + "@types/node": "catalog:", + "tsx": "^4.23.5", + "typescript": "catalog:" + } +} diff --git a/e2e/scripts/check-suite.mjs b/e2e/scripts/check-suite.mjs new file mode 100644 index 000000000..b106a0cc9 --- /dev/null +++ b/e2e/scripts/check-suite.mjs @@ -0,0 +1,185 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { loadJourneyRegistry, renderCoverage } from './journey-registry.mjs'; + +const e2eRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const featureDir = join(e2eRoot, 'src', 'features'); +const stepDir = join(e2eRoot, 'src', 'steps'); +const pageDir = join(e2eRoot, 'src', 'support', 'pages'); +const featureReadmePath = join(featureDir, 'README.md'); +const stepReadmePath = join(stepDir, 'README.md'); +const supportReadmePath = join(e2eRoot, 'src', 'support', 'README.md'); +const coveragePath = join(e2eRoot, 'COVERAGE.md'); + +const cucumberRequire = createRequire(import.meta.resolve('@cucumber/cucumber/package.json')); +const { generateMessages } = cucumberRequire('@cucumber/gherkin'); +const { IdGenerator, SourceMediaType } = cucumberRequire('@cucumber/messages'); +const failures = []; + +function fail(message) { + failures.push(message); +} + +function filesIn(directory, suffix) { + return readdirSync(directory, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(suffix)) + .map((entry) => entry.name) + .sort(); +} + +function assertIndexed(readmePath, filenames, label) { + const readme = readFileSync(readmePath, 'utf8'); + for (const filename of filenames) { + if (!readme.includes(`\`${filename}\``)) { + fail(`${relative(e2eRoot, readmePath)} does not index ${label} ${filename}`); + } + } +} + +const featureFiles = filesIn(featureDir, '.feature'); +const pickles = []; +for (const filename of featureFiles) { + const uri = `src/features/${filename}`; + const envelopes = generateMessages( + readFileSync(join(featureDir, filename), 'utf8'), + uri, + SourceMediaType.TEXT_X_CUCUMBER_GHERKIN_PLAIN, + { + defaultDialect: 'zh-CN', + includeSource: false, + includeGherkinDocument: true, + includePickles: true, + newId: IdGenerator.incrementing(), + } + ); + for (const envelope of envelopes) { + if (envelope.parseError) { + fail( + `${uri}:${envelope.parseError.source?.location?.line ?? '?'} ${envelope.parseError.message}` + ); + } + if (envelope.pickle) pickles.push(envelope.pickle); + } +} + +const stableIds = new Map(); +const scenarioContracts = new Map(); +let p0Count = 0; +let p1Count = 0; +for (const pickle of pickles) { + const tags = pickle.tags.map((tag) => tag.name); + const location = `${pickle.uri}:${pickle.location?.line ?? '?'}`; + if (!tags.includes('@lody')) fail(`${location} ${pickle.name} is missing @lody`); + if (!tags.includes('@essence')) fail(`${location} ${pickle.name} is missing @essence`); + if (tags.includes('@wip')) fail(`${location} ${pickle.name} must not use @wip`); + + const priorities = tags.filter((tag) => tag === '@P0' || tag === '@P1'); + if (priorities.length !== 1) { + fail(`${location} ${pickle.name} must have exactly one of @P0 or @P1`); + } + if (priorities[0] === '@P0') p0Count += 1; + if (priorities[0] === '@P1') p1Count += 1; + + const runtimes = tags.filter((tag) => + ['@runtime-none', '@runtime-simulator', '@runtime-codex'].includes(tag) + ); + if (runtimes.length !== 1) { + fail(`${location} ${pickle.name} must have exactly one supported @runtime-* owner`); + } + + const ids = tags.filter((tag) => /^@LODY-[A-Z0-9-]+-\d{3}$/u.test(tag)); + if (ids.length !== 1) { + fail(`${location} ${pickle.name} must have exactly one stable @LODY-AREA-NNN id`); + continue; + } + const id = ids[0]; + const previous = stableIds.get(id); + if (previous) fail(`${location} duplicates ${id} already used by ${previous}`); + stableIds.set(id, `${location} ${pickle.name}`); + scenarioContracts.set(id.slice(1), { + feature: pickle.uri, + priority: priorities[0]?.slice(1), + runtime: runtimes[0]?.replace('@runtime-', ''), + }); +} + +assertIndexed(featureReadmePath, featureFiles, 'feature'); +assertIndexed(stepReadmePath, filesIn(stepDir, '.steps.ts'), 'step file'); +assertIndexed( + supportReadmePath, + filesIn(pageDir, '.ts').map((name) => `pages/${name}`), + 'Page Object' +); + +const expectedCount = `The active suite contains ${pickles.length} scenario${pickles.length === 1 ? '' : 's'}: ${p0Count} \`@P0\` smoke journey${p0Count === 1 ? '' : 's'} and ${p1Count} \`@P1\` deeper journey${p1Count === 1 ? '' : 's'}.`; +if (!readFileSync(featureReadmePath, 'utf8').includes(expectedCount)) { + fail(`src/features/README.md must contain the current count sentence: ${expectedCount}`); +} + +const registry = loadJourneyRegistry(); +const repositoryRoot = resolve(e2eRoot, '..'); +for (const journey of registry.journeys) { + for (const ownerPath of journey.ownerPaths) { + if (!existsSync(resolve(repositoryRoot, ownerPath))) { + fail(`journeys/registry.json ${journey.id} owner path does not exist: ${ownerPath}`); + } + } + for (const evidencePath of journey.evidence ?? []) { + if (!existsSync(resolve(repositoryRoot, evidencePath))) { + fail(`journeys/registry.json ${journey.id} evidence does not exist: ${evidencePath}`); + } + } +} +const activeJourneys = registry.journeys.filter((journey) => journey.state === 'active'); +const activeIds = new Set(activeJourneys.map((journey) => journey.id)); +for (const journey of activeJourneys) { + const contract = scenarioContracts.get(journey.id); + if (!contract) { + fail(`journeys/registry.json marks ${journey.id} active but no scenario implements it`); + continue; + } + if (contract.priority !== journey.priority) { + fail(`${journey.id} uses @${contract.priority} but registry priority is ${journey.priority}`); + } + if (contract.runtime !== journey.runtime) { + fail( + `${journey.id} uses @runtime-${contract.runtime} but registry runtime is ${journey.runtime}` + ); + } + if (contract.feature !== journey.feature) { + fail(`${journey.id} is in ${contract.feature} but registry feature is ${journey.feature}`); + } +} +for (const stableId of scenarioContracts.keys()) { + if (!activeIds.has(stableId)) { + fail(`${stableId} has an executable scenario but is not active in journeys/registry.json`); + } +} + +const coverage = readFileSync(coveragePath, 'utf8'); +if (coverage !== renderCoverage(registry)) { + fail('COVERAGE.md is stale; run `pnpm --filter @lody/e2e journey:coverage`'); +} + +for (const markdownPath of [coveragePath, featureReadmePath, stepReadmePath, supportReadmePath]) { + const markdown = readFileSync(markdownPath, 'utf8'); + for (const match of markdown.matchAll(/\[[^\]]+\]\(([^)]+)\)/gu)) { + const target = match[1].split('#')[0]; + if (!target || /^https?:/u.test(target)) continue; + if (!existsSync(resolve(dirname(markdownPath), target))) { + fail(`${relative(e2eRoot, markdownPath)} links to missing path ${target}`); + } + } +} + +if (failures.length > 0) { + console.error(`E2E suite contract failed with ${failures.length} issue(s):`); + for (const failure of failures) console.error(`- ${failure}`); + process.exitCode = 1; +} else { + console.log( + `E2E suite contract passed: ${pickles.length} scenarios (${p0Count} P0, ${p1Count} P1), ${stableIds.size} unique IDs.` + ); +} diff --git a/e2e/scripts/generate-coverage.mjs b/e2e/scripts/generate-coverage.mjs new file mode 100644 index 000000000..b6abe13bc --- /dev/null +++ b/e2e/scripts/generate-coverage.mjs @@ -0,0 +1,25 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { loadJourneyRegistry, renderCoverage } from './journey-registry.mjs'; + +const e2eRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const coveragePath = resolve(e2eRoot, 'COVERAGE.md'); +const mode = process.argv[2] ?? '--check'; +if (!['--check', '--write'].includes(mode) || process.argv.length > 3) { + throw new Error('Usage: node scripts/generate-coverage.mjs [--check|--write]'); +} + +const expected = renderCoverage(loadJourneyRegistry()); +if (mode === '--write') { + writeFileSync(coveragePath, expected, 'utf8'); + console.log('Updated COVERAGE.md from journeys/registry.json.'); +} else { + const actual = readFileSync(coveragePath, 'utf8'); + if (actual !== expected) { + console.error('COVERAGE.md is stale. Run `pnpm --filter @lody/e2e journey:coverage`.'); + process.exitCode = 1; + } else { + console.log('COVERAGE.md matches journeys/registry.json.'); + } +} diff --git a/e2e/scripts/journey-registry.mjs b/e2e/scripts/journey-registry.mjs new file mode 100644 index 000000000..9864197d1 --- /dev/null +++ b/e2e/scripts/journey-registry.mjs @@ -0,0 +1,382 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)); +export const defaultRegistryPath = resolve(scriptDirectory, '..', 'journeys', 'registry.json'); + +const states = new Set(['active', 'backlog', 'quarantined']); +const priorities = new Set(['P0', 'P1']); +const runtimes = new Set(['none', 'simulator', 'codex']); +const coverageKeys = ['renderer', 'electronIpc', 'bundledCli', 'durableState', 'externalWire']; +const signalKeys = ['criticality', 'boundaryRisk', 'changeFrequency']; +const scoringKeys = [ + 'criticality', + 'boundaryRisk', + 'changeFrequency', + 'freshness', + 'escapedDefect', + 'scoutSignal', + 'changedPath', + 'estimatedMinutePenalty', +]; + +function isRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function requireString(value, path, failures) { + if (typeof value !== 'string' || value.trim() === '') failures.push(`${path} must be a string`); +} + +function requireStringArray(value, path, failures, allowEmpty = false) { + if (!Array.isArray(value) || (!allowEmpty && value.length === 0)) { + failures.push(`${path} must be ${allowEmpty ? 'a' : 'a non-empty'} string array`); + return; + } + for (const [index, entry] of value.entries()) { + requireString(entry, `${path}[${index}]`, failures); + } +} + +export function normalizeSemanticContract(journey) { + return { + runtime: journey.runtime, + fixture: journey.fixture, + actions: journey.actions?.map((action) => ({ + id: action.id, + ...(action.args === undefined ? {} : { args: action.args }), + })), + checkpoints: journey.checkpoints, + cleanup: journey.cleanup, + }; +} + +export function journeyFingerprint(journey) { + return createHash('sha256') + .update(JSON.stringify(normalizeSemanticContract(journey))) + .digest('hex'); +} + +export function validateRegistry(registry) { + const failures = []; + if (!isRecord(registry)) return ['registry must be an object']; + if (registry.schemaVersion !== 1) failures.push('schemaVersion must equal 1'); + if (!isRecord(registry.scoring)) { + failures.push('scoring must be an object'); + } else { + for (const key of scoringKeys) { + if (!Number.isSafeInteger(registry.scoring[key]) || registry.scoring[key] < 0) { + failures.push(`scoring.${key} must be a non-negative integer`); + } + } + } + if (!Array.isArray(registry.journeys) || registry.journeys.length === 0) { + failures.push('journeys must be a non-empty array'); + return failures; + } + + const ids = new Map(); + const fingerprints = new Map(); + for (const [index, journey] of registry.journeys.entries()) { + const path = `journeys[${index}]`; + if (!isRecord(journey)) { + failures.push(`${path} must be an object`); + continue; + } + requireString(journey.id, `${path}.id`, failures); + if (typeof journey.id === 'string' && !/^LODY-[A-Z0-9-]+-\d{3}$/u.test(journey.id)) { + failures.push(`${path}.id must match LODY-AREA-NNN`); + } + if (ids.has(journey.id)) failures.push(`${path}.id duplicates ${ids.get(journey.id)}`); + else ids.set(journey.id, path); + if (!states.has(journey.state)) failures.push(`${path}.state is unsupported`); + if (!priorities.has(journey.priority)) failures.push(`${path}.priority is unsupported`); + if (!runtimes.has(journey.runtime)) failures.push(`${path}.runtime is unsupported`); + requireString(journey.title, `${path}.title`, failures); + requireString(journey.owner, `${path}.owner`, failures); + requireString(journey.fixture, `${path}.fixture`, failures); + requireStringArray(journey.ownerPaths, `${path}.ownerPaths`, failures); + requireStringArray(journey.checkpoints, `${path}.checkpoints`, failures); + requireStringArray(journey.cleanup, `${path}.cleanup`, failures); + + if (!Array.isArray(journey.actions) || journey.actions.length === 0) { + failures.push(`${path}.actions must be a non-empty array`); + } else { + for (const [actionIndex, action] of journey.actions.entries()) { + if (!isRecord(action)) { + failures.push(`${path}.actions[${actionIndex}] must be an object`); + continue; + } + requireString(action.id, `${path}.actions[${actionIndex}].id`, failures); + } + } + + if (!isRecord(journey.coverage)) { + failures.push(`${path}.coverage must be an object`); + } else { + for (const key of coverageKeys) { + requireString(journey.coverage[key], `${path}.coverage.${key}`, failures); + } + } + if (!isRecord(journey.signals)) { + failures.push(`${path}.signals must be an object`); + } else { + for (const key of signalKeys) { + if ( + !Number.isSafeInteger(journey.signals[key]) || + journey.signals[key] < 1 || + journey.signals[key] > 5 + ) { + failures.push(`${path}.signals.${key} must be an integer from 1 through 5`); + } + } + if (typeof journey.signals.escapedDefect !== 'boolean') { + failures.push(`${path}.signals.escapedDefect must be a boolean`); + } + } + if (!Number.isSafeInteger(journey.estimatedMinutes) || journey.estimatedMinutes < 1) { + failures.push(`${path}.estimatedMinutes must be a positive integer`); + } + if ( + !Number.isSafeInteger(journey.freshness) || + journey.freshness < 1 || + journey.freshness > 5 + ) { + failures.push(`${path}.freshness must be an integer from 1 through 5`); + } + requireStringArray(journey.scoutJourneys, `${path}.scoutJourneys`, failures, true); + if (journey.blockedReason !== null && typeof journey.blockedReason !== 'string') { + failures.push(`${path}.blockedReason must be null or a string`); + } + + if (journey.state === 'active') { + requireString(journey.feature, `${path}.feature`, failures); + } else { + requireString(journey.gap, `${path}.gap`, failures); + requireStringArray(journey.evidence, `${path}.evidence`, failures); + } + + if (Array.isArray(journey.actions) && journey.actions.length > 0) { + const fingerprint = journeyFingerprint(journey); + if (journey.fingerprint !== fingerprint) { + failures.push( + `${path}.fingerprint must equal the computed semantic fingerprint ${fingerprint}` + ); + } + const duplicate = fingerprints.get(fingerprint); + if (duplicate) failures.push(`${path} duplicates the semantic contract of ${duplicate}`); + else fingerprints.set(fingerprint, `${path} (${journey.id})`); + } + } + return failures; +} + +export function loadJourneyRegistry(path = defaultRegistryPath) { + const registry = JSON.parse(readFileSync(path, 'utf8')); + const failures = validateRegistry(registry); + if (failures.length > 0) { + throw new Error(`Journey registry is invalid:\n- ${failures.join('\n- ')}`); + } + return registry; +} + +function markdownCell(value) { + return String(value).replaceAll('|', '\\|').replaceAll('\r', ' ').replaceAll('\n', ' '); +} + +function markdownTable(headers, rows, rightAligned = new Set()) { + const cells = [headers, ...rows].map((row) => row.map(markdownCell)); + const widths = headers.map((_, column) => Math.max(3, ...cells.map((row) => row[column].length))); + const row = (values) => + `| ${values + .map((value, column) => + rightAligned.has(column) ? value.padStart(widths[column]) : value.padEnd(widths[column]) + ) + .join(' | ')} |`; + const separator = widths.map((width, column) => + rightAligned.has(column) ? `${'-'.repeat(width - 1)}:` : '-'.repeat(width) + ); + return [row(cells[0]), row(separator), ...cells.slice(1).map(row)]; +} + +export function renderCoverage(registry) { + const active = registry.journeys + .filter((journey) => journey.state === 'active') + .sort( + (left, right) => + left.priority.localeCompare(right.priority) || left.id.localeCompare(right.id) + ); + const backlog = registry.journeys + .filter((journey) => journey.state === 'backlog') + .sort((left, right) => left.id.localeCompare(right.id)); + const lines = [ + '# Desktop journey coverage', + '', + 'This file is generated from [`journeys/registry.json`](./journeys/registry.json).', + 'Run `pnpm --filter @lody/e2e journey:coverage` after changing the registry.', + '', + 'The active matrix records product boundaries exercised by implemented scenarios.', + 'Backlog rows are evidence-backed gaps, not executable or promised scenarios.', + '', + ]; + + for (const priority of ['P0', 'P1']) { + const journeys = active.filter((journey) => journey.priority === priority); + lines.push(`## Active ${priority} journeys`, ''); + lines.push( + ...markdownTable( + [ + 'Stable id', + 'Journey', + 'Renderer', + 'Electron / IPC', + 'Bundled CLI', + 'Durable state', + 'External wire', + ], + journeys.map((journey) => [ + `\`${journey.id}\``, + journey.title, + journey.coverage.renderer, + journey.coverage.electronIpc, + journey.coverage.bundledCli, + journey.coverage.durableState, + journey.coverage.externalWire, + ]) + ) + ); + lines.push(''); + } + + lines.push( + '## Evidence-backed backlog', + '', + ...markdownTable( + [ + 'Stable id', + 'Priority', + 'Owner', + 'Freshness', + 'Proposed journey', + 'Estimated minutes', + 'Status', + 'Gap', + ], + backlog.map((journey) => [ + `\`${journey.id}\``, + journey.priority, + journey.owner, + `${journey.freshness}/5`, + journey.title, + journey.estimatedMinutes, + journey.blockedReason ? `Blocked: ${journey.blockedReason}` : 'Eligible', + journey.gap, + ]), + new Set([3, 5]) + ) + ); + lines.push( + '', + 'Candidate selection is deterministic and returns at most one backlog row per run.', + 'Scout may provide evidence for a narrow candidate, but it does not maintain a second journey implementation.', + '' + ); + return lines.join('\n'); +} + +function normalizePath(path) { + return path.trim().replaceAll('\\', '/').replace(/^\.\//u, ''); +} + +export function ownerPathMatches(ownerPath, changedPath) { + const owner = normalizePath(ownerPath); + const changed = normalizePath(changedPath); + return owner.endsWith('/') ? changed.startsWith(owner) : changed === owner; +} + +function normalizeSelectionInputs(inputs) { + if (Array.isArray(inputs)) { + return { changedFiles: inputs, escapedDefectIds: [], scoutJourneys: [] }; + } + return { + changedFiles: inputs.changedFiles ?? [], + escapedDefectIds: inputs.escapedDefectIds ?? [], + scoutJourneys: inputs.scoutJourneys ?? [], + }; +} + +export function scoreJourney(journey, scoring, inputs = {}) { + const normalizedInputs = normalizeSelectionInputs(inputs); + const changedPathMatch = normalizedInputs.changedFiles.some((changedPath) => + journey.ownerPaths.some((ownerPath) => ownerPathMatches(ownerPath, changedPath)) + ); + const escapedDefectMatch = + journey.signals.escapedDefect || normalizedInputs.escapedDefectIds.includes(journey.id); + const scoutSignalMatch = normalizedInputs.scoutJourneys.some((scoutJourney) => + journey.scoutJourneys.includes(scoutJourney) + ); + const breakdown = { + criticality: journey.signals.criticality * scoring.criticality, + boundaryRisk: journey.signals.boundaryRisk * scoring.boundaryRisk, + changeFrequency: journey.signals.changeFrequency * scoring.changeFrequency, + freshness: journey.freshness * scoring.freshness, + escapedDefect: escapedDefectMatch ? scoring.escapedDefect : 0, + scoutSignal: scoutSignalMatch ? scoring.scoutSignal : 0, + changedPath: changedPathMatch ? scoring.changedPath : 0, + estimatedMinutePenalty: -journey.estimatedMinutes * scoring.estimatedMinutePenalty, + }; + return { + score: Object.values(breakdown).reduce((total, value) => total + value, 0), + changedPathMatch, + escapedDefectMatch, + scoutSignalMatch, + breakdown, + }; +} + +export function selectJourneyCandidate(registry, inputs = {}) { + const activeFingerprints = new Set( + registry.journeys + .filter((journey) => journey.state === 'active') + .map((journey) => journeyFingerprint(journey)) + ); + const seenBacklog = new Set(); + const skippedBlocked = []; + const skippedDuplicates = []; + const ranked = []; + const backlog = registry.journeys + .filter((journey) => journey.state === 'backlog') + .sort((left, right) => left.id.localeCompare(right.id)); + + for (const journey of backlog) { + if (journey.blockedReason) { + skippedBlocked.push({ id: journey.id, reason: journey.blockedReason }); + continue; + } + const fingerprint = journeyFingerprint(journey); + if (activeFingerprints.has(fingerprint) || seenBacklog.has(fingerprint)) { + skippedDuplicates.push({ id: journey.id, fingerprint }); + continue; + } + seenBacklog.add(fingerprint); + ranked.push({ + id: journey.id, + title: journey.title, + priority: journey.priority, + fingerprint, + owner: journey.owner, + freshness: journey.freshness, + ...scoreJourney(journey, registry.scoring, inputs), + }); + } + ranked.sort((left, right) => right.score - left.score || left.id.localeCompare(right.id)); + return { + selected: ranked[0] ?? null, + considered: ranked.length, + ranked, + skippedBlocked, + skippedDuplicates, + }; +} diff --git a/e2e/scripts/journey-registry.test.mjs b/e2e/scripts/journey-registry.test.mjs new file mode 100644 index 000000000..2d6fe8a87 --- /dev/null +++ b/e2e/scripts/journey-registry.test.mjs @@ -0,0 +1,186 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + journeyFingerprint, + ownerPathMatches, + renderCoverage, + scoreJourney, + selectJourneyCandidate, + validateRegistry, +} from './journey-registry.mjs'; + +const scoring = { + criticality: 100, + boundaryRisk: 20, + changeFrequency: 5, + freshness: 5, + escapedDefect: 40, + scoutSignal: 35, + changedPath: 50, + estimatedMinutePenalty: 2, +}; + +function journey(overrides = {}) { + const value = { + id: 'LODY-TEST-001', + state: 'backlog', + priority: 'P1', + runtime: 'none', + title: 'Test journey', + owner: 'example-owner', + fixture: 'synthetic-fixture', + ownerPaths: ['packages/components/src/example/'], + actions: [{ id: 'example.open' }, { id: 'example.close' }], + checkpoints: ['observable result'], + cleanup: ['resource released'], + coverage: { + renderer: 'Example UI', + electronIpc: 'Real IPC', + bundledCli: 'Real CLI', + durableState: 'Synthetic state', + externalWire: 'None', + }, + gap: 'No desktop coverage.', + evidence: ['packages/components/src/example/view.tsx'], + signals: { criticality: 3, boundaryRisk: 2, changeFrequency: 1, escapedDefect: false }, + freshness: 3, + scoutJourneys: [], + blockedReason: null, + estimatedMinutes: 4, + ...overrides, + }; + value.fingerprint = journeyFingerprint(value); + return value; +} + +void describe('journey registry', () => { + void it('rejects duplicate ids and semantic contracts', () => { + const first = journey(); + const duplicate = journey({ id: first.id }); + const failures = validateRegistry({ schemaVersion: 1, scoring, journeys: [first, duplicate] }); + assert.ok(failures.some((failure) => failure.includes('.id duplicates'))); + assert.ok(failures.some((failure) => failure.includes('duplicates the semantic contract'))); + }); + + void it('fingerprints only executable semantics', () => { + const first = journey(); + const renamed = journey({ id: 'LODY-OTHER-002', title: 'Renamed proposal' }); + const changed = journey({ + id: 'LODY-OTHER-003', + actions: [{ id: 'example.open' }, { id: 'example.save' }], + }); + assert.equal(journeyFingerprint(first), journeyFingerprint(renamed)); + assert.notEqual(journeyFingerprint(first), journeyFingerprint(changed)); + }); + + void it('matches exact files and owned directory prefixes', () => { + assert.equal(ownerPathMatches('apps/cli/src/mcp/', 'apps/cli/src/mcp/server.ts'), true); + assert.equal(ownerPathMatches('apps/cli/src/mcp/', 'apps/cli/src/agent/server.ts'), false); + assert.equal(ownerPathMatches('./package.json', 'package.json'), true); + assert.equal(ownerPathMatches('package.json', 'package.json.backup'), false); + }); + + void it('scores changed ownership without depending on changed-file order', () => { + const candidate = journey(); + const plain = scoreJourney(candidate, scoring, []); + const changed = scoreJourney(candidate, scoring, [ + 'unrelated/file.ts', + 'packages/components/src/example/view.tsx', + ]); + const reversed = scoreJourney(candidate, scoring, [ + 'packages/components/src/example/view.tsx', + 'unrelated/file.ts', + ]); + assert.equal(changed.score - plain.score, scoring.changedPath); + assert.deepEqual(changed, reversed); + }); + + void it('scores normalized escaped-defect and Scout inputs', () => { + const candidate = journey({ scoutJourneys: ['session'] }); + const plain = scoreJourney(candidate, scoring); + const signaled = scoreJourney(candidate, scoring, { + escapedDefectIds: [candidate.id], + scoutJourneys: ['session'], + }); + assert.equal(signaled.score - plain.score, scoring.escapedDefect + scoring.scoutSignal); + assert.equal(signaled.escapedDefectMatch, true); + assert.equal(signaled.scoutSignalMatch, true); + }); + + void it('selects exactly one highest score with an id tie-break', () => { + const lower = journey({ + id: 'LODY-TEST-003', + signals: { ...journey().signals, criticality: 2 }, + }); + const tiedLast = journey({ + id: 'LODY-TEST-002', + actions: [{ id: 'second.open' }], + }); + const tiedFirst = journey({ + id: 'LODY-TEST-001', + actions: [{ id: 'first.open' }], + }); + const result = selectJourneyCandidate( + { schemaVersion: 1, scoring, journeys: [lower, tiedLast, tiedFirst] }, + [] + ); + assert.equal(result.selected?.id, 'LODY-TEST-001'); + assert.equal(result.considered, 3); + }); + + void it('deduplicates backlog contracts and contracts already active', () => { + const active = journey({ id: 'LODY-ACTIVE-001', state: 'active', feature: 'active.feature' }); + const covered = journey({ id: 'LODY-COVERED-001' }); + const unique = journey({ id: 'LODY-UNIQUE-001', actions: [{ id: 'unique.open' }] }); + const repeated = journey({ id: 'LODY-UNIQUE-002', actions: [{ id: 'unique.open' }] }); + const result = selectJourneyCandidate( + { schemaVersion: 1, scoring, journeys: [active, covered, repeated, unique] }, + [] + ); + assert.equal(result.considered, 1); + assert.equal(result.selected?.id, 'LODY-UNIQUE-001'); + assert.deepEqual( + result.skippedDuplicates.map(({ id }) => id), + ['LODY-COVERED-001', 'LODY-UNIQUE-002'] + ); + }); + + void it('skips a blocked leader so it cannot stop the queue', () => { + const blocked = journey({ + id: 'LODY-BLOCKED-001', + signals: { ...journey().signals, criticality: 5 }, + blockedReason: 'Missing deterministic fixture.', + }); + const eligible = journey({ + id: 'LODY-ELIGIBLE-001', + actions: [{ id: 'eligible.open' }], + signals: { ...journey().signals, criticality: 2 }, + }); + const result = selectJourneyCandidate( + { schemaVersion: 1, scoring, journeys: [blocked, eligible] }, + [] + ); + assert.equal(result.selected?.id, 'LODY-ELIGIBLE-001'); + assert.deepEqual(result.skippedBlocked, [ + { id: 'LODY-BLOCKED-001', reason: 'Missing deterministic fixture.' }, + ]); + }); + + void it('renders active and backlog rows in deterministic id order', () => { + const active = journey({ + id: 'LODY-ACTIVE-002', + state: 'active', + priority: 'P0', + feature: 'active.feature', + }); + const backlogB = journey({ id: 'LODY-BACKLOG-002', actions: [{ id: 'b.open' }] }); + const backlogA = journey({ id: 'LODY-BACKLOG-001', actions: [{ id: 'a.open' }] }); + const markdown = renderCoverage({ + schemaVersion: 1, + scoring, + journeys: [backlogB, active, backlogA], + }); + assert.ok(markdown.indexOf('LODY-BACKLOG-001') < markdown.indexOf('LODY-BACKLOG-002')); + assert.match(markdown, /This file is generated from/u); + }); +}); diff --git a/e2e/scripts/render-failure-videos.mjs b/e2e/scripts/render-failure-videos.mjs new file mode 100644 index 000000000..74e96eacf --- /dev/null +++ b/e2e/scripts/render-failure-videos.mjs @@ -0,0 +1,166 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { mkdtemp, readFile, readdir, rename, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { once } from 'node:events'; + +const MAX_FRAMES = 600; +const FRAME_RATE = 5; + +export function selectTraceFrames(names, maximum = MAX_FRAMES) { + const frames = names + .map((name) => { + const match = /^page@.+-(\d+)\.jpeg$/u.exec(name); + return match ? { name, timestamp: Number(match[1]) } : null; + }) + .filter((frame) => frame !== null) + .sort((left, right) => left.timestamp - right.timestamp || left.name.localeCompare(right.name)); + if (frames.length <= maximum) return frames.map((frame) => frame.name); + return Array.from({ length: maximum }, (_unused, index) => { + const sourceIndex = Math.round((index * (frames.length - 1)) / (maximum - 1)); + return frames[sourceIndex].name; + }); +} + +export function resolvePlaywrightFfmpeg() { + if (process.env.LODY_E2E_FFMPEG_PATH) return resolve(process.env.LODY_E2E_FFMPEG_PATH); + const require = createRequire(import.meta.url); + const testPackage = dirname(require.resolve('@playwright/test/package.json')); + const playwrightPackage = require.resolve('playwright/package.json', { paths: [testPackage] }); + const corePackage = require.resolve('playwright-core/package.json', { + paths: [dirname(playwrightPackage)], + }); + const { registry } = require(join(dirname(corePackage), 'lib/server/registry/index.js')); + return registry.findExecutable('ffmpeg').executablePathOrDie(); +} + +async function run(command, args) { + const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + const stdout = []; + const stderr = []; + child.stdout.on('data', (chunk) => stdout.push(chunk)); + child.stderr.on('data', (chunk) => stderr.push(chunk)); + const [code, signal] = await once(child, 'close'); + if (code !== 0) { + throw new Error( + `${command} exited with ${code ?? signal}: ${Buffer.concat(stderr).toString('utf8').slice(-4000)}` + ); + } + return Buffer.concat(stdout).toString('utf8'); +} + +async function encodeFrames(ffmpegPath, framePaths, outputPath) { + const temporaryOutput = `${outputPath}.tmp`; + const child = spawn( + ffmpegPath, + [ + '-loglevel', + 'error', + '-f', + 'image2pipe', + '-framerate', + String(FRAME_RATE), + '-vcodec', + 'mjpeg', + '-i', + 'pipe:0', + '-vf', + 'scale=640:-2', + '-an', + '-c:v', + 'libvpx', + '-b:v', + '350k', + '-deadline', + 'realtime', + '-cpu-used', + '8', + '-f', + 'webm', + '-y', + temporaryOutput, + ], + { stdio: ['pipe', 'ignore', 'pipe'] } + ); + const stderr = []; + child.stderr.on('data', (chunk) => stderr.push(chunk)); + for (const framePath of framePaths) { + if (!child.stdin.write(await readFile(framePath))) await once(child.stdin, 'drain'); + } + child.stdin.end(); + const [code, signal] = await once(child, 'close'); + if (code !== 0) { + await rm(temporaryOutput, { force: true }); + throw new Error( + `ffmpeg exited with ${code ?? signal}: ${Buffer.concat(stderr).toString('utf8').slice(-4000)}` + ); + } + await rename(temporaryOutput, outputPath); +} + +export async function renderFailureVideos({ + artifactRoot, + ffmpegPath = resolvePlaywrightFfmpeg(), +}) { + const root = resolve(artifactRoot); + let failures; + try { + failures = JSON.parse(await readFile(join(root, 'failure-index.json'), 'utf8')); + } catch (error) { + if (error?.code === 'ENOENT') return []; + throw error; + } + if (!Array.isArray(failures)) throw new Error('failure-index.json must contain an array'); + + const rendered = []; + const seen = new Set(); + for (const failure of failures) { + const stableId = failure?.stableId; + const expectedPath = + typeof stableId === 'string' ? `scenarios/${stableId.toLowerCase()}` : undefined; + if ( + typeof stableId !== 'string' || + !/^LODY-[A-Z0-9-]+-\d{3}$/u.test(stableId) || + failure.path !== expectedPath || + seen.has(stableId) + ) { + continue; + } + seen.add(stableId); + const scenarioDirectory = join(root, failure.path); + const tracePath = join(scenarioDirectory, 'trace.zip'); + const extractionRoot = await mkdtemp(join(tmpdir(), 'lody-trace-video-')); + try { + await run('unzip', ['-q', tracePath, '-d', extractionRoot]); + const resources = join(extractionRoot, 'resources'); + const frames = selectTraceFrames(await readdir(resources)); + if (frames.length === 0) throw new Error(`${stableId} trace contains no screenshot frames`); + const outputPath = join(scenarioDirectory, 'failure.webm'); + await encodeFrames( + ffmpegPath, + frames.map((frame) => join(resources, frame)), + outputPath + ); + rendered.push({ stableId, frameCount: frames.length, outputPath }); + console.log(`[e2e] ${stableId}: rendered ${frames.length} trace frames to failure.webm`); + } catch (error) { + console.error(`[e2e] ${stableId}: failure video was not rendered`, error); + } finally { + await rm(extractionRoot, { recursive: true, force: true }); + } + } + return rendered; +} + +async function main() { + const artifactRoot = process.argv[2] ?? 'artifacts'; + await renderFailureVideos({ artifactRoot }); +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) { + await main(); +} diff --git a/e2e/scripts/render-failure-videos.test.mjs b/e2e/scripts/render-failure-videos.test.mjs new file mode 100644 index 000000000..9f6aee9a2 --- /dev/null +++ b/e2e/scripts/render-failure-videos.test.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { selectTraceFrames } from './render-failure-videos.mjs'; + +void test('orders trace screenshots by timestamp and ignores unrelated resources', () => { + assert.deepEqual( + selectTraceFrames([ + 'source.txt', + 'page@id-30.jpeg', + 'page@id-10.jpeg', + 'page@id-20.jpeg', + 'snapshot.png', + ]), + ['page@id-10.jpeg', 'page@id-20.jpeg', 'page@id-30.jpeg'] + ); +}); + +void test('samples long traces evenly while retaining both endpoints', () => { + const names = Array.from({ length: 10 }, (_unused, index) => `page@id-${index}.jpeg`); + assert.deepEqual(selectTraceFrames(names, 4), [ + 'page@id-0.jpeg', + 'page@id-3.jpeg', + 'page@id-6.jpeg', + 'page@id-9.jpeg', + ]); +}); diff --git a/e2e/scripts/run-acceptance.mjs b/e2e/scripts/run-acceptance.mjs new file mode 100644 index 000000000..a94310951 --- /dev/null +++ b/e2e/scripts/run-acceptance.mjs @@ -0,0 +1,246 @@ +import { spawnSync } from 'node:child_process'; +import { createHash, randomUUID } from 'node:crypto'; +import { + copyFileSync, + closeSync, + existsSync, + mkdirSync, + openSync, + readSync, + readFileSync, + readdirSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { join, relative, resolve } from 'node:path'; + +const SCENARIOS = { + onboarding: { + id: 'LODY-ONBOARDING-001', + question: + 'Does a clean real desktop reach an editable local workspace without cloud access or Agent configuration?', + }, + session: { + id: 'LODY-SESSION-001', + question: 'Does stopping and deleting a real ACP Session release its Agent process?', + }, + review: { + id: 'LODY-REVIEW-001', + question: 'Can a user inspect, switch, hide, restore, and close large local diffs?', + }, + work: { + id: 'LODY-WORK-001', + question: + 'Does deleting a worktree Session release its ACP process, Terminal, and worktree directory?', + }, +}; + +const SUBJECTS = { + 'desktop-local-bootstrap': { + tags: '@LODY-ONBOARDING-001', + requirement: + 'A clean OSS desktop starts its bundled CLI, provisions the local workspace, and enters the product without Agent configuration.', + scenarios: [SCENARIOS.onboarding], + }, + 'desktop-session-lifecycle': { + tags: '@LODY-SESSION-001', + requirement: 'A stopped and deleted Session releases its deterministic ACP runtime.', + scenarios: [SCENARIOS.session], + }, + 'desktop-review-lifecycle': { + tags: '@LODY-REVIEW-001', + requirement: 'Large local diffs remain usable through Review open, switch, hide, and close.', + scenarios: [SCENARIOS.review], + }, + 'desktop-work-lifecycle': { + tags: '@LODY-WORK-001', + requirement: + 'Permanent Work deletion releases the ACP process, Terminal, and generated worktree.', + scenarios: [SCENARIOS.work], + }, + 'desktop-lifecycle': { + tags: '@P0 or @P1', + requirement: + 'The real OSS desktop satisfies bootstrap, Session, Review, and Work lifecycle acceptance checks.', + scenarios: Object.values(SCENARIOS), + }, +}; + +function readSingleOption(argv, name) { + const positions = argv.flatMap((value, index) => (value === name ? [index] : [])); + if (positions.length > 1) throw new Error(`Acceptance ${name} may be provided only once`); + if (positions.length === 0) return undefined; + const value = argv[positions[0] + 1]; + if (!value || value.startsWith('--')) throw new Error(`Acceptance ${name} requires a value`); + return value; +} + +function readOptions(argv) { + const subject = readSingleOption(argv, '--subject') ?? 'desktop-local-bootstrap'; + if (!/^[a-z0-9][a-z0-9-]{0,79}$/u.test(subject) || !(subject in SUBJECTS)) { + throw new Error(`Acceptance --subject must be one of: ${Object.keys(SUBJECTS).join(', ')}`); + } + const before = readSingleOption(argv, '--before'); + const after = readSingleOption(argv, '--after'); + const retainedPath = readSingleOption(argv, '--retained-path'); + if (Boolean(before) !== Boolean(after)) { + throw new Error('Acceptance --before and --after must be provided together'); + } + return { subject, before, after, retainedPath }; +} + +function validateInput(path, kind, maxBytes, parseJson) { + if (!path) return undefined; + const absolutePath = resolve(path); + const stat = statSync(absolutePath); + if (!stat.isFile() || stat.size === 0 || stat.size > maxBytes) { + throw new Error(`${kind} must be a non-empty file no larger than ${maxBytes} bytes`); + } + if (parseJson) JSON.parse(readFileSync(absolutePath, 'utf8')); + return absolutePath; +} + +function scenarioEvidence(scenario) { + const directory = `scenarios/${scenario.id.toLowerCase()}`; + return [ + `${directory}/checkpoint.png`, + `${directory}/runtime.json`, + `${directory}/cli-backlog.json`, + `${directory}/trace.zip`, + ]; +} + +function listFiles(root, directory = root) { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + return entry.isDirectory() ? listFiles(root, path) : [path]; + }); +} + +function describeFile(root, path) { + const stat = statSync(path); + const hash = createHash('sha256'); + const buffer = Buffer.allocUnsafe(1024 * 1024); + const fd = openSync(path, 'r'); + try { + let bytesRead; + do { + bytesRead = readSync(fd, buffer, 0, buffer.length, null); + if (bytesRead > 0) hash.update(buffer.subarray(0, bytesRead)); + } while (bytesRead > 0); + } finally { + closeSync(fd); + } + return { + path: relative(root, path), + bytes: stat.size, + sha256: hash.digest('hex'), + }; +} + +const packageManagerEntry = process.env.npm_execpath; +if (!packageManagerEntry || !/\.(?:cjs|mjs|js)$/iu.test(packageManagerEntry)) { + throw new Error('Run acceptance through pnpm so the package manager entry is explicit'); +} + +const options = readOptions(process.argv.slice(2)); +const beforeInput = validateInput(options.before, 'Before evidence', 16 * 1024 * 1024, true); +const afterInput = validateInput(options.after, 'After evidence', 16 * 1024 * 1024, true); +const retainedPathInput = validateInput( + options.retainedPath, + 'Retained-path evidence', + 4 * 1024 * 1024, + false +); +const subject = SUBJECTS[options.subject]; +const startedAt = new Date().toISOString(); +const roundId = `${startedAt.replace(/[:.]/gu, '-')}-${randomUUID().slice(0, 8)}`; +const acceptanceRoot = join(process.cwd(), 'artifacts', 'acceptance'); +mkdirSync(acceptanceRoot, { recursive: true }); +const roundDir = join(acceptanceRoot, roundId); +mkdirSync(roundDir, { recursive: false }); + +const suppliedEvidence = {}; +if (beforeInput && afterInput) { + const evidenceDir = join(roundDir, 'evidence'); + mkdirSync(evidenceDir); + copyFileSync(beforeInput, join(evidenceDir, 'before.json')); + copyFileSync(afterInput, join(evidenceDir, 'after.json')); + suppliedEvidence.before = 'evidence/before.json'; + suppliedEvidence.after = 'evidence/after.json'; +} +if (retainedPathInput) { + const evidenceDir = join(roundDir, 'evidence'); + mkdirSync(evidenceDir, { recursive: true }); + copyFileSync(retainedPathInput, join(evidenceDir, 'retained-path.txt')); + suppliedEvidence.retainedPath = 'evidence/retained-path.txt'; +} + +const result = spawnSync( + process.execPath, + [packageManagerEntry, 'exec', 'cucumber-js', '--config', 'cucumber.mjs', '--tags', subject.tags], + { + cwd: process.cwd(), + env: { ...process.env, LODY_ACCEPTANCE_ROUND_ID: roundId }, + encoding: 'utf8', + maxBuffer: 16 * 1024 * 1024, + } +); + +if (result.stdout) process.stdout.write(result.stdout); +if (result.stderr) process.stderr.write(result.stderr); +writeFileSync( + join(roundDir, 'command.log'), + `${result.stdout ?? ''}${result.stderr ?? ''}`, + 'utf8' +); + +const checks = subject.scenarios.map((scenario) => ({ + id: scenario.id.toLowerCase(), + question: scenario.question, + evidence: scenarioEvidence(scenario), +})); +const declaredEvidence = [ + ...checks.flatMap((check) => check.evidence), + ...Object.values(suppliedEvidence), +]; +const missingEvidence = declaredEvidence.filter((path) => { + const absolutePath = join(roundDir, path); + return ( + !existsSync(absolutePath) || + !statSync(absolutePath).isFile() || + statSync(absolutePath).size === 0 + ); +}); +const status = result.status === 0 && missingEvidence.length === 0 ? 'ready_for_review' : 'failed'; +const report = { + schemaVersion: 2, + roundId, + subject: options.subject, + requirement: subject.requirement, + status, + startedAt, + completedAt: new Date().toISOString(), + immutable: true, + tags: subject.tags, + checks, + suppliedEvidence, + missingEvidence, +}; +writeFileSync(join(roundDir, 'result.json'), `${JSON.stringify(report, null, 2)}\n`, 'utf8'); + +const manifestFiles = listFiles(roundDir) + .filter((path) => relative(roundDir, path) !== 'manifest.json') + .map((path) => describeFile(roundDir, path)) + .sort((left, right) => left.path.localeCompare(right.path)); +writeFileSync( + join(roundDir, 'manifest.json'), + `${JSON.stringify({ schemaVersion: 1, roundId, files: manifestFiles }, null, 2)}\n`, + 'utf8' +); + +console.log(`Acceptance round: ${roundDir}`); +if (missingEvidence.length > 0) { + console.error(`Acceptance evidence missing: ${missingEvidence.join(', ')}`); +} +process.exit(result.status === 0 && missingEvidence.length === 0 ? 0 : (result.status ?? 1)); diff --git a/e2e/scripts/run-journey-author.mjs b/e2e/scripts/run-journey-author.mjs new file mode 100644 index 000000000..633a55c84 --- /dev/null +++ b/e2e/scripts/run-journey-author.mjs @@ -0,0 +1,852 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { spawn, spawnSync } from 'node:child_process'; +import { createWriteStream } from 'node:fs'; +import { lstat, mkdir, mkdtemp, open, readFile, rm, unlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { createJourneyAuthorTask } from '../../.github/scripts/journey-author-task.mjs'; +import { + applyAblation, + packageCandidate, + promoteCandidate, + restoreAblation, + validateAndApplyCandidate, +} from '../../.github/scripts/journey-author-package.mjs'; +import { loadJourneyRegistry } from './journey-registry.mjs'; + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)); +const defaultRepositoryRoot = resolve(scriptDirectory, '..', '..'); +const valueOptions = new Set([ + 'artifact-dir', + 'budget-minutes', + 'candidate', + 'changed-files', + 'escaped-defects', + 'excluded', + 'model', + 'scout-journeys', +]); +const booleanOptions = new Set(['help', 'prepare-only']); + +export function parseLocalAuthorOptions(argv) { + const options = { + candidate: 'next', + budgetMinutes: 90, + model: 'gpt-5.6-sol', + prepareOnly: false, + }; + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (token === '--') continue; + if (!token.startsWith('--')) throw new Error(`Unexpected argument: ${token}`); + const name = token.slice(2); + if (booleanOptions.has(name)) { + if (name === 'help') options.help = true; + if (name === 'prepare-only') options.prepareOnly = true; + continue; + } + if (!valueOptions.has(name)) throw new Error(`Unknown option: --${name}`); + const value = argv[index + 1]; + if (!value || value.startsWith('--')) throw new Error(`--${name} requires a value`); + index += 1; + if (name === 'budget-minutes') { + const minutes = Number.parseInt(value, 10); + if (!Number.isInteger(minutes) || minutes < 15 || minutes > 120) { + throw new Error('--budget-minutes must be an integer from 15 through 120'); + } + options.budgetMinutes = minutes; + } else { + const key = name.replace(/-([a-z])/gu, (_, letter) => letter.toUpperCase()); + options[key] = value; + } + } + return options; +} + +export function buildCodexEnvironment(environment = process.env) { + const allowed = [ + 'CODEX_HOME', + 'HOME', + 'LANG', + 'LC_ALL', + 'LOGNAME', + 'PATH', + 'SHELL', + 'SSL_CERT_DIR', + 'SSL_CERT_FILE', + 'TERM', + 'TMPDIR', + 'USER', + ]; + return Object.fromEntries( + allowed + .filter((name) => typeof environment[name] === 'string') + .map((name) => [name, environment[name]]) + ); +} + +export function buildValidationEnvironment(home, environment = process.env) { + const allowed = ['LANG', 'LC_ALL', 'PATH', 'SHELL', 'SSL_CERT_DIR', 'SSL_CERT_FILE', 'TERM']; + return { + ...Object.fromEntries( + allowed + .filter((name) => typeof environment[name] === 'string') + .map((name) => [name, environment[name]]) + ), + CI: '1', + HOME: home, + TMPDIR: resolve(home, 'tmp'), + }; +} + +export function buildCodexExecArgs({ model, root, schemaPath, outputPath }) { + return [ + 'exec', + '--ephemeral', + '--ignore-user-config', + '--sandbox', + 'workspace-write', + '--model', + model, + '--config', + 'model_reasoning_effort="high"', + '--config', + 'sandbox_workspace_write.network_access=false', + '--config', + 'shell_environment_policy.include_only=["PATH"]', + '--config', + 'shell_environment_policy.ignore_default_excludes=false', + '--output-schema', + schemaPath, + '--output-last-message', + outputPath, + '--cd', + root, + '-', + ]; +} + +export function validationCommandPlan(candidateId) { + if (!/^LODY-[A-Z0-9-]+-\d{3}$/u.test(candidateId)) throw new Error('Invalid candidate id'); + const focused = [1, 2, 3].map((round) => ({ + name: `focused-${round}`, + command: 'pnpm', + args: [ + '--filter', + '@lody/e2e', + 'exec', + 'cucumber-js', + '--config', + 'cucumber.mjs', + '--tags', + `@${candidateId}`, + ], + round, + })); + return [ + { + name: 'submodules', + command: 'git', + args: ['submodule', 'update', '--init', '--recursive'], + }, + { name: 'install', command: 'pnpm', args: ['install', '--frozen-lockfile'] }, + { name: 'contract', command: 'pnpm', args: ['--filter', '@lody/e2e', 'check'] }, + { name: 'build', command: 'pnpm', args: ['--dir', 'apps/electron', 'build'] }, + ...focused, + { name: 'full', command: 'pnpm', args: ['--filter', '@lody/e2e', 'full'] }, + { name: 'diff-check', command: 'git', args: ['diff', '--check'] }, + ]; +} + +function runCapture(command, args, { cwd, environment = process.env, timeout } = {}) { + const result = spawnSync(command, args, { + cwd, + env: environment, + encoding: 'utf8', + maxBuffer: 10_000_000, + timeout, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + const detail = [result.stdout, result.stderr].filter(Boolean).join('\n').trim(); + throw new Error(`${command} ${args.join(' ')} failed${detail ? `:\n${detail}` : ''}`); + } + return result.stdout.trim(); +} + +async function runLogged(command, args, { cwd, environment = process.env, logPath, timeout }) { + await mkdir(dirname(logPath), { recursive: true }); + const output = createWriteStream(logPath, { flags: 'w', mode: 0o600 }); + return await new Promise((resolvePromise, rejectPromise) => { + const child = spawn(command, args, { + cwd, + env: environment, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let tail = ''; + let timedOut = false; + const append = (chunk, target) => { + target.write(chunk); + output.write(chunk); + tail = `${tail}${chunk.toString('utf8')}`.slice(-100_000); + }; + child.stdout.on('data', (chunk) => append(chunk, process.stdout)); + child.stderr.on('data', (chunk) => append(chunk, process.stderr)); + child.on('error', (error) => { + output.end(); + rejectPromise(error); + }); + let forceTimer; + const timer = setTimeout(() => { + timedOut = true; + child.kill('SIGTERM'); + forceTimer = setTimeout(() => child.kill('SIGKILL'), 5_000); + }, timeout); + child.on('close', (code, signal) => { + clearTimeout(timer); + clearTimeout(forceTimer); + output.end(); + resolvePromise({ code, signal, tail, timedOut }); + }); + }); +} + +function assertSuccess(result, stage) { + if (result.code !== 0 || result.timedOut) { + throw new Error(`${stage} failed${result.timedOut ? ' after reaching its time limit' : ''}`); + } +} + +function parseRepository(remote) { + const match = remote.match(/(?:github\.com[/:])([^/]+\/[^/.]+)(?:\.git)?$/u); + return match?.[1] ?? 'LodyAI/Lody'; +} + +async function readStringList(path) { + if (!path) return []; + const body = await readFile(resolve(path), 'utf8'); + if (body.trim().startsWith('[')) { + const parsed = JSON.parse(body); + if (!Array.isArray(parsed) || parsed.some((entry) => typeof entry !== 'string')) { + throw new Error(`${path} must contain a JSON string array or one value per line`); + } + return [...new Set(parsed)].sort(); + } + return [ + ...new Set( + body + .split(/\r?\n/u) + .map((entry) => entry.trim()) + .filter(Boolean) + ), + ].sort(); +} + +function recentChangedFiles(root) { + const body = runCapture('git', ['log', '--format=', '--name-only', '-n', '20', 'HEAD'], { + cwd: root, + }); + return [ + ...new Set( + body + .split(/\r?\n/u) + .map((entry) => entry.trim()) + .filter(Boolean) + ), + ].sort(); +} + +function sha256(value) { + return createHash('sha256').update(value).digest('hex'); +} + +export function assertCandidatePathSet(status, expectedPaths) { + const actualPaths = status + .split('\0') + .filter(Boolean) + .map((entry) => entry.slice(3)) + .sort(); + const expected = [...expectedPaths].sort(); + if (JSON.stringify(actualPaths) !== JSON.stringify(expected)) { + throw new Error( + `Validated worktree paths differ from the candidate: expected ${expected.join(', ')}, found ${actualPaths.join(', ')}` + ); + } +} + +async function verifyCandidateFiles(root, candidate) { + const status = runCapture('git', ['status', '--porcelain=v1', '-z', '--untracked-files=all'], { + cwd: root, + }); + assertCandidatePathSet( + status, + candidate.files.map((file) => file.path) + ); + for (const file of candidate.files) { + const body = await readFile(resolve(root, file.path)); + if (body.length !== file.bytes || sha256(body) !== file.sha256) { + throw new Error(`Validated file no longer matches its candidate digest: ${file.path}`); + } + } +} + +async function createTask(root, options, runId) { + const registry = loadJourneyRegistry(resolve(root, 'e2e/journeys/registry.json')); + const baseSha = runCapture('git', ['rev-parse', 'HEAD'], { cwd: root }); + const remote = runCapture('git', ['remote', 'get-url', 'origin'], { cwd: root }); + return createJourneyAuthorTask({ + registry, + excludedCandidateIds: await readStringList(options.excluded), + requestedCandidateId: options.candidate, + budgetMinutes: options.budgetMinutes, + repository: parseRepository(remote), + baseRef: runCapture('git', ['branch', '--show-current'], { cwd: root }) || 'HEAD', + baseSha, + runId, + trigger: 'local-maintainer', + now: Date.now(), + signals: { + changedFiles: options.changedFiles + ? await readStringList(options.changedFiles) + : recentChangedFiles(root), + escapedDefectIds: await readStringList(options.escapedDefects), + scoutJourneys: await readStringList(options.scoutJourneys), + }, + }); +} + +async function assertToolchain(root) { + const nodeMajor = Number.parseInt(process.versions.node.split('.')[0], 10); + if (!Number.isInteger(nodeMajor) || nodeMajor < 22) { + throw new Error(`Node.js 22 or newer is required; found ${process.versions.node}`); + } + const manifest = JSON.parse(await readFile(resolve(root, 'package.json'), 'utf8')); + const expectedPnpm = /^pnpm@([^+]+)(?:\+|$)/u.exec(manifest.packageManager)?.[1]; + const actualPnpm = runCapture('pnpm', ['--version'], { cwd: root }); + if (!expectedPnpm || actualPnpm !== expectedPnpm) { + throw new Error(`pnpm ${expectedPnpm ?? ''} is required; found ${actualPnpm}`); + } +} + +async function acquireLock(root, runId) { + const gitPath = runCapture('git', ['rev-parse', '--git-path', 'lody-journey-author.lock'], { + cwd: root, + }); + const lockPath = resolve(root, gitPath); + await mkdir(dirname(lockPath), { recursive: true }); + let handle; + try { + handle = await open(lockPath, 'wx', 0o600); + } catch (error) { + if (error?.code === 'EEXIST') { + throw new Error(`Another local journey author owns ${lockPath}`, { cause: error }); + } + throw error; + } + await handle.writeFile(`${JSON.stringify({ runId, pid: process.pid })}\n`); + await handle.close(); + return async () => + await unlink(lockPath).catch((error) => { + if (error?.code !== 'ENOENT') throw error; + }); +} + +function assertControlledArtifactPath(root, artifactRoot) { + const controlledRoot = resolve(root, 'e2e/artifacts/journey-author'); + const controlledRelative = relative(controlledRoot, artifactRoot); + if ( + controlledRelative === '' || + controlledRelative === '..' || + controlledRelative.startsWith(`..${sep}`) || + controlledRelative.includes(sep) + ) { + throw new Error('Artifacts must be a direct child of e2e/artifacts/journey-author'); + } + return controlledRoot; +} + +async function assertArtifactDirectoriesAreReal(root, artifactRoot) { + for (const path of [ + resolve(root, 'e2e/artifacts'), + resolve(root, 'e2e/artifacts/journey-author'), + artifactRoot, + ]) { + const stat = await lstat(path); + if (stat.isSymbolicLink()) { + throw new Error(`Journey artifact directory must not be a symbolic link: ${path}`); + } + } +} + +export async function createArtifactRoot(root, requestedPath, runId) { + const artifactsRoot = resolve(root, 'e2e/artifacts'); + const artifactRoot = requestedPath + ? resolve(requestedPath) + : resolve(artifactsRoot, 'journey-author', runId); + const controlledRoot = assertControlledArtifactPath(root, artifactRoot); + try { + if ((await lstat(artifactsRoot)).isSymbolicLink()) { + throw new Error(`Journey artifact directory must not be a symbolic link: ${artifactsRoot}`); + } + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + await mkdir(controlledRoot, { recursive: true }); + for (const path of [artifactsRoot, controlledRoot]) { + if ((await lstat(path)).isSymbolicLink()) { + throw new Error(`Journey artifact directory must not be a symbolic link: ${path}`); + } + } + + await mkdir(dirname(artifactRoot), { recursive: true }); + try { + await mkdir(artifactRoot, { mode: 0o700 }); + } catch (error) { + if (error?.code === 'EEXIST') { + throw new Error(`Journey artifact directory already exists: ${artifactRoot}`, { + cause: error, + }); + } + throw error; + } + return artifactRoot; +} + +async function invokeCodex({ root, task, outputPath, model, timeout }) { + const login = spawnSync('codex', ['login', 'status'], { + cwd: root, + env: buildCodexEnvironment(), + stdio: 'ignore', + }); + if (login.error?.code === 'ENOENT') throw new Error('Codex CLI is not installed or not on PATH'); + if (login.error) throw login.error; + if (login.status !== 0) throw new Error('Codex is not authenticated; run `codex login` first'); + + const schemaPath = resolve(root, 'e2e/journeys/author-result.schema.json'); + const args = buildCodexExecArgs({ model, root, schemaPath, outputPath }); + const child = spawn('codex', args, { + cwd: root, + env: buildCodexEnvironment(), + stdio: ['pipe', 'inherit', 'inherit'], + }); + child.stdin.end(`${JSON.stringify(task, null, 2)}\n`); + await new Promise((resolvePromise, rejectPromise) => { + let timedOut = false; + let forceTimer; + const timer = setTimeout(() => { + timedOut = true; + child.kill('SIGTERM'); + forceTimer = setTimeout(() => child.kill('SIGKILL'), 5_000); + }, timeout); + child.on('error', (error) => { + clearTimeout(timer); + clearTimeout(forceTimer); + rejectPromise(error); + }); + child.on('close', (code) => { + clearTimeout(timer); + clearTimeout(forceTimer); + if (code === 0) resolvePromise(); + else { + rejectPromise( + new Error( + timedOut + ? `Codex author exceeded its ${task.claim.budgetMinutes}-minute lease` + : `Codex author exited with status ${code ?? 'unknown'}` + ) + ); + } + }); + }); +} + +async function validateLocally({ root, task, candidate, artifactRoot, environment, timeout }) { + const checks = {}; + try { + const plan = validationCommandPlan(task.candidate.id); + for (const stage of plan.slice(0, 4)) { + const result = await runLogged(stage.command, stage.args, { + cwd: root, + environment, + logPath: resolve(artifactRoot, `${stage.name}.log`), + timeout, + }); + checks[stage.name] = result.code === 0 && !result.timedOut ? 'passed' : 'failed'; + assertSuccess(result, stage.name); + } + + await applyAblation({ root, task, candidate }); + let counterfactual; + try { + counterfactual = await runLogged( + 'pnpm', + [ + '--filter', + '@lody/e2e', + 'exec', + 'cucumber-js', + '--config', + 'cucumber.mjs', + '--tags', + `@${task.candidate.id}`, + ], + { + cwd: root, + environment: { + ...environment, + LODY_ACCEPTANCE_ROUND_ID: `${task.runId}-counterfactual`, + }, + logPath: resolve(artifactRoot, 'counterfactual.log'), + timeout, + } + ); + } finally { + await restoreAblation({ root, task, candidate }); + } + if ( + counterfactual.code === 0 || + counterfactual.timedOut || + !counterfactual.tail.includes(candidate.ablation.expectedFailure) + ) { + checks.counterfactual = 'failed'; + throw new Error('Counterfactual did not fail at the declared checkpoint'); + } + checks.counterfactual = 'passed'; + + for (const stage of plan.slice(4)) { + const stageEnvironment = stage.round + ? { + ...environment, + LODY_ACCEPTANCE_ROUND_ID: `${task.runId}-${stage.round}`, + } + : environment; + const result = await runLogged(stage.command, stage.args, { + cwd: root, + environment: stageEnvironment, + logPath: resolve(artifactRoot, `${stage.name}.log`), + timeout, + }); + checks[stage.name] = result.code === 0 && !result.timedOut ? 'passed' : 'failed'; + assertSuccess(result, stage.name); + } + await verifyCandidateFiles(root, candidate); + checks.hashes = 'passed'; + return checks; + } catch (error) { + error.checks = checks; + throw error; + } +} + +export function parseLocalValidationOptions(argv) { + const options = { approveReviewed: false }; + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (token === '--') continue; + if (token === '--help') { + options.help = true; + continue; + } + if (token === '--approve-reviewed') { + options.approveReviewed = true; + continue; + } + if (token !== '--artifact-dir' && token !== '--budget-minutes') { + throw new Error(`Unknown option: ${token}`); + } + const value = argv[index + 1]; + if (!value || value.startsWith('--')) throw new Error(`${token} requires a value`); + index += 1; + if (token === '--artifact-dir') options.artifactDir = value; + else { + const minutes = Number.parseInt(value, 10); + if (!Number.isInteger(minutes) || minutes < 15 || minutes > 120) { + throw new Error('--budget-minutes must be an integer from 15 through 120'); + } + options.budgetMinutes = minutes; + } + } + return options; +} + +function renderWorktreePatch(root, candidate) { + runCapture('git', ['add', '--intent-to-add', '--', ...candidate.files.map((file) => file.path)], { + cwd: root, + }); + return runCapture( + 'git', + ['diff', '--no-ext-diff', '--binary', '--', ...candidate.files.map((file) => file.path)], + { cwd: root } + ); +} + +async function writeReviewBundle(root, artifactRoot, task, candidate) { + const reviewRoot = resolve(artifactRoot, 'review'); + for (const file of candidate.files) { + const path = resolve(reviewRoot, file.path); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, file.content, { mode: 0o600 }); + } + const patch = renderWorktreePatch(root, candidate); + await writeFile(resolve(artifactRoot, 'candidate.patch'), `${patch}\n`, { mode: 0o600 }); + const review = [ + `# Review ${task.candidate.id}`, + '', + task.candidate.title, + '', + 'Review every file and the declared counterfactual before approving local execution.', + '', + ...candidate.files.map((file) => `- \`${file.path}\` (${file.bytes} bytes)`), + '', + `Counterfactual file: \`${candidate.ablation.path}\``, + '', + 'After review:', + '', + '```bash', + `pnpm e2e:journey:validate -- --artifact-dir ${JSON.stringify(artifactRoot)} --approve-reviewed`, + '```', + '', + ].join('\n'); + await writeFile(resolve(artifactRoot, 'REVIEW.md'), review, { mode: 0o600 }); +} + +function validationUsage() { + return [ + 'Usage: pnpm e2e:journey:validate -- --artifact-dir PATH --approve-reviewed', + '', + ' --artifact-dir PATH Ready author bundle to validate', + ' --approve-reviewed Confirm every generated file was reviewed', + ' --budget-minutes N Per-command timeout, 15-120 (default: task lease)', + ].join('\n'); +} + +function usage() { + return [ + 'Usage: pnpm e2e:journey:author -- [options]', + '', + ' --candidate ID Registry id, or next (default: next)', + ' --model MODEL Local Codex model (default: gpt-5.6-sol)', + ' --budget-minutes N Author and command timeout, 15-120 (default: 90)', + ' --prepare-only Claim and write a task without invoking Codex', + ' --changed-files PATH JSON array or newline-delimited ranking signal', + ' --escaped-defects PATH JSON array or newline-delimited registry ids', + ' --scout-journeys PATH JSON array or newline-delimited Scout journey names', + ' --excluded PATH JSON array or newline-delimited claimed registry ids', + ' --artifact-dir PATH Override the ignored local evidence directory', + ].join('\n'); +} + +export async function main(argv = process.argv.slice(2), root = defaultRepositoryRoot) { + const options = parseLocalAuthorOptions(argv); + if (options.help) { + process.stdout.write(`${usage()}\n`); + return; + } + await assertToolchain(root); + const status = runCapture('git', ['status', '--porcelain=v1', '--untracked-files=all'], { + cwd: root, + }); + if (status) throw new Error('Local journey author requires a clean worktree'); + + const runId = `local-${new Date() + .toISOString() + .replaceAll(/[^0-9]/gu, '') + .slice(0, 14)}-${randomUUID().slice(0, 8)}`; + const artifactRoot = await createArtifactRoot(root, options.artifactDir, runId); + const releaseLock = await acquireLock(root, runId); + let temporaryRoot; + let authorWorktree; + try { + const task = await createTask(root, options, runId); + await writeFile(resolve(artifactRoot, 'task.json'), `${JSON.stringify(task, null, 2)}\n`, { + mode: 0o600, + }); + if (task.disposition === 'queue-empty') { + process.stdout.write(`Journey queue is empty. Task: ${resolve(artifactRoot, 'task.json')}\n`); + return; + } + process.stdout.write(`Claimed ${task.candidate.id}: ${task.candidate.title}\n`); + if (options.prepareOnly) { + process.stdout.write(`Prepared task: ${resolve(artifactRoot, 'task.json')}\n`); + return; + } + + temporaryRoot = await mkdtemp(join(tmpdir(), 'lody-journey-author-')); + authorWorktree = resolve(temporaryRoot, 'worktree'); + runCapture('git', ['worktree', 'add', '--detach', authorWorktree, task.baseSha], { cwd: root }); + const finalMessagePath = resolve(artifactRoot, 'author-result.json'); + try { + await invokeCodex({ + root: authorWorktree, + task, + outputPath: finalMessagePath, + model: options.model, + timeout: options.budgetMinutes * 60_000, + }); + } catch (error) { + await writeFile(resolve(artifactRoot, 'author-error.txt'), `${error.message}\n`, { + mode: 0o600, + }); + await writeFile( + finalMessagePath, + `${JSON.stringify({ + status: 'blocked', + failureClass: 'infra', + summary: error.message, + ablation: null, + })}\n`, + { mode: 0o600 } + ); + } + const candidate = await packageCandidate({ + root: authorWorktree, + task, + finalMessage: await readFile(finalMessagePath, 'utf8'), + }); + await writeFile( + resolve(artifactRoot, 'candidate.json'), + `${JSON.stringify(candidate, null, 2)}\n`, + { + mode: 0o600, + } + ); + if (candidate.status !== 'ready') { + process.exitCode = 2; + process.stdout.write( + `Candidate ${task.candidate.id} is blocked (${candidate.classification.code}). Evidence: ${artifactRoot}\n` + ); + return; + } + + await writeReviewBundle(authorWorktree, artifactRoot, task, candidate); + process.stdout.write( + `Candidate ${task.candidate.id} is ready for human review. No generated code was executed.\nReview: ${resolve(artifactRoot, 'REVIEW.md')}\n` + ); + } finally { + if (authorWorktree) { + spawnSync('git', ['worktree', 'remove', '--force', authorWorktree], { + cwd: root, + stdio: 'ignore', + }); + } + if (temporaryRoot) await rm(temporaryRoot, { recursive: true, force: true }); + await releaseLock(); + } +} + +export async function validateMain(argv = process.argv.slice(2), root = defaultRepositoryRoot) { + const options = parseLocalValidationOptions(argv); + if (options.help) { + process.stdout.write(`${validationUsage()}\n`); + return; + } + if (!options.artifactDir) throw new Error('--artifact-dir is required'); + if (!options.approveReviewed) { + throw new Error('Review REVIEW.md and candidate.patch, then pass --approve-reviewed'); + } + await assertToolchain(root); + if (runCapture('git', ['status', '--porcelain=v1', '--untracked-files=all'], { cwd: root })) { + throw new Error('Local journey validation requires a clean worktree'); + } + + const artifactRoot = resolve(options.artifactDir); + assertControlledArtifactPath(root, artifactRoot); + await assertArtifactDirectoriesAreReal(root, artifactRoot); + const task = JSON.parse(await readFile(resolve(artifactRoot, 'task.json'), 'utf8')); + const candidate = JSON.parse(await readFile(resolve(artifactRoot, 'candidate.json'), 'utf8')); + if (runCapture('git', ['rev-parse', 'HEAD'], { cwd: root }) !== task.baseSha) { + throw new Error(`Candidate base ${task.baseSha} does not match current HEAD`); + } + + const releaseLock = await acquireLock(root, `${task.runId}-validate`); + let temporaryRoot; + let validationRoot; + const attestation = { + schemaVersion: 1, + kind: 'lody-e2e-local-journey-attestation', + status: 'failed', + candidateId: task.candidate?.id ?? null, + baseSha: task.baseSha, + taskDigest: task.digest, + candidateDigest: candidate.digest, + checks: {}, + startedAt: new Date().toISOString(), + finishedAt: null, + }; + let patchPath; + try { + temporaryRoot = await mkdtemp(join(tmpdir(), 'lody-journey-validation-')); + validationRoot = resolve(temporaryRoot, 'worktree'); + const validationHome = resolve(temporaryRoot, 'home'); + await mkdir(resolve(validationHome, 'tmp'), { recursive: true }); + const environment = buildValidationEnvironment(validationHome); + runCapture('git', ['worktree', 'add', '--detach', validationRoot, task.baseSha], { cwd: root }); + await validateAndApplyCandidate({ root: validationRoot, task, candidate }); + const promoted = await promoteCandidate({ root: validationRoot, task, candidate }); + await writeFile( + resolve(artifactRoot, 'candidate.promoted.json'), + `${JSON.stringify(promoted, null, 2)}\n`, + { mode: 0o600 } + ); + attestation.candidateDigest = promoted.digest; + patchPath = resolve(artifactRoot, 'validated.patch'); + await writeFile(patchPath, `${renderWorktreePatch(validationRoot, promoted)}\n`, { + mode: 0o600, + }); + try { + attestation.checks = await validateLocally({ + root: validationRoot, + task, + candidate: promoted, + artifactRoot, + environment, + timeout: (options.budgetMinutes ?? task.claim.budgetMinutes) * 60_000, + }); + } catch (error) { + attestation.checks = error.checks ?? attestation.checks; + throw error; + } + + attestation.checks.patchReady = 'passed'; + attestation.status = 'passed'; + } catch (error) { + attestation.error = error.message; + throw error; + } finally { + attestation.finishedAt = new Date().toISOString(); + const digest = sha256(JSON.stringify(attestation)); + try { + await writeFile( + resolve(artifactRoot, 'attestation.json'), + `${JSON.stringify({ ...attestation, digest }, null, 2)}\n`, + { mode: 0o600 } + ); + } finally { + if (validationRoot) { + spawnSync('git', ['worktree', 'remove', '--force', validationRoot], { + cwd: root, + stdio: 'ignore', + }); + } + if (temporaryRoot) await rm(temporaryRoot, { recursive: true, force: true }); + await releaseLock(); + } + } + if (runCapture('git', ['rev-parse', 'HEAD'], { cwd: root }) !== task.baseSha) { + throw new Error('Current HEAD changed during isolated validation'); + } + if (runCapture('git', ['status', '--porcelain=v1', '--untracked-files=all'], { cwd: root })) { + throw new Error('Current worktree changed during isolated validation'); + } + runCapture('git', ['apply', '--check', patchPath], { cwd: root }); + runCapture('git', ['apply', patchPath], { cwd: root }); + process.stdout.write( + `Validated and applied ${task.candidate.id}. Review the local diff, then commit and open a normal PR.\nEvidence: ${artifactRoot}\n` + ); +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) { + await main(); +} diff --git a/e2e/scripts/run-journey-author.test.mjs b/e2e/scripts/run-journey-author.test.mjs new file mode 100644 index 000000000..9a5039c07 --- /dev/null +++ b/e2e/scripts/run-journey-author.test.mjs @@ -0,0 +1,186 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { + assertCandidatePathSet, + buildCodexEnvironment, + buildCodexExecArgs, + buildValidationEnvironment, + createArtifactRoot, + parseLocalAuthorOptions, + parseLocalValidationOptions, + validateMain, + validationCommandPlan, +} from './run-journey-author.mjs'; + +void test('keeps author artifacts out of repository source paths and refuses overwrite', async () => { + const root = await mkdtemp(join(tmpdir(), 'lody-author-artifacts-')); + try { + await assert.rejects( + createArtifactRoot(root, join(root, 'e2e/src/support/fixtures'), 'run-1'), + /must be a direct child/u + ); + await assert.rejects( + createArtifactRoot(root, join(tmpdir(), 'outside'), 'run-1'), + /direct child/u + ); + const artifactRoot = await createArtifactRoot(root, undefined, 'run-1'); + assert.equal(artifactRoot, join(root, 'e2e/artifacts/journey-author/run-1')); + await assert.rejects(createArtifactRoot(root, undefined, 'run-1'), /already exists/u); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +void test('rejects validation side effects outside the packaged candidate', () => { + assert.doesNotThrow(() => + assertCandidatePathSet(' M e2e/journeys/registry.json\0?? e2e/src/features/mcp.feature\0', [ + 'e2e/src/features/mcp.feature', + 'e2e/journeys/registry.json', + ]) + ); + assert.throws( + () => + assertCandidatePathSet(' M e2e/journeys/registry.json\0?? packages/components/leak.ts\0', [ + 'e2e/journeys/registry.json', + ]), + /paths differ/u + ); +}); + +void test('requires an explicit human-review acknowledgement for validation', () => { + assert.deepEqual( + parseLocalValidationOptions([ + '--', + '--artifact-dir', + '/tmp/candidate', + '--approve-reviewed', + '--budget-minutes', + '60', + ]), + { artifactDir: '/tmp/candidate', approveReviewed: true, budgetMinutes: 60 } + ); + assert.throws(() => parseLocalValidationOptions(['--candidate', 'LODY-MCP-001']), /Unknown/u); +}); + +void test('refuses validation before touching a checkout without human acknowledgement', async () => { + await assert.rejects( + validateMain(['--artifact-dir', '/tmp/unreviewed'], '/tmp/not-a-repository'), + /approve-reviewed/u + ); +}); + +void test('parses bounded local author options', () => { + assert.deepEqual( + parseLocalAuthorOptions([ + '--', + '--candidate', + 'LODY-MCP-001', + '--budget-minutes', + '45', + '--model', + 'gpt-5.6-sol', + '--prepare-only', + ]), + { + candidate: 'LODY-MCP-001', + budgetMinutes: 45, + model: 'gpt-5.6-sol', + prepareOnly: true, + } + ); + assert.throws(() => parseLocalAuthorOptions(['--budget-minutes', '5']), /15 through 120/u); + assert.throws(() => parseLocalAuthorOptions(['--publish']), /Unknown option/u); +}); + +void test('passes only local authentication prerequisites to Codex', () => { + const environment = buildCodexEnvironment({ + HOME: '/maintainer', + PATH: '/bin', + CODEX_HOME: '/maintainer/.codex', + OPENAI_API_KEY: 'must-not-pass', + GH_TOKEN: 'must-not-pass', + LODY_SECRET: 'must-not-pass', + }); + assert.deepEqual(environment, { + CODEX_HOME: '/maintainer/.codex', + HOME: '/maintainer', + PATH: '/bin', + }); +}); + +void test('validates reviewed code with an isolated home and no caller secrets', () => { + const environment = buildValidationEnvironment('/tmp/validation-home', { + HOME: '/maintainer', + PATH: '/bin', + CODEX_HOME: '/maintainer/.codex', + OPENAI_API_KEY: 'must-not-pass', + GH_TOKEN: 'must-not-pass', + }); + assert.deepEqual(environment, { + PATH: '/bin', + CI: '1', + HOME: '/tmp/validation-home', + TMPDIR: '/tmp/validation-home/tmp', + }); +}); + +void test('runs the author ephemerally in a writable isolated worktree', () => { + const args = buildCodexExecArgs({ + model: 'gpt-5.6-sol', + root: '/tmp/author', + schemaPath: '/tmp/author/schema.json', + outputPath: '/tmp/evidence/result.json', + }); + assert.deepEqual(args, [ + 'exec', + '--ephemeral', + '--ignore-user-config', + '--sandbox', + 'workspace-write', + '--model', + 'gpt-5.6-sol', + '--config', + 'model_reasoning_effort="high"', + '--config', + 'sandbox_workspace_write.network_access=false', + '--config', + 'shell_environment_policy.include_only=["PATH"]', + '--config', + 'shell_environment_policy.ignore_default_excludes=false', + '--output-schema', + '/tmp/author/schema.json', + '--output-last-message', + '/tmp/evidence/result.json', + '--cd', + '/tmp/author', + '-', + ]); +}); + +void test('keeps three independent focused rounds between build and full regression', () => { + const plan = validationCommandPlan('LODY-MCP-001'); + assert.deepEqual( + plan.map((stage) => stage.name), + [ + 'submodules', + 'install', + 'contract', + 'build', + 'focused-1', + 'focused-2', + 'focused-3', + 'full', + 'diff-check', + ] + ); + assert.deepEqual( + plan.filter((stage) => stage.round).map((stage) => stage.round), + [1, 2, 3] + ); + assert.ok(plan.slice(1, -1).every((stage) => stage.command === 'pnpm')); + assert.equal(plan.find((stage) => stage.name === 'focused-1').args.at(-1), '@LODY-MCP-001'); +}); diff --git a/e2e/scripts/select-journey-candidate.mjs b/e2e/scripts/select-journey-candidate.mjs new file mode 100644 index 000000000..b4320e5eb --- /dev/null +++ b/e2e/scripts/select-journey-candidate.mjs @@ -0,0 +1,82 @@ +import { readFileSync } from 'node:fs'; +import { loadJourneyRegistry, selectJourneyCandidate } from './journey-registry.mjs'; + +function parseArguments(argv) { + const options = { changedFiles: [], escapedDefectIds: [], scoutJourneys: [], json: false }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--') continue; + if (argument === '--json') { + options.json = true; + continue; + } + if (argument === '--changed-files') { + const path = argv[index + 1]; + if (!path) throw new Error('--changed-files requires a newline-delimited file path'); + options.changedFiles = readFileSync(path, 'utf8') + .split(/\r?\n/gu) + .map((entry) => entry.trim()) + .filter(Boolean); + index += 1; + continue; + } + if (argument === '--escaped-defects') { + const path = argv[index + 1]; + if (!path) throw new Error('--escaped-defects requires a newline-delimited journey id file'); + options.escapedDefectIds = readFileSync(path, 'utf8') + .split(/\r?\n/gu) + .map((entry) => entry.trim()) + .filter(Boolean); + index += 1; + continue; + } + if (argument === '--scout-summary') { + const path = argv[index + 1]; + if (!path) throw new Error('--scout-summary requires a Scout summary.json path'); + const summary = JSON.parse(readFileSync(path, 'utf8')); + if (summary?.schemaVersion !== 1 || !Array.isArray(summary.suspectedTrends)) { + throw new Error('--scout-summary must use the Scout summary schema version 1'); + } + options.scoutJourneys = [ + ...new Set( + summary.suspectedTrends + .map((trend) => trend?.journey) + .filter((journey) => typeof journey === 'string' && journey !== '') + ), + ].sort(); + index += 1; + continue; + } + throw new Error(`Unknown argument: ${argument}`); + } + return options; +} + +const options = parseArguments(process.argv.slice(2)); +const result = selectJourneyCandidate(loadJourneyRegistry(), options); +const report = { + schemaVersion: 1, + inputs: { + changedFiles: options.changedFiles, + escapedDefectIds: options.escapedDefectIds, + scoutJourneys: options.scoutJourneys, + }, + ...result, +}; +if (options.json) { + console.log(JSON.stringify(report, null, 2)); +} else if (result.selected) { + console.log(`Selected: ${result.selected.id} - ${result.selected.title}`); + console.log(`Owner: ${result.selected.owner}`); + console.log(`Score: ${result.selected.score}`); + console.log(`Fingerprint: ${result.selected.fingerprint}`); + console.log('Score breakdown:'); + for (const [signal, score] of Object.entries(result.selected.breakdown)) { + console.log(`- ${signal}: ${score}`); + } + for (const blocked of result.skippedBlocked) { + console.log(`Skipped blocked: ${blocked.id} - ${blocked.reason}`); + } +} else { + console.log('No eligible journey candidate.'); +} diff --git a/e2e/scripts/validate-journey-candidate.mjs b/e2e/scripts/validate-journey-candidate.mjs new file mode 100644 index 000000000..5b7c28f84 --- /dev/null +++ b/e2e/scripts/validate-journey-candidate.mjs @@ -0,0 +1,8 @@ +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { validateMain } from './run-journey-author.mjs'; + +if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) { + await validateMain(); +} diff --git a/e2e/src/features/README.md b/e2e/src/features/README.md new file mode 100644 index 000000000..bf4ec0a96 --- /dev/null +++ b/e2e/src/features/README.md @@ -0,0 +1,8 @@ +# Feature index + +The active suite contains 4 scenarios: 3 `@P0` smoke journeys and 1 `@P1` deeper journey. + +| Feature | Scope | +| -------------------- | --------------------------------------------------------------------------------- | +| `onboarding.feature` | Real Electron cold start, bundled CLI bootstrap, local catalog, and product entry | +| `lifecycle.feature` | Session Stop, large Review, and Work/Terminal cleanup against a scripted ACP | diff --git a/e2e/src/features/lifecycle.feature b/e2e/src/features/lifecycle.feature new file mode 100644 index 000000000..c3a84e5bc --- /dev/null +++ b/e2e/src/features/lifecycle.feature @@ -0,0 +1,25 @@ +# language: zh-CN + +功能: 桌面资源生命周期 + + @lody @P0 @essence @runtime-simulator @LODY-SESSION-001 + 场景: 用户停止并关闭一个真实 ACP Session + 假如 已配置确定性 Agent 的隔离桌面 + 当 用户创建一个持续运行的 Session + 并且 用户停止当前 Agent + 那么 关闭 Session 后 Agent 进程被释放 + + @lody @P1 @essence @runtime-simulator @LODY-REVIEW-001 + 场景: 用户反复查看和隐藏大型本地 diff + 假如 已配置确定性 Agent 的隔离桌面 + 并且 已注册包含大型变更的合成项目 + 当 用户创建 Session 并打开全部变更 + 并且 用户切换大型 diff 并隐藏再恢复 Review + 那么 关闭 Review 和 Session 后相关视图被释放 + + @lody @P0 @essence @runtime-simulator @LODY-WORK-001 + 场景: 用户删除带 ACP 和 Terminal 的 worktree Session + 假如 已配置确定性 Agent 的隔离桌面 + 并且 已添加干净的合成 Git 项目 + 当 用户创建 worktree Session 并启动 Terminal + 那么 永久删除后 Work 进程、终端和 worktree 被释放 diff --git a/e2e/src/features/onboarding.feature b/e2e/src/features/onboarding.feature new file mode 100644 index 000000000..3e1f2be14 --- /dev/null +++ b/e2e/src/features/onboarding.feature @@ -0,0 +1,9 @@ +# language: zh-CN +@lody @P0 @essence @runtime-none @LODY-ONBOARDING-001 +功能: 本地桌面首次启动 + + 场景: 新用户通过真实 bundled CLI 进入本地 workspace + 假如 一个全新隔离的 Lody Desktop 已启动 + 那么 bundled CLI 拥有本地 runtime 并完成 workspace 初始化 + 当 用户跳过 Agent 配置并进入本地 workspace + 那么 真实产品会话输入界面可用 diff --git a/e2e/src/scout/scout-analysis.test.ts b/e2e/src/scout/scout-analysis.test.ts new file mode 100644 index 000000000..857de9cca --- /dev/null +++ b/e2e/src/scout/scout-analysis.test.ts @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + analyzeScoutCheckpoints, + isSustainedPostGcGrowth, + ordinaryLeastSquaresSlope, + theilSenSlopeAtIterations, +} from './scout-analysis.js'; +import type { RuntimeSnapshot } from '../support/resource-probe.js'; + +function snapshot(cliResidentSetBytes: number): RuntimeSnapshot { + return { + capturedAt: '2026-01-01T00:00:00.000Z', + kind: 'post-gc', + main: { + heapUsedBytes: 100, + heapTotalBytes: 100, + privateBytes: 100, + residentSetBytes: 100, + }, + electronProcesses: [], + processTree: [ + { + pid: 2, + parentPid: 1, + kind: 'bundled-cli', + cpuPercent: 0, + residentSetBytes: cliResidentSetBytes, + }, + ], + renderer: { + domNodes: 10, + documents: 1, + eventListeners: 10, + jsHeapUsedBytes: 100, + jsHeapTotalBytes: 100, + layoutCount: 0, + recalcStyleCount: 0, + taskDurationSeconds: 0, + longTaskCount: 0, + longTaskDurationMs: 0, + paintCount: 0, + }, + }; +} + +void describe('Scout trend classification', () => { + void it('requires repeated post-GC growth rather than one high-water mark', () => { + assert.equal( + isSustainedPostGcGrowth( + { + samples: 5, + first: 100, + last: 160, + netChange: 60, + slopePerCheckpoint: 15, + positiveDeltaRatio: 1, + nonDecreasingDeltaRatio: 1, + }, + 20 + ), + true + ); + assert.equal( + isSustainedPostGcGrowth( + { + samples: 5, + first: 100, + last: 105, + netChange: 5, + slopePerCheckpoint: 10, + positiveDeltaRatio: 0.75, + nonDecreasingDeltaRatio: 0.75, + }, + 20 + ), + false + ); + assert.equal( + isSustainedPostGcGrowth( + { + samples: 5, + first: 1024 * 1024 * 1024, + last: 1072 * 1024 * 1024, + netChange: 48 * 1024 * 1024, + slopePerCheckpoint: 12 * 1024 * 1024, + positiveDeltaRatio: 1, + nonDecreasingDeltaRatio: 1, + }, + 16 * 1024 * 1024 + ), + true + ); + }); + + void it('treats plateaus as consistent with sustained growth', () => { + assert.equal( + isSustainedPostGcGrowth( + { + samples: 6, + first: 100, + last: 400, + netChange: 300, + slopePerCheckpoint: 60, + positiveDeltaRatio: 0.6, + nonDecreasingDeltaRatio: 1, + }, + 100 + ), + true + ); + }); + + void it('keeps CLI working-set growth observational without hiding its trend', () => { + const checkpoints = [1, 2, 3, 4].map((iteration) => { + const value = iteration * 16 * 1024 * 1024; + return { + journey: 'session' as const, + iteration, + phase: 'measure' as const, + active: snapshot(value), + postGc: snapshot(value), + }; + }); + const result = analyzeScoutCheckpoints(checkpoints); + const cli = result.metrics.find((metric) => metric.metric === 'cli.residentSetBytes'); + + assert.equal(cli?.analysisKind, 'observational'); + assert.equal(cli?.postGc.netChange, 48 * 1024 * 1024); + assert.equal(cli?.suspected, false); + assert.deepEqual(result.suspectedTrends, []); + }); +}); + +void describe('ordinaryLeastSquaresSlope', () => { + void it('keeps an explicit comparison estimator for ablation', () => { + assert.equal(ordinaryLeastSquaresSlope([10, 20, 30, 40]), 10); + assert.equal(ordinaryLeastSquaresSlope([10]), 0); + }); +}); + +void describe('theilSenSlopeAtIterations', () => { + void it('normalizes uneven checkpoints to one user journey', () => { + assert.equal(theilSenSlopeAtIterations([100, 150, 200, 220], [5, 10, 15, 17]), 10); + assert.equal(theilSenSlopeAtIterations([100], [5]), 0); + assert.throws(() => theilSenSlopeAtIterations([100, 110], [5])); + }); +}); diff --git a/e2e/src/scout/scout-analysis.ts b/e2e/src/scout/scout-analysis.ts new file mode 100644 index 000000000..00f5cf8dc --- /dev/null +++ b/e2e/src/scout/scout-analysis.ts @@ -0,0 +1,332 @@ +import { + summarizeTrend, + type RuntimeSnapshot, + type TrendSummary, +} from '../support/resource-probe.js'; + +const MEBIBYTE = 1024 * 1024; + +export type ScoutJourney = 'session' | 'review' | 'work'; + +export type ScoutCheckpoint = { + journey: ScoutJourney; + iteration: number; + phase: 'warmup' | 'measure'; + active: RuntimeSnapshot; + postGc: RuntimeSnapshot; +}; + +export type ScoutMetricSummary = { + metric: string; + unit: 'bytes' | 'count' | 'percent' | 'milliseconds' | 'seconds'; + analysisKind: 'post-gc-candidate' | 'observational'; + active: ScoutTrendSummary; + postGc: ScoutTrendSummary; + suspected: boolean; +}; + +export type SuspectedTrend = { + journey: ScoutJourney; + metric: string; + trend: ScoutTrendSummary; + reason: string; +}; + +export type ScoutTrendSummary = TrendSummary & { + slopePerIteration: number; + relativeNetChange: number; +}; + +type MetricDefinition = { + name: string; + unit: ScoutMetricSummary['unit']; + minimumNetChange: number; + leakSignal: boolean; + analysisKind?: ScoutMetricSummary['analysisKind']; + read: (snapshot: RuntimeSnapshot) => number; +}; + +function sumProcessMetric( + snapshot: RuntimeSnapshot, + kinds: ReadonlySet, + field: 'residentSetBytes' | 'cpuPercent' +): number { + return snapshot.processTree + .filter((process) => kinds.has(process.kind)) + .reduce((total, process) => total + process[field], 0); +} + +function countProcesses(snapshot: RuntimeSnapshot, kinds?: ReadonlySet): number { + return kinds + ? snapshot.processTree.filter((process) => kinds.has(process.kind)).length + : snapshot.processTree.length; +} + +const CLI_KINDS = new Set(['bundled-cli']); +const AGENT_KINDS = new Set(['agent-runtime']); +const RENDERER_KINDS = new Set(['renderer']); + +const METRICS: readonly MetricDefinition[] = [ + { + name: 'main.heapUsedBytes', + unit: 'bytes', + minimumNetChange: 4 * MEBIBYTE, + leakSignal: true, + read: (snapshot) => snapshot.main.heapUsedBytes, + }, + { + name: 'main.privateBytes', + unit: 'bytes', + minimumNetChange: 16 * MEBIBYTE, + leakSignal: true, + read: (snapshot) => snapshot.main.privateBytes ?? snapshot.main.residentSetBytes, + }, + { + name: 'main.residentSetBytes', + unit: 'bytes', + minimumNetChange: 16 * MEBIBYTE, + leakSignal: true, + read: (snapshot) => snapshot.main.residentSetBytes, + }, + { + name: 'renderer.residentSetBytes', + unit: 'bytes', + minimumNetChange: 16 * MEBIBYTE, + leakSignal: true, + read: (snapshot) => sumProcessMetric(snapshot, RENDERER_KINDS, 'residentSetBytes'), + }, + { + name: 'renderer.jsHeapUsedBytes', + unit: 'bytes', + minimumNetChange: 4 * MEBIBYTE, + leakSignal: true, + read: (snapshot) => snapshot.renderer.jsHeapUsedBytes ?? 0, + }, + { + name: 'renderer.domNodes', + unit: 'count', + minimumNetChange: 100, + leakSignal: true, + read: (snapshot) => snapshot.renderer.domNodes, + }, + { + name: 'renderer.documents', + unit: 'count', + minimumNetChange: 1, + leakSignal: true, + read: (snapshot) => snapshot.renderer.documents, + }, + { + name: 'renderer.eventListeners', + unit: 'count', + minimumNetChange: 50, + leakSignal: true, + read: (snapshot) => snapshot.renderer.eventListeners, + }, + { + name: 'cli.residentSetBytes', + unit: 'bytes', + minimumNetChange: 8 * MEBIBYTE, + leakSignal: false, + analysisKind: 'observational', + read: (snapshot) => sumProcessMetric(snapshot, CLI_KINDS, 'residentSetBytes'), + }, + { + name: 'cli.cpuPercent', + unit: 'percent', + minimumNetChange: 0, + leakSignal: false, + read: (snapshot) => sumProcessMetric(snapshot, CLI_KINDS, 'cpuPercent'), + }, + { + name: 'agent.residentSetBytes', + unit: 'bytes', + minimumNetChange: 8 * MEBIBYTE, + leakSignal: true, + read: (snapshot) => sumProcessMetric(snapshot, AGENT_KINDS, 'residentSetBytes'), + }, + { + name: 'agent.cpuPercent', + unit: 'percent', + minimumNetChange: 0, + leakSignal: false, + read: (snapshot) => sumProcessMetric(snapshot, AGENT_KINDS, 'cpuPercent'), + }, + { + name: 'process.count', + unit: 'count', + minimumNetChange: 1, + leakSignal: true, + read: (snapshot) => countProcesses(snapshot), + }, + { + name: 'agent.processCount', + unit: 'count', + minimumNetChange: 1, + leakSignal: true, + read: (snapshot) => countProcesses(snapshot, AGENT_KINDS), + }, + { + name: 'renderer.layoutCount', + unit: 'count', + minimumNetChange: 0, + leakSignal: false, + read: (snapshot) => snapshot.renderer.layoutCount ?? 0, + }, + { + name: 'renderer.recalcStyleCount', + unit: 'count', + minimumNetChange: 0, + leakSignal: false, + read: (snapshot) => snapshot.renderer.recalcStyleCount ?? 0, + }, + { + name: 'renderer.taskDurationSeconds', + unit: 'seconds', + minimumNetChange: 0, + leakSignal: false, + read: (snapshot) => snapshot.renderer.taskDurationSeconds ?? 0, + }, + { + name: 'renderer.longTaskCount', + unit: 'count', + minimumNetChange: 0, + leakSignal: false, + read: (snapshot) => snapshot.renderer.longTaskCount, + }, + { + name: 'renderer.longTaskDurationMs', + unit: 'milliseconds', + minimumNetChange: 0, + leakSignal: false, + read: (snapshot) => snapshot.renderer.longTaskDurationMs, + }, + { + name: 'renderer.paintCount', + unit: 'count', + minimumNetChange: 0, + leakSignal: false, + read: (snapshot) => snapshot.renderer.paintCount, + }, +]; + +export function isSustainedPostGcGrowth(trend: TrendSummary, minimumNetChange: number): boolean { + if (trend.samples < 4 || trend.first === null || trend.slopePerCheckpoint <= 0) return false; + return trend.netChange >= minimumNetChange && trend.nonDecreasingDeltaRatio >= 0.75; +} + +export function theilSenSlopeAtIterations( + values: readonly number[], + iterations: readonly number[] +): number { + if (values.length !== iterations.length) { + throw new Error('Scout values and iteration coordinates must have equal length'); + } + const slopes: number[] = []; + for (let left = 0; left < values.length; left += 1) { + for (let right = left + 1; right < values.length; right += 1) { + const iterationDelta = iterations[right]! - iterations[left]!; + if (iterationDelta > 0) { + slopes.push((values[right]! - values[left]!) / iterationDelta); + } + } + } + if (slopes.length === 0) return 0; + const sorted = [...slopes].sort((left, right) => left - right); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 ? (sorted[middle - 1]! + sorted[middle]!) / 2 : sorted[middle]!; +} + +function summarizeScoutTrend( + values: readonly number[], + iterations: readonly number[] +): ScoutTrendSummary { + const trend = summarizeTrend(values); + return { + ...trend, + slopePerIteration: theilSenSlopeAtIterations(values, iterations), + relativeNetChange: + trend.first && trend.first > 0 ? trend.netChange / trend.first : trend.netChange > 0 ? 1 : 0, + }; +} + +export function analyzeScoutCheckpoints(checkpoints: readonly ScoutCheckpoint[]): { + metrics: ScoutMetricSummary[]; + suspectedTrends: Omit[]; +} { + const measured = checkpoints.filter((checkpoint) => checkpoint.phase === 'measure'); + const iterations = measured.map((checkpoint) => checkpoint.iteration); + const metrics = METRICS.map((definition): ScoutMetricSummary => { + const active = summarizeScoutTrend( + measured.map((checkpoint) => definition.read(checkpoint.active)), + iterations + ); + const postGc = summarizeScoutTrend( + measured.map((checkpoint) => definition.read(checkpoint.postGc)), + iterations + ); + return { + metric: definition.name, + unit: definition.unit, + analysisKind: + definition.analysisKind ?? (definition.leakSignal ? 'post-gc-candidate' : 'observational'), + active, + postGc, + suspected: + definition.leakSignal && isSustainedPostGcGrowth(postGc, definition.minimumNetChange), + }; + }); + return { + metrics, + suspectedTrends: metrics + .filter((metric) => metric.suspected) + .map((metric) => ({ + metric: metric.metric, + trend: metric.postGc, + reason: 'post-GC baseline rose consistently after warmup', + })), + }; +} + +export function ordinaryLeastSquaresSlope(values: readonly number[]): number { + if (values.length < 2) return 0; + const centerX = (values.length - 1) / 2; + const centerY = values.reduce((total, value) => total + value, 0) / values.length; + let numerator = 0; + let denominator = 0; + for (let index = 0; index < values.length; index += 1) { + numerator += (index - centerX) * (values[index]! - centerY); + denominator += (index - centerX) ** 2; + } + return denominator === 0 ? 0 : numerator / denominator; +} + +export function buildAblationReport(checkpoints: readonly ScoutCheckpoint[]): object { + const postGc = checkpoints.map((checkpoint) => checkpoint.postGc); + const variants = [ + { name: 'raw-every-1', drop: 0, stride: 1 }, + { name: 'warmup-2-every-1', drop: 2, stride: 1 }, + { name: 'warmup-3-every-1', drop: 3, stride: 1 }, + { name: 'warmup-3-every-2', drop: 3, stride: 2 }, + { name: 'warmup-3-every-5', drop: 3, stride: 5 }, + ]; + return { + variants: variants.map((variant) => ({ + ...variant, + metrics: METRICS.filter((definition) => definition.minimumNetChange > 0).map((definition) => { + const values = postGc + .slice(variant.drop) + .filter((_snapshot, index) => index % variant.stride === 0) + .map(definition.read); + return { + metric: definition.name, + samples: values.length, + theilSenSlopePerIteration: summarizeTrend(values).slopePerCheckpoint / variant.stride, + ordinaryLeastSquaresSlopePerIteration: ordinaryLeastSquaresSlope(values) / variant.stride, + positiveDeltaRatio: summarizeTrend(values).positiveDeltaRatio, + nonDecreasingDeltaRatio: summarizeTrend(values).nonDecreasingDeltaRatio, + }; + }), + })), + }; +} diff --git a/e2e/src/scout/scout-runner.ts b/e2e/src/scout/scout-runner.ts new file mode 100644 index 000000000..00262a244 --- /dev/null +++ b/e2e/src/scout/scout-runner.ts @@ -0,0 +1,367 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { OnboardingPage } from '../support/pages/onboarding-page.js'; +import { ReviewPage } from '../support/pages/review-page.js'; +import { SCRIPTED_AGENT_NAME, SessionPage } from '../support/pages/session-page.js'; +import { WorkSessionPage } from '../support/pages/work-session-page.js'; +import { + createSyntheticReviewRepository, + PRIMARY_REVIEW_DIFF_PATH, + SECONDARY_REVIEW_DIFF_PATH, + type SyntheticReviewRepository, +} from '../support/fixtures/synthetic-review-repository.js'; +import { + WorkSessionFixture, + type ScriptedAcpEvent, +} from '../support/fixtures/work-session-fixture.js'; +import { ElectronHarness } from '../support/electron-harness.js'; +import type { ScenarioArtifacts } from '../support/world-utils.js'; +import { + analyzeScoutCheckpoints, + buildAblationReport, + type ScoutCheckpoint, + type ScoutJourney, + type SuspectedTrend, +} from './scout-analysis.js'; + +type ScoutOptions = { + journeys: ScoutJourney[]; + iterations: number; + warmup: number; + checkpointEvery: number; + ablation: boolean; +}; + +type JourneyResult = { + journey: ScoutJourney; + status: 'passed' | 'failed'; + durationMs: number; + checkpoints: ScoutCheckpoint[]; + metrics: ReturnType['metrics']; + suspectedTrends: SuspectedTrend[]; + error?: string; +}; + +function readIntegerOption(name: string, fallback: number): number { + const position = process.argv.indexOf(name); + if (position === -1) return fallback; + const value = Number(process.argv[position + 1]); + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`${name} must be a non-negative integer`); + } + return value; +} + +function parseOptions(): ScoutOptions { + const ablation = process.argv.includes('--ablation'); + const journeyPosition = process.argv.indexOf('--journey'); + const requestedJourney = journeyPosition === -1 ? 'all' : process.argv[journeyPosition + 1]; + if (!['all', 'session', 'review', 'work'].includes(requestedJourney ?? '')) { + throw new Error('--journey must be one of: all, session, review, work'); + } + const journeys: ScoutJourney[] = + requestedJourney === 'all' ? ['session', 'review', 'work'] : [requestedJourney as ScoutJourney]; + const iterations = readIntegerOption('--iterations', ablation ? 12 : 30); + const warmup = readIntegerOption('--warmup', ablation ? 0 : 3); + const checkpointEvery = readIntegerOption('--checkpoint-every', ablation ? 1 : 5); + if (iterations < 1) throw new Error('--iterations must be at least 1'); + if (checkpointEvery < 1) throw new Error('--checkpoint-every must be at least 1'); + const checkpointCount = Math.ceil(iterations / checkpointEvery); + if (checkpointCount < 4) { + throw new Error( + `Scout requires at least 4 measured checkpoints; received ${checkpointCount} from ${iterations} iterations sampled every ${checkpointEvery}` + ); + } + return { journeys, iterations, warmup, checkpointEvery, ablation }; +} + +function createRoundId(): string { + return `${new Date().toISOString().replaceAll(':', '-').replaceAll('.', '-')}-${randomUUID().slice(0, 8)}`; +} + +function errorText(error: unknown): string { + return error instanceof Error ? (error.stack ?? error.message) : String(error); +} + +async function setupJourney( + journey: ScoutJourney, + roundRoot: string +): Promise<{ + artifacts: ScenarioArtifacts; + harness: ElectronHarness; + fixture: WorkSessionFixture; + session: SessionPage; + review: ReviewPage; + work: WorkSessionPage; +}> { + const journeyDir = join(roundRoot, journey); + mkdirSync(journeyDir, { recursive: true }); + const artifacts: ScenarioArtifacts = { + rootDir: roundRoot, + scenarioDir: journeyDir, + stableId: `SCOUT-${journey.toUpperCase()}`, + }; + const harness = new ElectronHarness(artifacts); + let fixture: WorkSessionFixture | null = null; + try { + await harness.launch(); + if (!harness.page) throw new Error('Electron did not open a main window'); + const onboarding = new OnboardingPage(harness.page); + await onboarding.waitForLocalBootstrap(); + fixture = await WorkSessionFixture.create(join(journeyDir, 'scripted-acp.ndjson')); + const session = new SessionPage(harness.page, fixture); + const review = new ReviewPage(harness.page); + const work = new WorkSessionPage(harness.page); + await onboarding.skipConfigurationAndEnterProduct(); + await session.configureCustomAgentFromSettings(); + return { artifacts, harness, fixture, session, review, work }; + } catch (error) { + if (harness.page) { + await harness.page + .screenshot({ path: join(journeyDir, 'failure.png'), fullPage: true }) + .catch(() => undefined); + } + try { + harness.writeDiagnostics(); + } catch { + // Preserve the launch failure; diagnostics are best-effort evidence. + } + await harness.close().catch(() => undefined); + fixture?.dispose(); + throw error; + } +} + +async function runSessionIteration( + iteration: number, + session: SessionPage, + captureActive?: () => Promise +): Promise { + const prompt = await session.createHeldSession( + `Scout Session lifecycle ${iteration} [SCOUT:HOLD]` + ); + const active = captureActive ? await captureActive() : null; + await session.stopHeldSession(prompt); + await session.archiveAndDeleteSession(prompt); + return active; +} + +async function runReviewIteration( + iteration: number, + session: SessionPage, + review: ReviewPage, + repository: SyntheticReviewRepository, + captureActive?: () => Promise +): Promise { + const prompt = await session.createCompletedSession( + `Scout Review lifecycle ${iteration} [SCOUT:REPLY]` + ); + await review.openChangesPanel(repository.changedPaths); + await review.openChangedFile(PRIMARY_REVIEW_DIFF_PATH, repository.changedPaths); + await review.hide(); + await review.show(); + await review.openChangedFile(SECONDARY_REVIEW_DIFF_PATH, repository.changedPaths); + const active = captureActive ? await captureActive() : null; + await review.closeDiffViewer(); + await review.closeChangesPanel(); + await session.archiveAndDeleteSession(prompt); + return active; +} + +async function runWorkIteration( + iteration: number, + fixture: WorkSessionFixture, + work: WorkSessionPage, + captureActive?: () => Promise +): Promise { + await work.enableWorktree(); + const priorPromptEnds = fixture + .readAcpEvents() + .filter((event) => event.event === 'prompt-end').length; + await work.startSession(`Scout Work lifecycle ${iteration} [SCOUT:REPLY]`); + const completed = await fixture.waitForAcpEvent('prompt-end', priorPromptEnds + 1); + const prompt = completed.at(-1) as ScriptedAcpEvent; + const marker = `lody-scout-terminal-${iteration}`; + await work.openTerminalAndRun(`printf '${marker}\\n'`, marker); + const resources = await work.captureResources(); + const active = captureActive ? await captureActive() : null; + await work.archiveAndDeletePermanently(resources); + await work.expectResourcesReleased(resources, [prompt.pid]); + return active; +} + +async function runJourney( + journey: ScoutJourney, + options: ScoutOptions, + roundRoot: string +): Promise { + const startedAt = Date.now(); + const checkpoints: ScoutCheckpoint[] = []; + let harness: ElectronHarness | null = null; + let fixture: WorkSessionFixture | null = null; + let reviewRepository: SyntheticReviewRepository | null = null; + let analysis: ReturnType = { + metrics: [], + suspectedTrends: [], + }; + let failure: string | undefined; + + try { + const context = await setupJourney(journey, roundRoot); + harness = context.harness; + fixture = context.fixture; + + if (journey === 'review') { + reviewRepository = createSyntheticReviewRepository(); + const project = await context.review.registerLocalProject(reviewRepository.rootPath); + await context.work.selectLocalProject(project.name); + } else if (journey === 'work') { + await context.work.addLocalProject(fixture.projectRoot, fixture.projectName); + await context.work.selectAgent(SCRIPTED_AGENT_NAME); + } + + const totalIterations = options.warmup + options.iterations; + for (let run = 1; run <= totalIterations; run += 1) { + const phase = run <= options.warmup ? 'warmup' : 'measure'; + const measuredIteration = run - options.warmup; + const captureAblationWarmup = options.ablation && phase === 'warmup'; + const checkpointDue = + phase === 'measure' && + (measuredIteration % options.checkpointEvery === 0 || + measuredIteration === options.iterations); + const captureActive = + captureAblationWarmup || checkpointDue ? () => harness!.captureSnapshot() : undefined; + let active: ScoutCheckpoint['active'] | null; + if (journey === 'session') { + active = await runSessionIteration(run, context.session, captureActive); + } else if (journey === 'review') { + active = await runReviewIteration( + run, + context.session, + context.review, + reviewRepository!, + captureActive + ); + } else { + active = await runWorkIteration(run, fixture, context.work, captureActive); + } + + if (captureAblationWarmup || checkpointDue) { + if (!active) throw new Error(`Scout ${journey} checkpoint did not capture active state`); + const postGc = await harness.capturePostGcSnapshot(); + checkpoints.push({ + journey, + iteration: phase === 'warmup' ? run : measuredIteration, + phase, + active, + postGc, + }); + } + process.stdout.write( + `[scout:${journey}] ${phase} ${phase === 'warmup' ? run : measuredIteration}/${ + phase === 'warmup' ? options.warmup : options.iterations + }\n` + ); + } + + analysis = analyzeScoutCheckpoints(checkpoints); + const suspectedTrends = analysis.suspectedTrends.map((trend) => ({ journey, ...trend })); + if (suspectedTrends.length > 0) { + await harness.captureHeapSnapshots(join(harness.artifacts.scenarioDir, 'heap')); + await harness.stopTrace(join(harness.artifacts.scenarioDir, 'trace.zip')); + } + } catch (error) { + failure = errorText(error); + if (harness?.page) { + await harness.page + .screenshot({ path: join(harness.artifacts.scenarioDir, 'failure.png'), fullPage: true }) + .catch(() => undefined); + await harness + .captureHeapSnapshots(join(harness.artifacts.scenarioDir, 'heap')) + .catch(() => undefined); + await harness + .stopTrace(join(harness.artifacts.scenarioDir, 'trace.zip')) + .catch(() => undefined); + } + } finally { + if (harness) { + try { + harness.writeDiagnostics(); + } catch (error) { + failure = `${failure ? `${failure}\n` : ''}diagnostics: ${errorText(error)}`; + } + await harness.close().catch((error) => { + failure = `${failure ? `${failure}\n` : ''}teardown: ${errorText(error)}`; + }); + } + reviewRepository?.cleanup(); + fixture?.dispose(); + } + + const suspectedTrends = analysis.suspectedTrends.map((trend) => ({ journey, ...trend })); + const result: JourneyResult = { + journey, + status: failure ? 'failed' : 'passed', + durationMs: Date.now() - startedAt, + checkpoints, + metrics: analysis.metrics, + suspectedTrends, + ...(failure ? { error: failure } : {}), + }; + const journeyDir = join(roundRoot, journey); + mkdirSync(journeyDir, { recursive: true }); + writeFileSync(join(journeyDir, 'scout-result.json'), `${JSON.stringify(result, null, 2)}\n`); + if (options.ablation) { + writeFileSync( + join(journeyDir, 'ablation.json'), + `${JSON.stringify(buildAblationReport(checkpoints), null, 2)}\n` + ); + } + return result; +} + +async function main(): Promise { + const options = parseOptions(); + if (options.ablation && options.journeys.length !== 1) { + throw new Error('Ablation runs exactly one journey; pass --journey session, review, or work'); + } + const roundId = createRoundId(); + const roundRoot = resolve(process.cwd(), 'artifacts', 'scout', roundId); + mkdirSync(roundRoot, { recursive: true }); + const journeys: JourneyResult[] = []; + for (const journey of options.journeys) { + journeys.push(await runJourney(journey, options, roundRoot)); + } + const suspectedTrends = journeys.flatMap((journey) => journey.suspectedTrends); + const summary = { + schemaVersion: 1, + roundId, + createdAt: new Date().toISOString(), + options, + journeys: journeys.map(({ checkpoints: _checkpoints, ...journey }) => journey), + suspectedTrends, + }; + writeFileSync(join(roundRoot, 'summary.json'), `${JSON.stringify(summary, null, 2)}\n`); + process.stdout.write( + `${JSON.stringify( + { + roundRoot, + journeys: journeys.map((journey) => ({ + journey: journey.journey, + status: journey.status, + durationMs: journey.durationMs, + checkpoints: journey.checkpoints.length, + suspectedTrends: journey.suspectedTrends.length, + ...(journey.error ? { error: journey.error } : {}), + })), + suspectedTrends, + }, + null, + 2 + )}\n` + ); + if (journeys.some((journey) => journey.status === 'failed')) process.exitCode = 1; +} + +await main(); diff --git a/e2e/src/steps/README.md b/e2e/src/steps/README.md new file mode 100644 index 000000000..a638b7c89 --- /dev/null +++ b/e2e/src/steps/README.md @@ -0,0 +1,6 @@ +# Step index + +| Step file | Responsibility | +| --------------------- | ------------------------------------------------------------------------------- | +| `onboarding.steps.ts` | Maps the first-run feature to the onboarding Page Object and runtime assertions | +| `lifecycle.steps.ts` | Drives deterministic Session, Review, and Work resource lifecycles | diff --git a/e2e/src/steps/lifecycle.steps.ts b/e2e/src/steps/lifecycle.steps.ts new file mode 100644 index 000000000..21d51d3b3 --- /dev/null +++ b/e2e/src/steps/lifecycle.steps.ts @@ -0,0 +1,85 @@ +import { Given, Then, When } from '@cucumber/cucumber'; +import { expect } from '@playwright/test'; +import type { LodyWorld } from '../support/world.js'; +import { + createSyntheticReviewRepository, + PRIMARY_REVIEW_DIFF_PATH, + SECONDARY_REVIEW_DIFF_PATH, +} from '../support/fixtures/synthetic-review-repository.js'; + +Given('已配置确定性 Agent 的隔离桌面', async function (this: LodyWorld) { + await this.configureScriptedAgent(); +}); + +When('用户创建一个持续运行的 Session', async function (this: LodyWorld) { + this.activeAcpEvent = await this.sessionPage!.createHeldSession(); +}); + +When('用户停止当前 Agent', async function (this: LodyWorld) { + await this.sessionPage!.stopHeldSession(this.activeAcpEvent!); +}); + +Then('关闭 Session 后 Agent 进程被释放', async function (this: LodyWorld) { + await this.sessionPage!.archiveAndDeleteSession(this.activeAcpEvent!); + await this.harness!.capturePostGcSnapshot(); +}); + +Given('已注册包含大型变更的合成项目', async function (this: LodyWorld) { + this.reviewFixture = createSyntheticReviewRepository(); + const project = await this.reviewPage!.registerLocalProject(this.reviewFixture.rootPath); + await this.workPage!.selectLocalProject(project.name); +}); + +When('用户创建 Session 并打开全部变更', async function (this: LodyWorld) { + this.activeAcpEvent = await this.sessionPage!.createCompletedSession(); + await this.reviewPage!.openChangesPanel(this.reviewFixture!.changedPaths); +}); + +When('用户切换大型 diff 并隐藏再恢复 Review', async function (this: LodyWorld) { + await this.reviewPage!.openChangedFile( + PRIMARY_REVIEW_DIFF_PATH, + this.reviewFixture!.changedPaths + ); + await this.reviewPage!.hide(); + await this.reviewPage!.show(); + await this.reviewPage!.openChangedFile( + SECONDARY_REVIEW_DIFF_PATH, + this.reviewFixture!.changedPaths + ); +}); + +Then('关闭 Review 和 Session 后相关视图被释放', async function (this: LodyWorld) { + await this.reviewPage!.closeDiffViewer(); + await this.reviewPage!.closeChangesPanel(); + await this.sessionPage!.archiveAndDeleteSession(this.activeAcpEvent!); + await this.harness!.capturePostGcSnapshot(); +}); + +Given('已添加干净的合成 Git 项目', async function (this: LodyWorld) { + await this.workPage!.addLocalProject( + this.workFixture!.projectRoot, + this.workFixture!.projectName + ); + await this.workPage!.selectAgent('Deterministic E2E Agent'); +}); + +When('用户创建 worktree Session 并启动 Terminal', async function (this: LodyWorld) { + await this.workPage!.enableWorktree(); + const promptEnds = this.workFixture!.readAcpEvents().filter( + (event) => event.event === 'prompt-end' + ).length; + await this.workPage!.startSession('Exercise Work lifecycle [SCOUT:REPLY]'); + const completed = await this.workFixture!.waitForAcpEvent('prompt-end', promptEnds + 1); + this.activeAcpEvent = completed.at(-1)!; + await this.workPage!.openTerminalAndRun("printf 'lody-terminal-ready\\n'", 'lody-terminal-ready'); + this.workResources = await this.workPage!.captureResources(); +}); + +Then('永久删除后 Work 进程、终端和 worktree 被释放', async function (this: LodyWorld) { + await this.workPage!.archiveAndDeletePermanently(this.workResources!); + await this.workPage!.expectResourcesReleased(this.workResources!, [this.activeAcpEvent!.pid]); + expect(this.workFixture!.readAcpEvents()).toContainEqual( + expect.objectContaining({ event: 'prompt-end', stopReason: 'end_turn' }) + ); + await this.harness!.capturePostGcSnapshot(); +}); diff --git a/e2e/src/steps/onboarding.steps.ts b/e2e/src/steps/onboarding.steps.ts new file mode 100644 index 000000000..87474aebf --- /dev/null +++ b/e2e/src/steps/onboarding.steps.ts @@ -0,0 +1,21 @@ +import { Given, Then, When } from '@cucumber/cucumber'; +import { expect } from '@playwright/test'; +import type { LodyWorld } from '../support/world.js'; + +Given('一个全新隔离的 Lody Desktop 已启动', async function (this: LodyWorld) { + expect(await this.harness?.page?.evaluate(() => window.__LODY_ELECTRON__)).toBe(true); +}); + +Then('bundled CLI 拥有本地 runtime 并完成 workspace 初始化', async function (this: LodyWorld) { + const state = await this.onboarding!.waitForLocalBootstrap(); + expect(state.cli?.runtime?.pid).toEqual(expect.any(Number)); +}); + +When('用户跳过 Agent 配置并进入本地 workspace', async function (this: LodyWorld) { + await this.onboarding!.skipConfigurationAndEnterProduct(); +}); + +Then('真实产品会话输入界面可用', async function (this: LodyWorld) { + await expect(this.harness!.page!.locator('#chat-prompt')).toBeEditable(); + await this.harness!.captureSnapshot(); +}); diff --git a/e2e/src/support/README.md b/e2e/src/support/README.md new file mode 100644 index 000000000..e687071b2 --- /dev/null +++ b/e2e/src/support/README.md @@ -0,0 +1,15 @@ +# Harness map + +| Component | Responsibility | +| ----------------------------------------- | ------------------------------------------------------------------- | +| `electron-harness.ts` | Isolated Electron/CLI process lifecycle, logs, traces, and teardown | +| `hooks.ts` | Scenario evidence retention policy | +| `resource-probe.ts` | Structured main, renderer, DOM, CPU, and memory snapshots | +| `world.ts` | Cucumber adapter for the shared harness | +| `world-utils.ts` | Stable artifact paths, port reservation, and cleanup assertions | +| `fixtures/synthetic-review-repository.ts` | Deterministic large Git diff fixture | +| `pages/onboarding-page.ts` | First-run user interaction and local bootstrap contract | +| `pages/review-page.ts` | Review-panel project setup and observable diff interactions | +| `pages/session-page.ts` | Deterministic ACP conversation and Stop lifecycle | +| `pages/work-session-page.ts` | Worktree Session, terminal, deletion, and cleanup contract | +| `fixtures/work-session-fixture.ts` | Synthetic Git workspace and scripted ACP evidence | diff --git a/e2e/src/support/electron-harness.ts b/e2e/src/support/electron-harness.ts new file mode 100644 index 000000000..721474de4 --- /dev/null +++ b/e2e/src/support/electron-harness.ts @@ -0,0 +1,376 @@ +import { + closeSync, + existsSync, + fsyncSync, + mkdirSync, + mkdtempSync, + openSync, + readFileSync, + rmSync, + writeSync, + writeFileSync, +} from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { _electron, type CDPSession, type ElectronApplication, type Page } from '@playwright/test'; +import { + assertNamedPipeReleased, + assertTcpPortReleased, + reserveTcpPort, + type ScenarioArtifacts, +} from './world-utils.js'; +import { + collectPostGcRuntimeSnapshot, + collectRuntimeSnapshot, + type RuntimeSnapshot, +} from './resource-probe.js'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const ELECTRON_DIR = join(ROOT, 'apps', 'electron'); +const MAIN_ENTRY = join(ELECTRON_DIR, 'out', 'main', 'index.js'); +const BUNDLED_CLI_ENTRY = join(ELECTRON_DIR, 'resources', 'cli', 'index.js'); +const requireFromElectron = createRequire(join(ELECTRON_DIR, 'package.json')); +const TEARDOWN_OPERATION_TIMEOUT_MS = 20_000; + +async function boundedTeardown(label: string, operation: Promise): Promise { + let timeout: NodeJS.Timeout | undefined; + const deadline = new Promise((_resolve, reject) => { + timeout = setTimeout( + () => reject(new Error(`${label} exceeded ${TEARDOWN_OPERATION_TIMEOUT_MS}ms`)), + TEARDOWN_OPERATION_TIMEOUT_MS + ); + timeout.unref(); + }); + try { + return await Promise.race([operation, deadline]); + } finally { + if (timeout) clearTimeout(timeout); + } +} + +function resolveElectronExecutable(): string { + const electronPackageDir = dirname(requireFromElectron.resolve('electron/package.json')); + const relativePath = readFileSync(join(electronPackageDir, 'path.txt'), 'utf8').trim(); + return join(electronPackageDir, 'dist', relativePath); +} + +type LogRecord = { + at: string; + source: 'electron-main' | 'renderer' | 'page' | 'request'; + level: string; + message: string; +}; + +const INHERITED_ENV_ALLOWLIST = [ + 'APPDATA', + 'DBUS_SESSION_BUS_ADDRESS', + 'DISPLAY', + 'HOME', + 'LOCALAPPDATA', + 'PATH', + 'PATHEXT', + 'SHELL', + 'SystemRoot', + 'TEMP', + 'TMP', + 'TMPDIR', + 'USER', + 'USERPROFILE', + 'WAYLAND_DISPLAY', + 'WINDIR', + 'XDG_RUNTIME_DIR', +] as const; + +function createIsolatedEnvironment(overrides: Record): Record { + const env: Record = {}; + for (const name of INHERITED_ENV_ALLOWLIST) { + const value = process.env[name]; + if (value !== undefined) env[name] = value; + } + return { ...env, ...overrides }; +} + +export class ElectronHarness { + app: ElectronApplication | null = null; + page: Page | null = null; + readonly logs: LogRecord[] = []; + readonly snapshots: RuntimeSnapshot[] = []; + private tempRoot: string | null = null; + private hostPort: number | null = null; + private hostPipe: string | null = null; + private traceStarted = false; + private performanceSession: CDPSession | null = null; + private rendererPaintCount = 0; + + constructor(readonly artifacts: ScenarioArtifacts) {} + + async launch(): Promise { + if (!existsSync(MAIN_ENTRY) || !existsSync(BUNDLED_CLI_ENTRY)) { + throw new Error( + 'Desktop E2E artifacts are missing. Run `pnpm e2e:build` before launching scenarios.' + ); + } + + const tempBase = process.platform === 'win32' ? tmpdir() : '/tmp'; + this.tempRoot = mkdtempSync(join(tempBase, 'lody-e2e-')); + const electronUserDataDir = join(this.tempRoot, 'electron-user-data'); + const lodyDataDir = join(this.tempRoot, 'lody-data'); + mkdirSync(electronUserDataDir, { recursive: true }); + mkdirSync(lodyDataDir, { recursive: true }); + if (process.platform === 'win32') { + this.hostPipe = `\\\\.\\pipe\\lody-e2e-${randomUUID()}`; + } else { + this.hostPort = await reserveTcpPort(); + } + + const env = createIsolatedEnvironment({ + LANG: 'en_US.UTF-8', + LC_ALL: 'en_US.UTF-8', + LODY_DATA_DIR: lodyDataDir, + LODY_E2E: '1', + ...(this.hostPort !== null ? { LODY_E2E_LOCAL_CLI_HOST_PORT: String(this.hostPort) } : {}), + ...(this.hostPipe ? { LODY_E2E_LOCAL_CLI_HOST_PIPE: this.hostPipe } : {}), + LODY_ELECTRON_DISABLE_SHELL_ENV: '1', + LODY_ELECTRON_DISABLE_SYSTEM_PROXY_ENV: '1', + LODY_ELECTRON_FORCE_ONBOARDING: '1', + LODY_ELECTRON_USER_DATA_DIR: electronUserDataDir, + NODE_ENV: 'test', + }); + this.app = await _electron.launch({ + args: [ + '--js-flags=--expose-gc', + MAIN_ENTRY, + `--user-data-dir=${electronUserDataDir}`, + '--lang=en-US', + ], + cwd: ELECTRON_DIR, + env, + executablePath: resolveElectronExecutable(), + timeout: 60_000, + }); + const childProcess = this.app.process(); + childProcess.stdout?.on('data', (chunk) => + this.record('electron-main', 'stdout', String(chunk)) + ); + childProcess.stderr?.on('data', (chunk) => + this.record('electron-main', 'stderr', String(chunk)) + ); + + const bootState = await this.app.evaluate(({ app, BrowserWindow }) => ({ + appReady: app.isReady(), + diagnostic: ( + globalThis as typeof globalThis & { + __LODY_E2E_BOOT_DIAGNOSTIC__?: { stage: string; error?: string }; + } + ).__LODY_E2E_BOOT_DIAGNOSTIC__, + rendererCount: BrowserWindow.getAllWindows().length, + userDataPath: app.getPath('userData'), + })); + this.record('electron-main', 'boot-state', JSON.stringify(bootState)); + if (bootState.diagnostic?.stage === 'failed') { + throw new Error( + `Electron main boot failed:\n${bootState.diagnostic.error ?? 'unknown error'}` + ); + } + + this.page = await this.app.firstWindow({ timeout: 60_000 }); + this.page.on('console', (message) => this.record('renderer', message.type(), message.text())); + this.page.on('pageerror', (error) => + this.record('page', 'error', error.stack ?? error.message) + ); + this.page.on('requestfailed', (request) => + this.record( + 'request', + 'error', + `${request.method()} ${request.url()} ${request.failure()?.errorText ?? 'failed'}` + ) + ); + await this.app.context().tracing.start({ screenshots: true, snapshots: true, sources: true }); + this.traceStarted = true; + await this.page.waitForFunction(() => document.readyState !== 'loading', undefined, { + timeout: 60_000, + }); + this.performanceSession = await this.page.context().newCDPSession(this.page); + this.performanceSession.on('LayerTree.layerPainted', () => { + this.rendererPaintCount += 1; + }); + await this.performanceSession.send('Performance.enable'); + await this.performanceSession.send('LayerTree.enable'); + await this.page.evaluate(() => { + if (window.__LODY_E2E_PERFORMANCE__) return; + window.__LODY_E2E_PERFORMANCE__ = { longTaskCount: 0, longTaskDurationMs: 0 }; + const observer = new PerformanceObserver((list) => { + const summary = window.__LODY_E2E_PERFORMANCE__; + if (!summary) return; + for (const entry of list.getEntries()) { + summary.longTaskCount += 1; + summary.longTaskDurationMs += entry.duration; + } + }); + try { + observer.observe({ type: 'longtask', buffered: true }); + } catch { + observer.disconnect(); + } + }); + } + + async captureSnapshot(): Promise { + if (!this.app || !this.page) throw new Error('Electron harness is not running'); + const snapshot = await collectRuntimeSnapshot( + this.app, + this.page, + 'ambient', + this.performanceSession ?? undefined, + this.rendererPaintCount + ); + this.snapshots.push(snapshot); + return snapshot; + } + + async capturePostGcSnapshot(): Promise { + if (!this.app || !this.page) throw new Error('Electron harness is not running'); + const snapshot = await collectPostGcRuntimeSnapshot( + this.app, + this.page, + this.performanceSession ?? undefined, + this.rendererPaintCount + ); + this.snapshots.push(snapshot); + return snapshot; + } + + async captureCliBacklog(): Promise { + if (!this.page) return []; + return await this.page.evaluate(async () => await window.ipc?.invoke('cli.getOutputBacklog')); + } + + async stopTrace(path?: string): Promise { + if (!this.app || !this.traceStarted) return; + this.traceStarted = false; + await this.app.context().tracing.stop(path ? { path } : undefined); + } + + async captureHeapSnapshots(outputDir: string): Promise<{ main: string; renderer: string }> { + if (!this.app || !this.page) throw new Error('Electron harness is not running'); + mkdirSync(outputDir, { recursive: true }); + const mainPath = join(outputDir, 'electron-main.heapsnapshot'); + const rendererPath = join(outputDir, 'renderer.heapsnapshot'); + await this.app.evaluate(async (_runtime, targetPath) => { + const writer = ( + globalThis as typeof globalThis & { + __LODY_E2E_WRITE_HEAP_SNAPSHOT__?: (path: string) => string; + } + ).__LODY_E2E_WRITE_HEAP_SNAPSHOT__; + if (!writer) throw new Error('Electron main E2E heap writer is unavailable'); + writer(targetPath); + }, mainPath); + + const cdp = await this.page.context().newCDPSession(this.page); + const rendererFd = openSync(rendererPath, 'w'); + let complete = false; + let writeError: unknown; + cdp.on('HeapProfiler.addHeapSnapshotChunk', ({ chunk }: { chunk: string }) => { + if (writeError) return; + try { + writeSync(rendererFd, chunk, undefined, 'utf8'); + } catch (error) { + writeError = error; + } + }); + try { + await cdp.send('HeapProfiler.enable'); + await cdp.send('HeapProfiler.takeHeapSnapshot', { reportProgress: false }); + if (writeError) throw writeError; + fsyncSync(rendererFd); + complete = true; + } finally { + try { + await cdp.detach(); + } finally { + closeSync(rendererFd); + if (!complete) rmSync(rendererPath, { force: true }); + } + } + return { main: mainPath, renderer: rendererPath }; + } + + async close(): Promise { + let closeError: unknown; + const appProcess = this.app?.process(); + try { + await this.stopTrace(); + } catch (error) { + closeError = error; + } + try { + await this.performanceSession?.detach(); + } catch (error) { + closeError ??= error; + } + try { + if (this.app) await boundedTeardown('Electron application close', this.app.close()); + } catch (error) { + closeError ??= error; + try { + appProcess?.kill('SIGKILL'); + } catch (killError) { + closeError ??= killError; + } + } finally { + this.app = null; + this.page = null; + this.performanceSession = null; + } + + try { + if (this.hostPort !== null) await assertTcpPortReleased(this.hostPort); + if (this.hostPipe !== null) await assertNamedPipeReleased(this.hostPipe); + } catch (error) { + closeError ??= error; + } + try { + if (this.tempRoot) rmSync(this.tempRoot, { recursive: true, force: true }); + } catch (error) { + closeError ??= error; + } + this.tempRoot = null; + this.hostPort = null; + this.hostPipe = null; + this.rendererPaintCount = 0; + if (closeError) throw closeError; + } + + writeDiagnostics(): void { + writeFileSync( + join(this.artifacts.scenarioDir, 'console.log'), + this.logs.map((record) => JSON.stringify(record)).join('\n') + '\n', + 'utf8' + ); + writeFileSync( + join(this.artifacts.scenarioDir, 'runtime.json'), + `${JSON.stringify({ snapshots: this.snapshots }, null, 2)}\n`, + 'utf8' + ); + } + + private record(source: LogRecord['source'], level: string, message: string): void { + this.logs.push({ at: new Date().toISOString(), source, level, message }); + } +} + +declare global { + interface Window { + ipc?: { + invoke(channel: string, ...args: unknown[]): Promise; + }; + __LODY_ELECTRON__?: true; + __LODY_E2E_PERFORMANCE__?: { + longTaskCount: number; + longTaskDurationMs: number; + }; + } +} diff --git a/e2e/src/support/fixtures/synthetic-review-repository.test.ts b/e2e/src/support/fixtures/synthetic-review-repository.test.ts new file mode 100644 index 000000000..de5e7e13e --- /dev/null +++ b/e2e/src/support/fixtures/synthetic-review-repository.test.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import test from 'node:test'; +import { + createSyntheticReviewRepository, + PRIMARY_REVIEW_DIFF_PATH, + SECONDARY_REVIEW_DIFF_PATH, +} from './synthetic-review-repository.js'; + +void test('creates and removes a deterministic dirty review repository', () => { + const fixture = createSyntheticReviewRepository(); + try { + const status = execFileSync('git', ['status', '--short'], { + cwd: fixture.rootPath, + encoding: 'utf8', + }) + .trimEnd() + .split('\n'); + assert.deepEqual(status, [ + ' M README.md', + ` M ${PRIMARY_REVIEW_DIFF_PATH}`, + ` M ${SECONDARY_REVIEW_DIFF_PATH}`, + ]); + + const numstat = execFileSync('git', ['diff', '--numstat'], { + cwd: fixture.rootPath, + encoding: 'utf8', + }); + assert.match(numstat, new RegExp(`2400\\t2400\\t${PRIMARY_REVIEW_DIFF_PATH}`)); + assert.match(numstat, new RegExp(`1200\\t1200\\t${SECONDARY_REVIEW_DIFF_PATH}`)); + } finally { + fixture.cleanup(); + } + assert.equal(existsSync(fixture.rootPath), false); +}); diff --git a/e2e/src/support/fixtures/synthetic-review-repository.ts b/e2e/src/support/fixtures/synthetic-review-repository.ts new file mode 100644 index 000000000..c5966420c --- /dev/null +++ b/e2e/src/support/fixtures/synthetic-review-repository.ts @@ -0,0 +1,108 @@ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +export const PRIMARY_REVIEW_DIFF_PATH = 'src/generated/primary-snapshot.ts'; +export const SECONDARY_REVIEW_DIFF_PATH = 'src/generated/secondary-snapshot.ts'; + +const PRIMARY_LINE_COUNT = 2_400; +const SECONDARY_LINE_COUNT = 1_200; + +export type SyntheticReviewRepository = { + rootPath: string; + changedPaths: readonly string[]; + expectedLineChanges: Readonly>; + cleanup: () => void; +}; + +function renderGeneratedModule(prefix: string, lineCount: number, revision: number): string { + return Array.from( + { length: lineCount }, + (_, index) => + `export const ${prefix}_${String(index).padStart(4, '0')} = ${index + revision};\n` + ).join(''); +} + +function writeRepositoryFile(rootPath: string, relativePath: string, content: string): void { + const targetPath = join(rootPath, relativePath); + mkdirSync(dirname(targetPath), { recursive: true }); + writeFileSync(targetPath, content, 'utf8'); +} + +function git(rootPath: string, args: readonly string[]): string { + return execFileSync('git', args, { + cwd: rootPath, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); +} + +/** + * Creates a real, fully local Git repository whose working tree has two large + * deterministic text diffs. The fixture owns its temporary directory. + */ +export function createSyntheticReviewRepository(): SyntheticReviewRepository { + const rootPath = mkdtempSync(join(tmpdir(), 'lody-review-e2e-')); + let retained = false; + + try { + git(rootPath, ['init', '--initial-branch=main']); + git(rootPath, ['config', 'user.name', 'Lody E2E']); + git(rootPath, ['config', 'user.email', 'e2e@invalid.example']); + git(rootPath, ['config', 'commit.gpgsign', 'false']); + + writeRepositoryFile( + rootPath, + PRIMARY_REVIEW_DIFF_PATH, + renderGeneratedModule('primary_value', PRIMARY_LINE_COUNT, 0) + ); + writeRepositoryFile( + rootPath, + SECONDARY_REVIEW_DIFF_PATH, + renderGeneratedModule('secondary_value', SECONDARY_LINE_COUNT, 0) + ); + writeRepositoryFile( + rootPath, + 'README.md', + '# Synthetic Review Fixture\n\nThis repository contains generated test data only.\n' + ); + git(rootPath, ['add', '--all']); + git(rootPath, ['commit', '--quiet', '-m', 'test: establish synthetic review baseline']); + + writeRepositoryFile( + rootPath, + PRIMARY_REVIEW_DIFF_PATH, + renderGeneratedModule('primary_value', PRIMARY_LINE_COUNT, 10_000) + ); + writeRepositoryFile( + rootPath, + SECONDARY_REVIEW_DIFF_PATH, + renderGeneratedModule('secondary_value', SECONDARY_LINE_COUNT, 20_000) + ); + writeRepositoryFile( + rootPath, + 'README.md', + '# Synthetic Review Fixture\n\nThis working tree is intentionally changed.\n' + ); + + retained = true; + return { + rootPath, + changedPaths: ['README.md', PRIMARY_REVIEW_DIFF_PATH, SECONDARY_REVIEW_DIFF_PATH], + expectedLineChanges: { + [PRIMARY_REVIEW_DIFF_PATH]: { + additions: PRIMARY_LINE_COUNT, + deletions: PRIMARY_LINE_COUNT, + }, + [SECONDARY_REVIEW_DIFF_PATH]: { + additions: SECONDARY_LINE_COUNT, + deletions: SECONDARY_LINE_COUNT, + }, + }, + cleanup: () => rmSync(rootPath, { recursive: true, force: true }), + }; + } finally { + if (!retained) rmSync(rootPath, { recursive: true, force: true }); + } +} diff --git a/e2e/src/support/fixtures/work-session-fixture.ts b/e2e/src/support/fixtures/work-session-fixture.ts new file mode 100644 index 000000000..9befc6ce8 --- /dev/null +++ b/e2e/src/support/fixtures/work-session-fixture.ts @@ -0,0 +1,147 @@ +import { execFile } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { expect } from '@playwright/test'; + +const execFileAsync = promisify(execFile); +const SCRIPTED_ACP_ENTRY = resolve( + dirname(fileURLToPath(import.meta.url)), + '../../../fixtures/scripted-acp.mjs' +); + +export type ScriptedAcpEvent = { + at: string; + pid: number; + event: string; + sessionId?: string; + mode?: string; + stopReason?: string; +}; + +function quoteCommandArgument(value: string): string { + if (/^[A-Za-z0-9_./:\\-]+$/u.test(value)) return value; + return `"${value.replace(/["\\$`]/gu, '\\$&')}"`; +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return !( + error instanceof Error && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ESRCH' + ); + } +} + +export class WorkSessionFixture { + readonly projectName = 'lody-e2e-work'; + readonly projectRoot: string; + readonly acpEventLogPath: string; + readonly scriptedAcpEntry = SCRIPTED_ACP_ENTRY; + readonly scriptedAgentCommandLine: string; + + private constructor( + readonly tempRoot: string, + eventLogPath?: string + ) { + this.projectRoot = join(tempRoot, this.projectName); + this.acpEventLogPath = eventLogPath ?? join(tempRoot, 'scripted-acp-events.jsonl'); + this.scriptedAgentCommandLine = [process.execPath, this.scriptedAcpEntry, this.acpEventLogPath] + .map(quoteCommandArgument) + .join(' '); + } + + static async create(eventLogPath?: string): Promise { + const tempBase = process.platform === 'win32' ? tmpdir() : '/tmp'; + const fixture = new WorkSessionFixture( + mkdtempSync(join(tempBase, 'lody-e2e-work-')), + eventLogPath + ); + try { + mkdirSync(fixture.projectRoot, { recursive: true }); + writeFileSync( + join(fixture.projectRoot, 'README.md'), + '# Synthetic Lody E2E workspace\n\nThis repository contains no user data.\n', + 'utf8' + ); + await execFileAsync('git', ['init', '--initial-branch=main', fixture.projectRoot]); + await execFileAsync('git', ['-C', fixture.projectRoot, 'add', 'README.md']); + await execFileAsync('git', [ + '-C', + fixture.projectRoot, + '-c', + 'user.name=Lody E2E', + '-c', + 'user.email=e2e@lody.invalid', + '-c', + 'commit.gpgSign=false', + 'commit', + '-m', + 'test: initialize synthetic workspace', + ]); + return fixture; + } catch (error) { + fixture.dispose(); + throw error; + } + } + + readAcpEvents(): ScriptedAcpEvent[] { + try { + return readFileSync(this.acpEventLogPath, 'utf8') + .split('\n') + .filter(Boolean) + .flatMap((line) => { + try { + return [JSON.parse(line) as ScriptedAcpEvent]; + } catch { + // A process may be in the middle of appending the final JSONL record. + return []; + } + }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } + } + + async waitForAcpEvent(event: string, minimumCount = 1): Promise { + await expect + .poll(() => this.readAcpEvents().filter((entry) => entry.event === event).length, { + timeout: 30_000, + intervals: [50, 100, 250, 500], + }) + .toBeGreaterThanOrEqual(minimumCount); + return this.readAcpEvents().filter((entry) => entry.event === event); + } + + getStartedAgentPids(): number[] { + return [ + ...new Set( + this.readAcpEvents() + .filter((entry) => entry.event === 'process-start') + .map((entry) => entry.pid) + ), + ]; + } + + async expectAgentProcessesExited(pids = this.getStartedAgentPids()): Promise { + expect(pids.length, 'The scripted ACP process never started').toBeGreaterThan(0); + await expect + .poll(() => pids.filter(isProcessAlive), { + timeout: 30_000, + intervals: [50, 100, 250, 500], + }) + .toEqual([]); + } + + dispose(): void { + rmSync(this.tempRoot, { recursive: true, force: true }); + } +} diff --git a/e2e/src/support/hooks.ts b/e2e/src/support/hooks.ts new file mode 100644 index 000000000..066e60d04 --- /dev/null +++ b/e2e/src/support/hooks.ts @@ -0,0 +1,131 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + After, + Before, + setDefaultTimeout, + Status, + type ITestCaseHookParameter, +} from '@cucumber/cucumber'; +import { appendFailureIndex } from './world-utils.js'; +import type { LodyWorld } from './world.js'; + +setDefaultTimeout(120_000); + +Before(async function (this: LodyWorld, scenario: ITestCaseHookParameter) { + const tags = scenario.pickle.tags.map((tag) => tag.name); + this.prepare(tags); + console.log(`[e2e] ${this.artifacts!.stableId}: launch`); + try { + await this.launch(); + } catch (error) { + console.error(`[e2e] ${this.artifacts!.stableId}: launch failed; cleaning up`); + await this.harness?.close().catch((cleanupError: unknown) => { + console.error(`[e2e] ${this.artifacts!.stableId}: launch cleanup failed`, cleanupError); + }); + throw error; + } +}); + +After(async function (this: LodyWorld, scenario: ITestCaseHookParameter) { + const failed = scenario.result?.status !== Status.PASSED; + const harness = this.harness; + const artifacts = this.artifacts; + if (!harness || !artifacts) return; + console.log(`[e2e] ${artifacts.stableId}: teardown (${scenario.result?.status ?? 'unknown'})`); + + const evidenceErrors: string[] = []; + let artifactDirectoryReady = false; + try { + mkdirSync(artifacts.scenarioDir, { recursive: true }); + artifactDirectoryReady = true; + } catch (error) { + evidenceErrors.push(`artifact directory: ${String(error)}`); + } + + if (artifactDirectoryReady && harness.app && harness.page) { + try { + await harness.captureSnapshot(); + const cliBacklog = await harness.captureCliBacklog(); + writeFileSync( + join(artifacts.scenarioDir, 'cli-backlog.json'), + `${JSON.stringify(cliBacklog, null, 2)}\n`, + 'utf8' + ); + } catch (error) { + evidenceErrors.push(`runtime evidence: ${String(error)}`); + } + } + if (artifactDirectoryReady && failed) { + try { + await harness.page?.screenshot({ + path: join(artifacts.scenarioDir, 'failure.png'), + fullPage: true, + }); + } catch (error) { + evidenceErrors.push(`failure screenshot: ${String(error)}`); + } + try { + await harness.stopTrace(join(artifacts.scenarioDir, 'trace.zip')); + } catch (error) { + evidenceErrors.push(`failure trace: ${String(error)}`); + } + try { + appendFailureIndex(artifacts); + } catch (error) { + evidenceErrors.push(`failure index: ${String(error)}`); + } + } else if (artifactDirectoryReady && process.env.LODY_ACCEPTANCE_ROUND_ID) { + try { + await harness.capturePostGcSnapshot(); + await harness.page?.screenshot({ + path: join(artifacts.scenarioDir, 'checkpoint.png'), + fullPage: true, + }); + await harness.stopTrace(join(artifacts.scenarioDir, 'trace.zip')); + } catch (error) { + evidenceErrors.push(`acceptance evidence: ${String(error)}`); + } + } + + if (artifactDirectoryReady) { + try { + harness.writeDiagnostics(); + } catch (error) { + evidenceErrors.push(`diagnostics: ${String(error)}`); + } + } + try { + await harness.close(); + } catch (error) { + evidenceErrors.push(`teardown: ${String(error)}`); + } + try { + this.disposeFixtures(); + } catch (error) { + evidenceErrors.push(`fixture cleanup: ${String(error)}`); + } + if (artifactDirectoryReady && !failed && evidenceErrors.length > 0) { + try { + appendFailureIndex(artifacts); + } catch (error) { + evidenceErrors.push(`failure index: ${String(error)}`); + } + } + + if (evidenceErrors.length > 0) { + if (artifactDirectoryReady) { + try { + writeFileSync( + join(artifacts.scenarioDir, 'evidence-errors.log'), + `${evidenceErrors.join('\n')}\n`, + 'utf8' + ); + } catch (error) { + evidenceErrors.push(`evidence error log: ${String(error)}`); + } + } + if (!failed) throw new Error(evidenceErrors.join('\n')); + } + console.log(`[e2e] ${artifacts.stableId}: teardown complete`); +}); diff --git a/e2e/src/support/pages/onboarding-page.ts b/e2e/src/support/pages/onboarding-page.ts new file mode 100644 index 000000000..b026352fc --- /dev/null +++ b/e2e/src/support/pages/onboarding-page.ts @@ -0,0 +1,73 @@ +import { expect, type Page } from '@playwright/test'; + +type LocalBootstrapState = { + cli: { + phase?: string; + startupStage?: string; + runtimeOwnership?: string; + runtime?: { pid?: number }; + } | null; + snapshot: { + userId?: string; + workspace?: { workspaceId?: string; slug?: string | null }; + } | null; +}; + +export class OnboardingPage { + constructor(private readonly page: Page) {} + + async waitForLocalBootstrap(): Promise { + await expect + .poll( + async () => { + return await this.page.evaluate(async () => { + if (window.__LODY_ELECTRON__ !== true || !window.ipc) return null; + const [cli, snapshot] = await Promise.all([ + window.ipc.invoke('cli.getState'), + window.ipc.invoke('localPlatform.getSnapshot'), + ]); + return { cli, snapshot }; + }); + }, + { timeout: 120_000, intervals: [100, 250, 500, 1000] } + ) + .toMatchObject({ + cli: { + phase: 'running', + startupStage: 'ready', + runtimeOwnership: 'owned', + }, + snapshot: { + userId: expect.stringMatching(/^local:/u), + workspace: { workspaceId: expect.stringMatching(/^lw_/u), slug: 'local' }, + }, + }); + + return (await this.page.evaluate(async () => { + const [cli, snapshot] = await Promise.all([ + window.ipc!.invoke('cli.getState'), + window.ipc!.invoke('localPlatform.getSnapshot'), + ]); + return { cli, snapshot }; + })) as LocalBootstrapState; + } + + async skipConfigurationAndEnterProduct(): Promise { + await this.openAgentConfiguration(); + await this.page.getByRole('button', { name: /^(Skip for now|稍后再配置)$/u }).click(); + await expect( + this.page.getByRole('heading', { name: /^(Explore Lody|探索 Lody)$/u }) + ).toBeVisible(); + await this.page.getByRole('button', { name: /^(Enter Lody|进入 Lody)$/u }).click(); + await expect(this.page.locator('#chat-prompt')).toBeVisible({ timeout: 60_000 }); + await expect(this.page).toHaveURL(/#\/local\/chat(?:\?.*)?$/u); + } + + async openAgentConfiguration(): Promise { + await this.page.getByRole('button', { name: /^(Skip intro|跳过介绍)$/u }).click(); + await this.page.getByRole('button', { name: /^(Configure Lody|开始配置)$/u }).click(); + await expect( + this.page.getByRole('heading', { name: /^(Connect a coding agent|连接一个编码 Agent)$/u }) + ).toBeVisible(); + } +} diff --git a/e2e/src/support/pages/review-page.ts b/e2e/src/support/pages/review-page.ts new file mode 100644 index 000000000..65496251e --- /dev/null +++ b/e2e/src/support/pages/review-page.ts @@ -0,0 +1,235 @@ +import { expect, type Locator, type Page } from '@playwright/test'; + +type RegisteredLocalProject = { + machineId: string; + workspaceId: string; + workspaceSlug: string; + localProjectId: string; + name: string; + rootPath: string; +}; + +type LocalProjectAddResponse = { + ok?: boolean; + type?: string; + message?: string; + result?: { + localProjectId?: string; + name?: string; + rootPath?: string; + }; +}; + +export class ReviewPage { + private readonly sidePanel: Locator; + + constructor(private readonly page: Page) { + this.sidePanel = page.locator('[data-lody-session-tab-region="side-panel"]'); + } + + async registerLocalProject(rootPath: string): Promise { + const registered = await this.page.evaluate(async (projectRootPath) => { + if (!window.ipc) throw new Error('Electron IPC is unavailable'); + const [cliStateRaw, platformRaw] = await Promise.all([ + window.ipc.invoke('cli.getState'), + window.ipc.invoke('localPlatform.getSnapshot'), + ]); + const cliState = cliStateRaw as { runtime?: { machineId?: unknown } } | null; + const platform = platformRaw as { + workspace?: { workspaceId?: unknown; slug?: unknown }; + } | null; + const machineId = cliState?.runtime?.machineId; + const workspaceId = platform?.workspace?.workspaceId; + const workspaceSlug = platform?.workspace?.slug; + if (typeof machineId !== 'string' || typeof workspaceId !== 'string') { + throw new Error('Local runtime identity is not ready'); + } + const response = (await window.ipc.invoke('localProjects.control', { + type: 'local-project/add', + machineId, + rootPath: projectRootPath, + workspace: workspaceId, + })) as LocalProjectAddResponse; + if ( + response.ok !== true || + response.type !== 'local-project/add' || + typeof response.result?.localProjectId !== 'string' || + typeof response.result.name !== 'string' || + typeof response.result.rootPath !== 'string' + ) { + throw new Error(response.message ?? 'Failed to register synthetic local project'); + } + return { + machineId, + workspaceId, + workspaceSlug: typeof workspaceSlug === 'string' ? workspaceSlug : 'local', + localProjectId: response.result.localProjectId, + name: response.result.name, + rootPath: response.result.rootPath, + }; + }, rootPath); + + await expect + .poll( + async () => + await this.page.evaluate(async ({ machineId, workspaceId, localProjectId }) => { + const response = (await window.ipc?.invoke('localProjects.control', { + type: 'local-project/list', + machineId, + })) as + | { + ok?: boolean; + result?: { + workspaces?: Array<{ + workspaceId?: string; + projects?: Array<{ localProjectId?: string }>; + }>; + }; + } + | undefined; + return ( + response?.ok === true && + response.result?.workspaces?.some( + (workspace) => + workspace.workspaceId === workspaceId && + workspace.projects?.some((project) => project.localProjectId === localProjectId) + ) === true + ); + }, registered), + { timeout: 30_000, intervals: [100, 250, 500] } + ) + .toBe(true); + + return registered; + } + + async openSession(workspaceSlug: string, sessionId: string): Promise { + await this.page.evaluate( + ({ slug, id }) => { + window.location.hash = `/${encodeURIComponent(slug)}/sessions/${encodeURIComponent(id)}`; + }, + { slug: workspaceSlug, id: sessionId } + ); + await expect(this.page).toHaveURL( + new RegExp( + `#/${escapeRegExp(workspaceSlug)}/sessions/${escapeRegExp(sessionId)}(?:\\?.*)?$`, + 'u' + ) + ); + await expect(this.page.locator('#chat-prompt')).toBeVisible({ timeout: 60_000 }); + } + + async openChangesPanel(expectedPaths: readonly string[]): Promise { + const showSidebar = this.page.getByRole('button', { name: /^(Show sidebar|显示侧边栏)$/u }); + if (await showSidebar.isVisible()) { + await showSidebar.click(); + await this.waitForSidebarState(false); + } + + const existingTab = this.allChangesTabs().first(); + if ((await existingTab.count()) > 0) { + await existingTab.click(); + } else { + const emptyStateButton = this.sidePanel.getByRole('button', { + name: /^(All Changes|全部变更)$/iu, + }); + if (await emptyStateButton.isVisible()) { + await emptyStateButton.click(); + } else { + await this.sidePanel.getByRole('button', { name: /^(Add panel|添加面板)$/u }).click(); + await this.page.getByRole('menuitem', { name: /^(All Changes|全部变更)$/iu }).click(); + } + } + + await expect(this.activeSidePanelTab()).toContainText(/^(All Changes|全部变更)$/iu); + for (const path of expectedPaths) { + await expect(this.changeRow(path)).toBeVisible({ timeout: 60_000 }); + } + } + + async openChangedFile(path: string, expectedPaths: readonly string[]): Promise { + await this.openChangesPanel(expectedPaths); + await this.changeRow(path).click(); + await expect(this.activeSidePanelTab()).toContainText(/^(All Changes|全部变更)$/iu); + + const readyDiffs = this.sidePanel.locator('[data-section-id="diff-viewer"]'); + await expect(readyDiffs).toHaveCount(expectedPaths.length, { timeout: 60_000 }); + const focusedCard = readyDiffs.filter({ has: this.page.getByTitle(path, { exact: true }) }); + await expect(focusedCard).toHaveCount(1); + await expect(focusedCard).toBeInViewport(); + } + + async hide(): Promise { + await this.sidePanel.getByRole('button', { name: /^(Hide sidebar|隐藏侧边栏)$/u }).click(); + await this.waitForSidebarState(true); + await expect( + this.page.getByRole('button', { name: /^(Show sidebar|显示侧边栏)$/u }) + ).toBeVisible(); + } + + async show(): Promise { + await this.page.getByRole('button', { name: /^(Show sidebar|显示侧边栏)$/u }).click(); + await this.waitForSidebarState(false); + await expect( + this.sidePanel.getByRole('button', { name: /^(Hide sidebar|隐藏侧边栏)$/u }) + ).toBeVisible(); + } + + async closeDiffViewer(): Promise { + const readyDiffs = this.sidePanel.locator('[data-section-id="diff-viewer"]'); + await expect(readyDiffs.first()).toBeVisible(); + + const activeTab = this.activeSidePanelTab(); + await expect(activeTab).toContainText(/^(All Changes|全部变更)$/iu); + await activeTab + .getByRole('button', { name: /^(Close All Changes|关闭\s*全部变更)$/iu }) + .click(); + + await expect(readyDiffs).toHaveCount(0); + await expect(this.allChangesTabs()).toHaveCount(1); + } + + async closeChangesPanel(): Promise { + await expect(this.sidePanel.locator('[data-section-id="diff-viewer"]')).toHaveCount(0); + const activeTab = this.activeSidePanelTab(); + await expect(activeTab).toContainText(/^(All Changes|全部变更)$/iu); + await activeTab + .getByRole('button', { name: /^(Close All Changes|关闭\s*全部变更)$/iu }) + .click(); + await expect(this.allChangesTabs()).toHaveCount(0); + await expect(this.sidePanel.locator('[data-id^="change:"]')).toHaveCount(0); + await expect(this.sidePanel.locator('[data-section-id="diff-viewer"]')).toHaveCount(0); + } + + private allChangesTabs(): Locator { + return this.sidePanel.locator('[role="tab"]').filter({ hasText: /^(All Changes|全部变更)$/iu }); + } + + private activeSidePanelTab(): Locator { + return this.sidePanel.locator('[role="tab"][aria-selected="true"]'); + } + + private changeRow(path: string): Locator { + return this.sidePanel.getByTitle(path, { exact: true }).locator('xpath=self::button'); + } + + private async waitForSidebarState(hidden: boolean): Promise { + const animatedPanel = this.sidePanel.locator('xpath=ancestor::*[@aria-hidden][1]'); + await expect(animatedPanel).toHaveAttribute('aria-hidden', String(hidden)); + await animatedPanel.evaluate(async (element) => { + await Promise.all( + element.getAnimations({ subtree: true }).map(async (animation) => { + try { + await animation.finished; + } catch { + // A replacement animation is itself the next observable state. + } + }) + ); + }); + } +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); +} diff --git a/e2e/src/support/pages/session-page.ts b/e2e/src/support/pages/session-page.ts new file mode 100644 index 000000000..721824e7c --- /dev/null +++ b/e2e/src/support/pages/session-page.ts @@ -0,0 +1,143 @@ +import { expect, type Page } from '@playwright/test'; +import { type ScriptedAcpEvent, WorkSessionFixture } from '../fixtures/work-session-fixture.js'; + +const PROVIDER_NAME = 'Deterministic E2E Agent'; +const HELD_RESPONSE = 'Synthetic response started.'; + +export class SessionPage { + constructor( + private readonly page: Page, + private readonly fixture: WorkSessionFixture + ) {} + + async configureCustomAgentFromSettings(): Promise { + await this.page.getByRole('button', { name: 'Settings', exact: true }).click(); + const settings = this.page.getByRole('dialog').filter({ + has: this.page.getByRole('navigation', { name: /^(Settings|设置)$/u }), + }); + await expect(settings).toBeVisible(); + await settings.getByRole('button', { name: 'Agents', exact: true }).click(); + + const addProvider = this.page.getByRole('button', { + name: /^(Add provider|添加 Provider)$/u, + }); + await expect(addProvider.first()).toBeEnabled({ timeout: 60_000 }); + await addProvider.first().click(); + await this.page.getByRole('option', { name: /^(Custom command|自定义命令)$/u }).click(); + await this.page.locator('#agent-config-name').fill(PROVIDER_NAME); + await this.page.locator('#custom-acp-command').fill(this.fixture.scriptedAgentCommandLine); + await this.page.getByRole('button', { name: /^(Test command|测试命令)$/u }).click(); + await expect(this.page.getByText(/^(Ready|就绪)$/u).first()).toBeVisible({ timeout: 60_000 }); + await this.page.getByRole('button', { name: /^(Create|创建)$/u }).click(); + await expect(this.page.getByText(PROVIDER_NAME, { exact: true })).toBeVisible({ + timeout: 30_000, + }); + await this.page.keyboard.press('Escape'); + await expect(settings).toBeHidden(); + await expect(this.page.locator('#chat-prompt')).toBeEditable({ timeout: 60_000 }); + } + + async createHeldSession( + prompt = 'Exercise deterministic lifecycle [SCOUT:HOLD]' + ): Promise { + await this.page.locator('#chat-prompt').fill(prompt); + await this.page.getByRole('button', { name: /^(Send|发送)$/u }).click(); + await expect(this.page).toHaveURL(/#\/local\/sessions\/[^/?#]+(?:\?.*)?$/u, { + timeout: 60_000, + }); + await expect(this.page.getByText(HELD_RESPONSE, { exact: true })).toBeVisible({ + timeout: 60_000, + }); + await expect(this.page.getByRole('button', { name: /^(Stop|停止)$/u })).toBeVisible(); + + const promptEvents = await this.fixture.waitForAcpEvent('prompt-start'); + const waiting = [...promptEvents].reverse().find((event) => event.mode === 'hold'); + expect(waiting, 'The scripted ACP did not observe the held prompt').toBeDefined(); + expect(waiting?.sessionId).toEqual(expect.any(String)); + return waiting!; + } + + async createCompletedSession( + prompt = 'Exercise deterministic reply [SCOUT:REPLY]' + ): Promise { + const priorCount = this.fixture + .readAcpEvents() + .filter((event) => event.event === 'prompt-end').length; + await this.page.locator('#chat-prompt').fill(prompt); + await this.page.getByRole('button', { name: /^(Send|发送)$/u }).click(); + await expect(this.page).toHaveURL(/#\/local\/sessions\/[^/?#]+(?:\?.*)?$/u, { + timeout: 60_000, + }); + await expect(this.page.getByText(/Synthetic (?:response|diff revision)/u).first()).toBeVisible({ + timeout: 60_000, + }); + const completed = await this.fixture.waitForAcpEvent('prompt-end', priorCount + 1); + return completed.at(-1)!; + } + + async stopHeldSession(waiting: ScriptedAcpEvent): Promise { + await this.page.getByRole('button', { name: /^(Stop|停止)$/u }).click(); + await this.waitForSessionEvent('session-cancel', waiting); + const completed = await this.waitForSessionEvent('prompt-end', waiting); + expect(completed.stopReason).toBe('cancelled'); + await expect(this.page.getByRole('button', { name: /^(Stop|停止)$/u })).toBeHidden({ + timeout: 30_000, + }); + } + + async archiveSessionAndWaitForRuntimeExit(waiting: ScriptedAcpEvent): Promise { + await this.page + .getByRole('button', { name: /^(More actions|更多操作)$/u }) + .last() + .click(); + await this.page.getByRole('menuitem', { name: /^(Archive session|归档会话)$/u }).click(); + await expect(this.page).toHaveURL(/#\/local\/chat(?:\?.*)?$/u, { timeout: 30_000 }); + await this.fixture.expectAgentProcessesExited([waiting.pid]); + } + + async archiveAndDeleteSession(waiting: ScriptedAcpEvent): Promise { + const match = /#\/local\/sessions\/([^?]+)/u.exec(this.page.url()); + if (!match?.[1]) throw new Error(`Expected a Session route, received ${this.page.url()}`); + const sessionId = decodeURIComponent(match[1]); + await this.archiveSessionAndWaitForRuntimeExit(waiting); + await this.page.evaluate((id) => { + window.location.hash = `/local/sessions/${encodeURIComponent(id)}`; + }, sessionId); + const actions = this.page.getByRole('button', { name: /^(More actions|更多操作)$/u }).last(); + await expect(actions).toBeVisible({ timeout: 30_000 }); + await actions.click(); + await this.page.getByRole('menuitem', { name: /^(Delete permanently|永久删除)$/u }).click(); + const dialog = this.page.getByRole('dialog', { + name: /^(Delete permanently\?|确认永久删除?)$/u, + }); + await dialog.getByRole('button', { name: /^(Delete permanently|永久删除)$/u }).click(); + await expect(this.page).toHaveURL(/#\/local\/chat(?:\?.*)?$/u, { timeout: 30_000 }); + await expect(this.page.locator('#chat-prompt')).toBeEditable({ timeout: 30_000 }); + } + + private async waitForSessionEvent( + event: string, + prompt: ScriptedAcpEvent + ): Promise { + let match: ScriptedAcpEvent | undefined; + await expect + .poll( + () => { + match = this.fixture + .readAcpEvents() + .find( + (entry) => + entry.event === event && + entry.pid === prompt.pid && + entry.sessionId === prompt.sessionId + ); + return match !== undefined; + }, + { timeout: 30_000, intervals: [50, 100, 250, 500] } + ) + .toBe(true); + return match!; + } +} + +export { PROVIDER_NAME as SCRIPTED_AGENT_NAME }; diff --git a/e2e/src/support/pages/work-session-page.ts b/e2e/src/support/pages/work-session-page.ts new file mode 100644 index 000000000..247132222 --- /dev/null +++ b/e2e/src/support/pages/work-session-page.ts @@ -0,0 +1,193 @@ +import { existsSync } from 'node:fs'; +import { expect, type Page } from '@playwright/test'; + +type TerminalSnapshot = { + terminalId: string; + title: string; + cwd?: string; +}; + +export type WorkSessionResources = { + sessionId: string; + terminalIds: string[]; + worktreePath: string; +}; + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return !( + error instanceof Error && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ESRCH' + ); + } +} + +export class WorkSessionPage { + constructor(private readonly page: Page) {} + + async addLocalProject( + rootPath: string, + projectName: string, + machineName?: string + ): Promise { + await this.page.getByRole('button', { name: /^(Select a project|选择项目)$/u }).click(); + await this.page.getByRole('menuitem', { name: /^(Add a folder|添加文件夹)$/u }).click(); + + const dialog = this.page.getByRole('dialog', { name: /^(Add a folder|添加文件夹)$/u }); + await expect(dialog).toBeVisible(); + const editPath = dialog.getByTitle(/^(Edit path|编辑路径)$/u); + if (!(await editPath.isVisible())) { + const machine = machineName + ? dialog.getByText(machineName, { exact: true }) + : dialog.getByText(/^(Your machine|你的机器)$/u); + await machine.click(); + } + await expect(editPath).toBeVisible(); + await editPath.click(); + const pathInput = dialog.getByPlaceholder(/^(Type an absolute path|输入绝对路径)$/u); + await pathInput.fill(rootPath); + await pathInput.press('Enter'); + + await expect(dialog.getByText(projectName, { exact: true })).toBeVisible(); + await dialog.getByRole('button', { name: /^(Add|添加)$/u }).click(); + await expect(dialog).toBeHidden(); + await expect(this.page.getByRole('button', { name: projectName, exact: true })).toBeVisible(); + } + + async selectLocalProject(projectName: string): Promise { + const selected = this.page.getByRole('button', { name: projectName, exact: true }); + if (await selected.isVisible()) return; + await this.page.getByRole('button', { name: /^(Select a project|选择项目)$/u }).click(); + await this.page.getByPlaceholder(/^(Search projects|搜索项目)$/u).fill(projectName); + await this.page.getByRole('menuitem', { name: projectName, exact: true }).click(); + await expect(selected).toBeVisible(); + } + + async selectAgent(agentName: string): Promise { + await this.page.getByRole('button', { name: /^(Run configuration|运行设置)$/u }).click(); + await this.page.getByRole('menuitem', { name: /^Agent(?:\s|$)/u }).hover(); + const agentOption = this.page.getByRole('menuitemradio', { name: agentName, exact: true }); + await agentOption.click(); + await expect(agentOption).toHaveAttribute('aria-checked', 'true'); + await this.page.keyboard.press('Escape'); + } + + async enableWorktree(): Promise { + const checkbox = this.page.getByRole('checkbox', { name: /^(Use worktree|使用 worktree)$/u }); + await expect(checkbox).toBeEnabled({ timeout: 30_000 }); + await checkbox.check(); + await expect(checkbox).toBeChecked(); + } + + async startSession(prompt: string): Promise { + await this.page.locator('#chat-prompt').fill(prompt); + await this.page.getByRole('button', { name: /^(Send|发送)$/u }).click(); + await expect(this.page).toHaveURL(/#\/local\/sessions\/[^?]+(?:\?.*)?$/u, { timeout: 60_000 }); + const match = /#\/local\/sessions\/([^?]+)/u.exec(this.page.url()); + if (!match?.[1]) throw new Error(`Unable to read Session id from ${this.page.url()}`); + return decodeURIComponent(match[1]); + } + + async openTerminalAndRun(command: string, outputMarker: string): Promise { + await this.page.getByRole('button', { name: /^(Show terminal panel|显示终端面板)$/u }).click(); + const terminal = this.page.locator('.lody-terminal-panel'); + await expect(terminal).toBeVisible({ timeout: 30_000 }); + const input = terminal.locator('.xterm-helper-textarea'); + await input.focus(); + await this.page.keyboard.type(command); + await this.page.keyboard.press('Enter'); + await expect(terminal.locator('.xterm-rows')).toContainText(outputMarker, { timeout: 30_000 }); + + const sessionId = this.currentSessionId(); + await expect + .poll(() => this.listTerminals(sessionId), { + timeout: 30_000, + intervals: [50, 100, 250, 500], + }) + .not.toEqual([]); + return await this.listTerminals(sessionId); + } + + async captureResources(): Promise { + const sessionId = this.currentSessionId(); + const terminals = await this.listTerminals(sessionId); + expect(terminals.length, 'The Session has no live terminal to clean up').toBeGreaterThan(0); + const worktreePath = terminals.find((terminal) => terminal.cwd)?.cwd; + expect(worktreePath, 'The live terminal did not report its worktree cwd').toEqual( + expect.any(String) + ); + await expect.poll(() => existsSync(worktreePath!)).toBe(true); + return { + sessionId, + terminalIds: terminals.map((terminal) => terminal.terminalId), + worktreePath: worktreePath!, + }; + } + + async archiveAndDeletePermanently(resources: WorkSessionResources): Promise { + expect(this.currentSessionId()).toBe(resources.sessionId); + await this.page + .getByRole('button', { name: /^(More actions|更多操作)$/u }) + .last() + .click(); + await this.page.getByRole('menuitem', { name: /^(Archive session|归档会话)$/u }).click(); + await expect(this.page).toHaveURL(/#\/local\/chat(?:\?.*)?$/u, { timeout: 30_000 }); + await expect + .poll(() => this.listTerminals(resources.sessionId), { + timeout: 30_000, + intervals: [50, 100, 250, 500], + }) + .toEqual([]); + + await this.page.evaluate((sessionId) => { + window.location.hash = `/local/sessions/${encodeURIComponent(sessionId)}`; + }, resources.sessionId); + await expect(this.page).toHaveURL( + new RegExp(`#\\/local\\/sessions\\/${resources.sessionId}(?:\\?.*)?$`, 'u') + ); + await this.page + .getByRole('button', { name: /^(More actions|更多操作)$/u }) + .last() + .click(); + await this.page.getByRole('menuitem', { name: /^(Delete permanently|永久删除)$/u }).click(); + const dialog = this.page.getByRole('dialog', { + name: /^(Delete permanently\?|确认永久删除?)$/u, + }); + await expect(dialog).toBeVisible(); + await dialog.getByRole('button', { name: /^(Delete permanently|永久删除)$/u }).click(); + await expect(this.page).toHaveURL(/#\/local\/chat(?:\?.*)?$/u, { timeout: 30_000 }); + } + + async expectResourcesReleased( + resources: WorkSessionResources, + agentPids: readonly number[] + ): Promise { + expect(agentPids.length, 'The ACP process was not observed before deletion').toBeGreaterThan(0); + await expect + .poll( + async () => ({ + terminals: await this.listTerminals(resources.sessionId), + worktreeExists: existsSync(resources.worktreePath), + liveAgentPids: agentPids.filter(isProcessAlive), + }), + { timeout: 60_000, intervals: [50, 100, 250, 500, 1000] } + ) + .toEqual({ terminals: [], worktreeExists: false, liveAgentPids: [] }); + } + + private currentSessionId(): string { + const match = /#\/local\/sessions\/([^?]+)/u.exec(this.page.url()); + if (!match?.[1]) throw new Error(`Expected a Session route, received ${this.page.url()}`); + return decodeURIComponent(match[1]); + } + + private async listTerminals(sessionId: string): Promise { + return (await this.page.evaluate(async (targetSessionId) => { + return await window.ipc!.invoke('terminal.list', targetSessionId); + }, sessionId)) as TerminalSnapshot[]; + } +} diff --git a/e2e/src/support/resource-probe.test.ts b/e2e/src/support/resource-probe.test.ts new file mode 100644 index 000000000..d8c109e57 --- /dev/null +++ b/e2e/src/support/resource-probe.test.ts @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + parseProcessTable, + selectProcessTree, + summarizeTrend, + theilSenSlope, +} from './resource-probe.js'; + +void describe('process tree evidence', () => { + void it('keeps descendants and removes raw commands', () => { + const rows = parseProcessTable(` + 20 1 1000 1.5 /Applications/Electron app.js + 21 20 2000 2.5 Electron Helper --type=renderer + 22 20 3000 3.5 Electron resources/cli/index.js start --secret redacted-at-output + 23 22 4000 4.5 codex app-server --token never-retained + 24 22 5000 5.5 node /fixture/scripted-acp.mjs --fixture-data synthetic + 99 1 9000 9.5 unrelated + `); + assert.deepEqual(selectProcessTree(rows, 20), [ + { + pid: 20, + parentPid: 1, + residentSetBytes: 1_024_000, + cpuPercent: 1.5, + kind: 'electron-main', + }, + { pid: 21, parentPid: 20, residentSetBytes: 2_048_000, cpuPercent: 2.5, kind: 'renderer' }, + { pid: 22, parentPid: 20, residentSetBytes: 3_072_000, cpuPercent: 3.5, kind: 'bundled-cli' }, + { + pid: 23, + parentPid: 22, + residentSetBytes: 4_096_000, + cpuPercent: 4.5, + kind: 'agent-runtime', + }, + { + pid: 24, + parentPid: 22, + residentSetBytes: 5_120_000, + cpuPercent: 5.5, + kind: 'agent-runtime', + }, + ]); + }); +}); + +void describe('theilSenSlope', () => { + void it('reports growth per checkpoint', () => { + assert.equal(theilSenSlope([100, 110, 120, 130]), 10); + }); + + void it('does not invent a trend without two checkpoints', () => { + assert.equal(theilSenSlope([]), 0); + assert.equal(theilSenSlope([42]), 0); + }); + + void it('resists one noisy checkpoint', () => { + assert.equal(theilSenSlope([100, 110, 1_000, 130, 140]), 10); + }); +}); + +void describe('summarizeTrend', () => { + void it('reports net growth and directional consistency', () => { + const summary = summarizeTrend([100, 110, 105, 120]); + assert.deepEqual( + { ...summary, slopePerCheckpoint: undefined }, + { + samples: 4, + first: 100, + last: 120, + netChange: 20, + slopePerCheckpoint: undefined, + positiveDeltaRatio: 2 / 3, + nonDecreasingDeltaRatio: 2 / 3, + } + ); + assert.ok(Math.abs(summary.slopePerCheckpoint - 35 / 6) <= Number.EPSILON * 8); + }); +}); diff --git a/e2e/src/support/resource-probe.ts b/e2e/src/support/resource-probe.ts new file mode 100644 index 000000000..a91ea1adf --- /dev/null +++ b/e2e/src/support/resource-probe.ts @@ -0,0 +1,268 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import type { CDPSession, ElectronApplication, Page } from '@playwright/test'; + +const execFileAsync = promisify(execFile); + +export type ProcessKind = + | 'electron-main' + | 'renderer' + | 'gpu' + | 'utility' + | 'bundled-cli' + | 'agent-runtime' + | 'child'; + +export type ProcessTreeMetric = { + pid: number; + parentPid: number; + kind: ProcessKind; + cpuPercent: number; + residentSetBytes: number; +}; + +export type RuntimeSnapshot = { + capturedAt: string; + kind: 'ambient' | 'post-gc'; + main: { + heapUsedBytes: number; + heapTotalBytes: number; + privateBytes: number | null; + residentSetBytes: number; + }; + electronProcesses: Array<{ + pid: number; + type: string; + cpuPercent: number; + workingSetBytes: number; + peakWorkingSetBytes: number; + }>; + processTree: ProcessTreeMetric[]; + renderer: { + domNodes: number; + documents: number; + eventListeners: number; + jsHeapUsedBytes: number | null; + jsHeapTotalBytes: number | null; + layoutCount: number | null; + recalcStyleCount: number | null; + taskDurationSeconds: number | null; + longTaskCount: number; + longTaskDurationMs: number; + paintCount: number; + }; +}; + +type CdpMetric = { name: string; value: number }; +type ProcessTableRow = Omit & { command: string }; + +function metricValue(metrics: readonly CdpMetric[], name: string): number | null { + return metrics.find((metric) => metric.name === name)?.value ?? null; +} + +export function parseProcessTable(output: string): ProcessTableRow[] { + return output + .split('\n') + .map((line) => /^\s*(\d+)\s+(\d+)\s+(\d+)\s+([\d.]+)\s+(.+)$/u.exec(line)) + .filter((match): match is RegExpExecArray => match !== null) + .map((match) => ({ + pid: Number(match[1]), + parentPid: Number(match[2]), + residentSetBytes: Number(match[3]) * 1024, + cpuPercent: Number(match[4]), + command: match[5]!, + })); +} + +function classifyProcess(command: string, pid: number, rootPid: number): ProcessKind { + if (pid === rootPid) return 'electron-main'; + if (/resources[/\\]cli[/\\]index\.js/u.test(command)) return 'bundled-cli'; + if (/(?:-acp\.js\b|scripted-acp\.mjs\b|\bapp-server\b|code-mode-host\b)/u.test(command)) { + return 'agent-runtime'; + } + if (/--type=renderer\b/u.test(command)) return 'renderer'; + if (/--type=gpu-process\b/u.test(command)) return 'gpu'; + if (/--type=utility\b/u.test(command)) return 'utility'; + return 'child'; +} + +export function selectProcessTree( + rows: readonly ProcessTableRow[], + rootPid: number +): ProcessTreeMetric[] { + const selectedPids = new Set([rootPid]); + let changed = true; + while (changed) { + changed = false; + for (const row of rows) { + if (!selectedPids.has(row.pid) && selectedPids.has(row.parentPid)) { + selectedPids.add(row.pid); + changed = true; + } + } + } + return rows + .filter((row) => selectedPids.has(row.pid)) + .map(({ command, ...row }) => ({ + ...row, + kind: classifyProcess(command, row.pid, rootPid), + })) + .sort((left, right) => left.pid - right.pid); +} + +async function collectProcessTree(rootPid: number): Promise { + if (process.platform === 'win32') return []; + const { stdout } = await execFileAsync('/bin/ps', ['-axo', 'pid=,ppid=,rss=,%cpu=,command=']); + return selectProcessTree(parseProcessTable(stdout), rootPid); +} + +export async function collectRuntimeSnapshot( + electronApp: ElectronApplication, + page: Page, + kind: RuntimeSnapshot['kind'] = 'ambient', + performanceSession?: CDPSession, + paintCount?: number +): Promise { + const rootPid = electronApp.process().pid; + if (rootPid === undefined) throw new Error('Electron main process has no pid'); + const main = await electronApp.evaluate(async ({ app }) => { + const memory = process.memoryUsage(); + const processMemory = await process.getProcessMemoryInfo(); + return { + heapUsedBytes: memory.heapUsed, + heapTotalBytes: memory.heapTotal, + privateBytes: typeof processMemory.private === 'number' ? processMemory.private * 1024 : null, + residentSetBytes: memory.rss, + processes: app.getAppMetrics().map((metric) => ({ + pid: metric.pid, + type: metric.type, + cpuPercent: metric.cpu.percentCPUUsage, + workingSetBytes: metric.memory.workingSetSize * 1024, + peakWorkingSetBytes: metric.memory.peakWorkingSetSize * 1024, + })), + }; + }); + + const cdp = performanceSession ?? (await page.context().newCDPSession(page)); + if (!performanceSession) await cdp.send('Performance.enable'); + const [dom, performanceMetrics, heap, processTree, timeline] = await Promise.all([ + cdp.send('Memory.getDOMCounters') as Promise<{ + documents: number; + nodes: number; + jsEventListeners: number; + }>, + cdp.send('Performance.getMetrics') as Promise<{ metrics: CdpMetric[] }>, + cdp.send('Runtime.getHeapUsage') as Promise<{ usedSize: number; totalSize: number }>, + collectProcessTree(rootPid), + page.evaluate(() => ({ + longTaskCount: window.__LODY_E2E_PERFORMANCE__?.longTaskCount ?? 0, + longTaskDurationMs: window.__LODY_E2E_PERFORMANCE__?.longTaskDurationMs ?? 0, + paintCount: performance.getEntriesByType('paint').length, + })), + ]); + if (!performanceSession) await cdp.detach(); + + return { + capturedAt: new Date().toISOString(), + kind, + main: { + heapUsedBytes: main.heapUsedBytes, + heapTotalBytes: main.heapTotalBytes, + privateBytes: main.privateBytes, + residentSetBytes: main.residentSetBytes, + }, + electronProcesses: main.processes, + processTree, + renderer: { + domNodes: dom.nodes, + documents: dom.documents, + eventListeners: dom.jsEventListeners, + jsHeapUsedBytes: heap.usedSize, + jsHeapTotalBytes: heap.totalSize, + layoutCount: metricValue(performanceMetrics.metrics, 'LayoutCount'), + recalcStyleCount: metricValue(performanceMetrics.metrics, 'RecalcStyleCount'), + taskDurationSeconds: metricValue(performanceMetrics.metrics, 'TaskDuration'), + longTaskCount: timeline.longTaskCount, + longTaskDurationMs: timeline.longTaskDurationMs, + paintCount: paintCount ?? timeline.paintCount, + }, + }; +} + +export async function collectPostGcRuntimeSnapshot( + electronApp: ElectronApplication, + page: Page, + performanceSession?: CDPSession, + paintCount?: number +): Promise { + await electronApp.evaluate(() => global.gc?.()); + const cdp = await page.context().newCDPSession(page); + try { + await cdp.send('HeapProfiler.collectGarbage'); + } finally { + await cdp.detach(); + } + await page.evaluate( + async () => + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }) + ); + return await collectRuntimeSnapshot(electronApp, page, 'post-gc', performanceSession, paintCount); +} + +function median(values: readonly number[]): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((left, right) => left - right); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 ? (sorted[middle - 1]! + sorted[middle]!) / 2 : sorted[middle]!; +} + +export function theilSenSlope(values: readonly number[]): number { + const slopes: number[] = []; + for (let left = 0; left < values.length; left += 1) { + for (let right = left + 1; right < values.length; right += 1) { + slopes.push((values[right]! - values[left]!) / (right - left)); + } + } + return median(slopes); +} + +export type TrendSummary = { + samples: number; + first: number | null; + last: number | null; + netChange: number; + slopePerCheckpoint: number; + positiveDeltaRatio: number; + nonDecreasingDeltaRatio: number; +}; + +export function summarizeTrend(values: readonly number[]): TrendSummary { + if (values.length === 0) { + return { + samples: 0, + first: null, + last: null, + netChange: 0, + slopePerCheckpoint: 0, + positiveDeltaRatio: 0, + nonDecreasingDeltaRatio: 0, + }; + } + let positiveDeltas = 0; + let nonDecreasingDeltas = 0; + for (let index = 1; index < values.length; index += 1) { + if (values[index]! > values[index - 1]!) positiveDeltas += 1; + if (values[index]! >= values[index - 1]!) nonDecreasingDeltas += 1; + } + return { + samples: values.length, + first: values[0]!, + last: values.at(-1)!, + netChange: values.at(-1)! - values[0]!, + slopePerCheckpoint: theilSenSlope(values), + positiveDeltaRatio: values.length < 2 ? 0 : positiveDeltas / (values.length - 1), + nonDecreasingDeltaRatio: values.length < 2 ? 0 : nonDecreasingDeltas / (values.length - 1), + }; +} diff --git a/e2e/src/support/world-utils.ts b/e2e/src/support/world-utils.ts new file mode 100644 index 000000000..b49873b1f --- /dev/null +++ b/e2e/src/support/world-utils.ts @@ -0,0 +1,82 @@ +import { createServer } from 'node:net'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +export type ScenarioArtifacts = { + rootDir: string; + scenarioDir: string; + stableId: string; +}; + +export function findStableId(tags: readonly string[]): string { + const stableId = tags.find((tag) => /^@LODY-[A-Z0-9-]+-\d{3}$/u.test(tag)); + if (!stableId) throw new Error('Scenario is missing a stable @LODY-AREA-NNN id'); + return stableId.slice(1); +} + +export function createScenarioArtifacts(tags: readonly string[]): ScenarioArtifacts { + const stableId = findStableId(tags); + const acceptanceRound = process.env.LODY_ACCEPTANCE_ROUND_ID?.trim(); + const rootDir = acceptanceRound + ? join(process.cwd(), 'artifacts', 'acceptance', acceptanceRound) + : join(process.cwd(), 'artifacts'); + const scenarioDir = join(rootDir, 'scenarios', stableId.toLowerCase()); + mkdirSync(scenarioDir, { recursive: true }); + return { rootDir, scenarioDir, stableId }; +} + +export async function reserveTcpPort(): Promise { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + throw new Error('Kernel did not return a TCP port for the E2E runtime'); + } + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + return address.port; +} + +export async function assertTcpPortReleased(port: number): Promise { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, '127.0.0.1', resolve); + }); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} + +export async function assertNamedPipeReleased(path: string): Promise { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(path, resolve); + }); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} + +export function appendFailureIndex(artifacts: ScenarioArtifacts): void { + const indexPath = join(artifacts.rootDir, 'failure-index.json'); + const current = (() => { + try { + const parsed = JSON.parse(readFileSync(indexPath, 'utf8')) as unknown; + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + })(); + current.push({ + stableId: artifacts.stableId, + path: `scenarios/${artifacts.stableId.toLowerCase()}`, + }); + writeFileSync(indexPath, `${JSON.stringify(current, null, 2)}\n`, 'utf8'); +} diff --git a/e2e/src/support/world.ts b/e2e/src/support/world.ts new file mode 100644 index 000000000..91a0c062c --- /dev/null +++ b/e2e/src/support/world.ts @@ -0,0 +1,58 @@ +import { World, setWorldConstructor } from '@cucumber/cucumber'; +import { ElectronHarness } from './electron-harness.js'; +import { OnboardingPage } from './pages/onboarding-page.js'; +import { ReviewPage } from './pages/review-page.js'; +import { SessionPage } from './pages/session-page.js'; +import { WorkSessionPage, type WorkSessionResources } from './pages/work-session-page.js'; +import { WorkSessionFixture, type ScriptedAcpEvent } from './fixtures/work-session-fixture.js'; +import type { SyntheticReviewRepository } from './fixtures/synthetic-review-repository.js'; +import { createScenarioArtifacts, type ScenarioArtifacts } from './world-utils.js'; + +export class LodyWorld extends World { + artifacts: ScenarioArtifacts | null = null; + harness: ElectronHarness | null = null; + onboarding: OnboardingPage | null = null; + reviewPage: ReviewPage | null = null; + sessionPage: SessionPage | null = null; + workPage: WorkSessionPage | null = null; + workFixture: WorkSessionFixture | null = null; + reviewFixture: SyntheticReviewRepository | null = null; + activeAcpEvent: ScriptedAcpEvent | null = null; + workResources: WorkSessionResources | null = null; + + prepare(tags: readonly string[]): void { + this.artifacts = createScenarioArtifacts(tags); + this.harness = new ElectronHarness(this.artifacts); + } + + async launch(): Promise { + if (!this.harness) throw new Error('Scenario was not prepared'); + await this.harness.launch(); + if (!this.harness.page) throw new Error('Electron did not open a main window'); + this.onboarding = new OnboardingPage(this.harness.page); + this.reviewPage = new ReviewPage(this.harness.page); + this.workPage = new WorkSessionPage(this.harness.page); + } + + async configureScriptedAgent(): Promise { + if (!this.artifacts || !this.onboarding || !this.harness?.page) { + throw new Error('Scenario is not ready for scripted Agent setup'); + } + await this.onboarding.waitForLocalBootstrap(); + this.workFixture = await WorkSessionFixture.create( + `${this.artifacts.scenarioDir}/scripted-acp.ndjson` + ); + this.sessionPage = new SessionPage(this.harness.page, this.workFixture); + await this.onboarding.skipConfigurationAndEnterProduct(); + await this.sessionPage.configureCustomAgentFromSettings(); + } + + disposeFixtures(): void { + this.reviewFixture?.cleanup(); + this.workFixture?.dispose(); + this.reviewFixture = null; + this.workFixture = null; + } +} + +setWorldConstructor(LodyWorld); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 000000000..dacfd45bb --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "lib": ["ES2022", "DOM"], + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/package.json b/package.json index 93d7afc37..fb80a0e4d 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,17 @@ "test:ci": "corepack pnpm test:scripts && corepack pnpm -r --workspace-concurrency=2 --filter \"!@lody/electron\" --filter \"!acp-extension-codex\" --filter \"!acp-extension-claude\" run test --maxWorkers=2 && corepack pnpm --filter @lody/electron run test", "test:watch": "corepack pnpm -r run test:watch", "test:coverage": "corepack pnpm -r run test:coverage", + "e2e:build": "corepack pnpm --dir apps/electron build", + "e2e:check": "corepack pnpm --filter @lody/e2e check", + "e2e:smoke": "corepack pnpm --filter @lody/e2e smoke", + "e2e:full": "corepack pnpm --filter @lody/e2e full", + "e2e:scout": "corepack pnpm --filter @lody/e2e scout", + "e2e:scout:ablation": "corepack pnpm --filter @lody/e2e scout:ablation", + "e2e:acceptance": "corepack pnpm --filter @lody/e2e acceptance", + "e2e:journey:author": "node e2e/scripts/run-journey-author.mjs", + "e2e:journey:candidate": "corepack pnpm --filter @lody/e2e journey:candidate", + "e2e:journey:coverage": "corepack pnpm --filter @lody/e2e journey:coverage", + "e2e:journey:validate": "node e2e/scripts/validate-journey-candidate.mjs", "lint:fast": "oxlint --quiet --ignore-pattern packages/acp-extension-kimi", "lint": "oxlint --type-aware --quiet --ignore-pattern packages/acp-extension-kimi", "lint:i18n": "node scripts/check-i18n.mjs", diff --git a/packages/components/src/components/ai-gui/markdown-renderer.tsx b/packages/components/src/components/ai-gui/markdown-renderer.tsx index 94e8357c2..2446221fa 100644 --- a/packages/components/src/components/ai-gui/markdown-renderer.tsx +++ b/packages/components/src/components/ai-gui/markdown-renderer.tsx @@ -1179,12 +1179,47 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ // observer is installed only for text that actually fences a diagram. useEffect(() => { const root = containerRef.current; - if (!root || !hasMermaidBlock) { + if (!root) { + return undefined; + } + + const markedDiagrams = new Map< + HTMLElement, + { role: string | null; tabIndex: string | null; ariaLabel: string | null } + >(); + const clearMarkedDiagrams = () => { + for (const [diagram, attributes] of markedDiagrams) { + if (attributes.role == null) { + diagram.removeAttribute('role'); + } else { + diagram.setAttribute('role', attributes.role); + } + if (attributes.tabIndex == null) { + diagram.removeAttribute('tabindex'); + } else { + diagram.setAttribute('tabindex', attributes.tabIndex); + } + if (attributes.ariaLabel == null) { + diagram.removeAttribute('aria-label'); + } else { + diagram.setAttribute('aria-label', attributes.ariaLabel); + } + } + markedDiagrams.clear(); + }; + if (!hasMermaidBlock) { + clearMarkedDiagrams(); return undefined; } const markDiagramsOpenable = () => { + clearMarkedDiagrams(); root.querySelectorAll(MERMAID_DIAGRAM_SELECTOR).forEach((diagram) => { + markedDiagrams.set(diagram, { + role: diagram.getAttribute('role'), + tabIndex: diagram.getAttribute('tabindex'), + ariaLabel: diagram.getAttribute('aria-label'), + }); diagram.setAttribute('role', 'button'); diagram.setAttribute('tabindex', '0'); diagram.setAttribute('aria-label', openDiagramLabel); @@ -1194,7 +1229,10 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ markDiagramsOpenable(); const observer = new MutationObserver(markDiagramsOpenable); observer.observe(root, { childList: true, subtree: true }); - return () => observer.disconnect(); + return () => { + observer.disconnect(); + clearMarkedDiagrams(); + }; }, [hasMermaidBlock, openDiagramLabel]); const components = useMemo( () => diff --git a/packages/components/src/components/onboarding/ceremony/intro-sequence.tsx b/packages/components/src/components/onboarding/ceremony/intro-sequence.tsx index 74609e458..1908d1223 100644 --- a/packages/components/src/components/onboarding/ceremony/intro-sequence.tsx +++ b/packages/components/src/components/onboarding/ceremony/intro-sequence.tsx @@ -6,6 +6,7 @@ import continuousScroll from '@/assets/onboarding/intro/continuous-scroll.png'; import readyToBegin from '@/assets/onboarding/intro/ready-to-begin.png'; import { cn } from '@/lib/utils'; import { Button } from '@/ui/button'; +import { WINDOW_DRAG_EXEMPT_CLASS } from '@/ui/window-drag-region'; import type { AudioLayers } from './use-onboarding-audio'; import { playClick, playCut, playReveal, playSelect } from './ui-sounds'; @@ -407,7 +408,10 @@ export function IntroSequence({ playClick(); setCurrent(LAST); }} - className="absolute right-8 top-7 z-10 border-b border-transparent px-1 py-1 font-mono text-[10.5px] tracking-[0.08em] text-slate-600 transition-colors hover:border-slate-500 hover:text-slate-950" + className={cn( + WINDOW_DRAG_EXEMPT_CLASS, + 'absolute right-8 top-14 z-10 border-b border-transparent px-1 py-1 font-mono text-[10.5px] tracking-[0.08em] text-slate-600 transition-colors hover:border-slate-500 hover:text-slate-950' + )} > {t('onboarding.intro.skip', 'Skip intro')} diff --git a/packages/components/src/hooks/use-app-store-review-prompt.ts b/packages/components/src/hooks/use-app-store-review-prompt.ts index 12e6e8048..cb290cc36 100644 --- a/packages/components/src/hooks/use-app-store-review-prompt.ts +++ b/packages/components/src/hooks/use-app-store-review-prompt.ts @@ -270,9 +270,9 @@ export function useAppStoreReviewPrompt({ const sessionKey = currentUserId ? `${currentUserId}:${sessionId}` : null; const bootstrappedSessionKeyRef = useRef(null); const consumedTurnIdsRef = useRef>(new Set()); - // A boolean, so the idle effect can depend on it directly: an identity-only - // history update recomputes the same value and cannot cancel a pending prompt. const hasRecentHardFailure = useMemo(() => hasRecentHardFailureOutcome(outcomes), [outcomes]); + const hasRecentHardFailureRef = useRef(hasRecentHardFailure); + hasRecentHardFailureRef.current = hasRecentHardFailure; const completedCandidateTurnId = useMemo(() => { if (!historyHydrated || !sessionCompleted || !lastCompletedAssistantMessageId) return null; const turnId = `${sessionId}:${lastCompletedAssistantMessageId}`; @@ -352,7 +352,7 @@ export function useAppStoreReviewPrompt({ state: promptState, appVersion, nowMs, - hasRecentHardFailure, + hasRecentHardFailure: hasRecentHardFailureRef.current, }); if (blockReason) { captureReviewPromptBlocked(currentUserId, blockReason); @@ -380,5 +380,5 @@ export function useAppStoreReviewPrompt({ window.removeEventListener(event, cancel, { capture: true }); } }; - }, [completedCandidateTurnId, currentUserId, hasRecentHardFailure, sessionKey, sessionOwnerId]); + }, [completedCandidateTurnId, currentUserId, sessionKey, sessionOwnerId]); } diff --git a/packages/components/src/hooks/use-billing-overview-preload.ts b/packages/components/src/hooks/use-billing-overview-preload.ts index 7a8af2a63..9df95424c 100644 --- a/packages/components/src/hooks/use-billing-overview-preload.ts +++ b/packages/components/src/hooks/use-billing-overview-preload.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useCloudQuery } from '@lody/platform/react'; import { useAuthenticatedConvex } from '@/hooks/use-authenticated-convex'; import { useAppCapability } from '@/lib/app-platform'; @@ -14,13 +14,37 @@ import { export function useBillingOverviewPreload(workspaceId: string | null): void { const billingAvailable = useAppCapability('billing'); const { authSessionId } = useAuthenticatedConvex(); + const previousWorkspaceAuthRef = useRef<{ workspaceId: string; authSessionId: string | null } | null>( + null + ); const overview = useCloudQuery( cloudOperations.billing.getBillingOverview, billingAvailable && authSessionId && workspaceId ? { workspaceId } : 'skip' ); useEffect(() => { - if (!workspaceId || !authSessionId || overview === undefined) return; + if (!workspaceId) return; + + const previousWorkspaceAuth = previousWorkspaceAuthRef.current; + if ( + previousWorkspaceAuth && + previousWorkspaceAuth.authSessionId !== authSessionId && + previousWorkspaceAuth.workspaceId !== workspaceId + ) { + clearBillingOverviewCache(previousWorkspaceAuth.workspaceId); + } else if ( + previousWorkspaceAuth?.workspaceId === workspaceId && + previousWorkspaceAuth.authSessionId !== authSessionId + ) { + clearBillingOverviewCache(workspaceId); + } + previousWorkspaceAuthRef.current = { workspaceId, authSessionId }; + + if (!billingAvailable) { + clearBillingOverviewCache(workspaceId); + return; + } + if (!authSessionId || overview === undefined) return; const cached = readBillingOverviewCache(workspaceId, authSessionId); if (overview === null) { @@ -28,5 +52,5 @@ export function useBillingOverviewPreload(workspaceId: string | null): void { } else if (!areBillingOverviewsEqual(cached, overview)) { writeBillingOverviewCache(workspaceId, authSessionId, overview); } - }, [authSessionId, overview, workspaceId]); + }, [authSessionId, billingAvailable, overview, workspaceId]); } diff --git a/packages/components/tests/app-store-review-prompt-hook.test.tsx b/packages/components/tests/app-store-review-prompt-hook.test.tsx index 6cb811290..96efc4fe1 100644 --- a/packages/components/tests/app-store-review-prompt-hook.test.tsx +++ b/packages/components/tests/app-store-review-prompt-hook.test.tsx @@ -196,6 +196,59 @@ describe('useAppStoreReviewPrompt lifecycle', () => { expect(requestReview).toHaveBeenCalledTimes(1); }); + it('keeps the idle timer when a later history update adds a recent hard failure', async () => { + const historical = eligibleHistoricalTurns(); + const baseInput = { + sessionId, + sessionOwnerId: 'hard-failure-user', + currentUserId: 'hard-failure-user', + historyHydrated: true, + sessionCompleted: true, + } as const; + + await render({ + ...baseInput, + history: historical, + lastCompletedAssistantMessageId: 'historical-49', + }); + + const completedHistory = [ + ...historical, + userTurn('eligible-user-turn', 'handled'), + assistantTurn('eligible-assistant-turn', nowMs, 'eligible-user-turn'), + ]; + await render({ + ...baseInput, + history: completedHistory, + lastCompletedAssistantMessageId: 'eligible-assistant-turn', + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000); + }); + + await render({ + ...baseInput, + history: [ + ...completedHistory, + userTurn('late-failure-user-turn', 'failed'), + ], + lastCompletedAssistantMessageId: 'eligible-assistant-turn', + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(1_499); + }); + expect(requestReview).not.toHaveBeenCalled(); + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + + expect(requestReview).not.toHaveBeenCalled(); + expect(capture).toHaveBeenCalledWith( + 'mobile/app_store_review_prompt_blocked', + expect.objectContaining({ block_reason: 'recent_hard_failure' }) + ); + }); + it('does not retry the same completed turn after real user interaction cancels its timer', async () => { const historical = eligibleHistoricalTurns(); const completedHistory = [ diff --git a/packages/components/tests/markdown-mermaid-fullscreen.test.tsx b/packages/components/tests/markdown-mermaid-fullscreen.test.tsx index 6625f14a9..251107a0d 100644 --- a/packages/components/tests/markdown-mermaid-fullscreen.test.tsx +++ b/packages/components/tests/markdown-mermaid-fullscreen.test.tsx @@ -64,6 +64,7 @@ const MERMAID_MARKDOWN = [ ' U->>K: Start whole-run task', '```', ].join('\n'); +const PLAIN_MARKDOWN = 'Just ordinary text.'; const viewer = () => document.body.querySelector('[data-testid="mermaid-diagram-viewer"]'); const viewerSurface = () => @@ -145,10 +146,13 @@ describe('mermaid full-screen viewer', () => { let root: Root | undefined; let container: HTMLDivElement | undefined; - const renderMarkdown = async () => { + const renderMarkdown = async (text = MERMAID_MARKDOWN) => { await act(async () => { - root?.render(createElement(MarkdownRenderer, { text: MERMAID_MARKDOWN })); + root?.render(createElement(MarkdownRenderer, { text })); }); + if (!text.includes('```mermaid')) { + return null; + } await flushUntil(() => Boolean(container?.querySelector('[data-streamdown="mermaid"] svg'))); const diagram = container?.querySelector('[data-streamdown="mermaid"]'); expect(diagram).toBeTruthy(); @@ -251,4 +255,14 @@ describe('mermaid full-screen viewer', () => { expect(viewer()).toBeTruthy(); }); + + it('removes diagram button semantics when the markdown rerenders without Mermaid', async () => { + await renderMarkdown(); + expect(container?.querySelector('[aria-label="Open diagram"]')).toBeTruthy(); + + await renderMarkdown(PLAIN_MARKDOWN); + + expect(container?.querySelector('[data-streamdown="mermaid"]')).toBeNull(); + expect(container?.querySelector('[aria-label="Open diagram"]')).toBeNull(); + }); }); diff --git a/packages/components/tests/use-billing-overview-preload.test.tsx b/packages/components/tests/use-billing-overview-preload.test.tsx new file mode 100644 index 000000000..3fb15c1f8 --- /dev/null +++ b/packages/components/tests/use-billing-overview-preload.test.tsx @@ -0,0 +1,116 @@ +// @vitest-environment jsdom + +import { act, createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + OPTIMISTIC_BILLING_OVERVIEW, + readBillingOverviewCache, + writeBillingOverviewCache, +} from '../src/components/settings/billing-overview-cache'; + +const useCloudQuery = vi.fn(); +const useAppCapability = vi.fn(); +const useAuthenticatedConvex = vi.fn(); + +vi.mock('@lody/platform/react', () => ({ + useCloudQuery, +})); + +vi.mock('../src/hooks/use-authenticated-convex', () => ({ + useAuthenticatedConvex, +})); + +vi.mock('../src/lib/app-platform', () => ({ + useAppCapability, +})); + +const { useBillingOverviewPreload } = await import('../src/hooks/use-billing-overview-preload'); + +function Probe({ workspaceId }: { workspaceId: string | null }) { + useBillingOverviewPreload(workspaceId); + return null; +} + +describe('useBillingOverviewPreload', () => { + let container: HTMLDivElement; + let root: Root; + let currentAuthSessionId: string | null; + + beforeEach(() => { + localStorage.clear(); + useCloudQuery.mockReset(); + useAppCapability.mockReset(); + useAuthenticatedConvex.mockReset(); + currentAuthSessionId = 'session-1'; + useAuthenticatedConvex.mockImplementation(() => ({ authSessionId: currentAuthSessionId })); + useCloudQuery.mockReturnValue(undefined); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + localStorage.clear(); + vi.restoreAllMocks(); + }); + + it('clears cached billing data when billing is unavailable for the workspace', async () => { + writeBillingOverviewCache('workspace-1', 'session-1', { + ...OPTIMISTIC_BILLING_OVERVIEW, + effectivePlanTier: 'plus', + }); + expect(readBillingOverviewCache('workspace-1', 'session-1')).toMatchObject({ + effectivePlanTier: 'plus', + }); + + useAppCapability.mockReturnValue(false); + await act(async () => { + root.render(createElement(Probe, { workspaceId: 'workspace-1' })); + }); + + expect(readBillingOverviewCache('workspace-1', 'session-1')).toBeNull(); + }); + + it('clears the workspace cache when the authenticated session changes', async () => { + useAppCapability.mockReturnValue(true); + writeBillingOverviewCache('workspace-1', 'session-1', OPTIMISTIC_BILLING_OVERVIEW); + + await act(async () => { + root.render(createElement(Probe, { workspaceId: 'workspace-1' })); + }); + expect(readBillingOverviewCache('workspace-1', 'session-1')).toMatchObject( + OPTIMISTIC_BILLING_OVERVIEW + ); + + currentAuthSessionId = 'session-2'; + await act(async () => { + root.render(createElement(Probe, { workspaceId: 'workspace-1' })); + }); + + expect(readBillingOverviewCache('workspace-1', 'session-1')).toBeNull(); + expect(readBillingOverviewCache('workspace-1', 'session-2')).toBeNull(); + }); + + it('clears the previous workspace cache when the session changes during a workspace switch', async () => { + useAppCapability.mockReturnValue(true); + writeBillingOverviewCache('workspace-1', 'session-1', OPTIMISTIC_BILLING_OVERVIEW); + + await act(async () => { + root.render(createElement(Probe, { workspaceId: 'workspace-1' })); + }); + expect(readBillingOverviewCache('workspace-1', 'session-1')).toMatchObject( + OPTIMISTIC_BILLING_OVERVIEW + ); + + currentAuthSessionId = 'session-2'; + await act(async () => { + root.render(createElement(Probe, { workspaceId: 'workspace-2' })); + }); + + expect(readBillingOverviewCache('workspace-1', 'session-1')).toBeNull(); + }); +}); diff --git a/packages/shared/src/node/local-cli-host-lease.cjs b/packages/shared/src/node/local-cli-host-lease.cjs index 759efb4a0..c664c382d 100644 --- a/packages/shared/src/node/local-cli-host-lease.cjs +++ b/packages/shared/src/node/local-cli-host-lease.cjs @@ -5,6 +5,9 @@ const { getInstallationProfile } = require('./installation-profile.cjs'); const HOST_TCP_ADDRESS = '127.0.0.1'; const HOST_REQUEST_TIMEOUT_MS = 800; +const E2E_HOST_PORT_ENV = 'LODY_E2E_LOCAL_CLI_HOST_PORT'; +const E2E_HOST_PIPE_ENV = 'LODY_E2E_LOCAL_CLI_HOST_PIPE'; +const E2E_HOST_PIPE_PATTERN = /^\\\\\.\\pipe\\lody-e2e-[A-Za-z0-9-]{1,80}$/u; function getUserPipeSuffix() { if (typeof process.getuid === 'function') return String(process.getuid()); @@ -16,15 +19,44 @@ function getUserPipeSuffix() { .slice(0, 16); } +function getE2eHostPort() { + if (process.env.LODY_E2E !== '1') return null; + const rawPort = process.env[E2E_HOST_PORT_ENV]?.trim(); + if (!rawPort) return null; + if (!/^\d+$/u.test(rawPort)) { + throw new Error(`${E2E_HOST_PORT_ENV} must be an integer between 1024 and 65535`); + } + const port = Number(rawPort); + if (!Number.isSafeInteger(port) || port < 1024 || port > 65_535) { + throw new Error(`${E2E_HOST_PORT_ENV} must be an integer between 1024 and 65535`); + } + return port; +} + +function getE2eHostPipe(nodePlatform = process.platform) { + if (nodePlatform !== 'win32' || process.env.LODY_E2E !== '1') return null; + const pipe = process.env[E2E_HOST_PIPE_ENV]?.trim(); + if (!pipe) return null; + if (!E2E_HOST_PIPE_PATTERN.test(pipe)) { + throw new Error(`${E2E_HOST_PIPE_ENV} must use the \\\\.\\pipe\\lody-e2e- namespace`); + } + return pipe; +} + function getLocalCliHostEndpoint(platform) { const profile = getInstallationProfile(platform); if (process.platform === 'win32') { return { kind: 'pipe', - path: `\\\\.\\pipe\\${profile.namespace}-agent-host-${getUserPipeSuffix()}`, + path: + getE2eHostPipe() ?? `\\\\.\\pipe\\${profile.namespace}-agent-host-${getUserPipeSuffix()}`, }; } - return { kind: 'tcp', host: HOST_TCP_ADDRESS, port: profile.localCliHostPort }; + return { + kind: 'tcp', + host: HOST_TCP_ADDRESS, + port: getE2eHostPort() ?? profile.localCliHostPort, + }; } function throwIfAborted(signal) { @@ -278,6 +310,7 @@ async function requestLocalCliHostShutdown(options) { module.exports = { acquireLocalCliHostLease, + getE2eHostPipe, getLocalCliHostEndpoint, inspectLocalCliHost, requestLocalCliHostShutdown, diff --git a/packages/shared/src/node/local-cli-host-lease.ts b/packages/shared/src/node/local-cli-host-lease.ts index e088ff4ca..387c3b559 100644 --- a/packages/shared/src/node/local-cli-host-lease.ts +++ b/packages/shared/src/node/local-cli-host-lease.ts @@ -6,6 +6,9 @@ import type { PlatformKind } from '../platform-kind'; const HOST_TCP_ADDRESS = '127.0.0.1'; const HOST_REQUEST_TIMEOUT_MS = 800; +const E2E_HOST_PORT_ENV = 'LODY_E2E_LOCAL_CLI_HOST_PORT'; +const E2E_HOST_PIPE_ENV = 'LODY_E2E_LOCAL_CLI_HOST_PIPE'; +const E2E_HOST_PIPE_PATTERN = /^\\\\\.\\pipe\\lody-e2e-[A-Za-z0-9-]{1,80}$/u; export type LocalCliHostMode = 'daemon' | 'electron' | 'foreground'; @@ -56,15 +59,44 @@ function getUserPipeSuffix(): string { .slice(0, 16); } +function getE2eHostPort(): number | null { + if (process.env.LODY_E2E !== '1') return null; + const rawPort = process.env[E2E_HOST_PORT_ENV]?.trim(); + if (!rawPort) return null; + if (!/^\d+$/u.test(rawPort)) { + throw new Error(`${E2E_HOST_PORT_ENV} must be an integer between 1024 and 65535`); + } + const port = Number(rawPort); + if (!Number.isSafeInteger(port) || port < 1024 || port > 65_535) { + throw new Error(`${E2E_HOST_PORT_ENV} must be an integer between 1024 and 65535`); + } + return port; +} + +export function getE2eHostPipe(nodePlatform = process.platform): string | null { + if (nodePlatform !== 'win32' || process.env.LODY_E2E !== '1') return null; + const pipe = process.env[E2E_HOST_PIPE_ENV]?.trim(); + if (!pipe) return null; + if (!E2E_HOST_PIPE_PATTERN.test(pipe)) { + throw new Error(`${E2E_HOST_PIPE_ENV} must use the \\\\.\\pipe\\lody-e2e- namespace`); + } + return pipe; +} + export function getLocalCliHostEndpoint(platform?: PlatformKind): LocalCliHostEndpoint { const profile = getInstallationProfile(platform); if (process.platform === 'win32') { return { kind: 'pipe', - path: `\\\\.\\pipe\\${profile.namespace}-agent-host-${getUserPipeSuffix()}`, + path: + getE2eHostPipe() ?? `\\\\.\\pipe\\${profile.namespace}-agent-host-${getUserPipeSuffix()}`, }; } - return { kind: 'tcp', host: HOST_TCP_ADDRESS, port: profile.localCliHostPort }; + return { + kind: 'tcp', + host: HOST_TCP_ADDRESS, + port: getE2eHostPort() ?? profile.localCliHostPort, + }; } function throwIfAborted(signal: AbortSignal | undefined): void { diff --git a/packages/shared/tests/installation-profile.test.ts b/packages/shared/tests/installation-profile.test.ts index fb8a46930..3ebae016a 100644 --- a/packages/shared/tests/installation-profile.test.ts +++ b/packages/shared/tests/installation-profile.test.ts @@ -1,11 +1,8 @@ import path from 'node:path'; import { createRequire } from 'node:module'; -import { describe, expect, it } from 'vitest'; -import { - getInstallationProfile, - getLodyDataDir, -} from '../src/node/installation-profile'; -import { getLocalCliHostEndpoint } from '../src/node/local-cli-host-lease'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getInstallationProfile, getLodyDataDir } from '../src/node/installation-profile'; +import { getE2eHostPipe, getLocalCliHostEndpoint } from '../src/node/local-cli-host-lease'; import { getLocalControlSocketPath, getLocalDaemonRunDir, @@ -16,6 +13,10 @@ import { getLocalWorkspaceCatalogPath } from '../src/node/local-workspace-catalo const require = createRequire(import.meta.url); +afterEach(() => { + vi.unstubAllEnvs(); +}); + describe('installation profile', () => { it('keeps cloud defaults backward compatible and gives local a disjoint namespace', () => { expect(getInstallationProfile('cloud')).toMatchObject({ @@ -47,6 +48,48 @@ describe('installation profile', () => { } }); + it.runIf(process.platform !== 'win32')( + 'uses an isolated host port only for an explicit E2E process', + () => { + vi.stubEnv('LODY_E2E', '1'); + vi.stubEnv('LODY_E2E_LOCAL_CLI_HOST_PORT', '29471'); + + expect(getLocalCliHostEndpoint('local')).toEqual({ + kind: 'tcp', + host: '127.0.0.1', + port: 29_471, + }); + + vi.stubEnv('LODY_E2E', '0'); + expect(getLocalCliHostEndpoint('local')).toEqual({ + kind: 'tcp', + host: '127.0.0.1', + port: 17_789, + }); + } + ); + + it.runIf(process.platform !== 'win32')('rejects an invalid E2E host port', () => { + vi.stubEnv('LODY_E2E', '1'); + vi.stubEnv('LODY_E2E_LOCAL_CLI_HOST_PORT', '17789junk'); + + expect(() => getLocalCliHostEndpoint('local')).toThrow( + 'LODY_E2E_LOCAL_CLI_HOST_PORT must be an integer between 1024 and 65535' + ); + }); + + it('accepts only an explicit E2E-scoped Windows pipe', () => { + vi.stubEnv('LODY_E2E', '1'); + vi.stubEnv('LODY_E2E_LOCAL_CLI_HOST_PIPE', '\\\\.\\pipe\\lody-e2e-round-123'); + expect(getE2eHostPipe('win32')).toBe('\\\\.\\pipe\\lody-e2e-round-123'); + expect(getE2eHostPipe('darwin')).toBeNull(); + + vi.stubEnv('LODY_E2E_LOCAL_CLI_HOST_PIPE', '\\\\.\\pipe\\lody-agent-host-user'); + expect(() => getE2eHostPipe('win32')).toThrow( + 'LODY_E2E_LOCAL_CLI_HOST_PIPE must use the \\\\.\\pipe\\lody-e2e- namespace' + ); + }); + it('keeps Electron main-process paths isolated without ambient LODY_PLATFORM', () => { const previousPlatform = process.env.LODY_PLATFORM; const previousDataDir = process.env.LODY_DATA_DIR; @@ -70,10 +113,11 @@ describe('installation profile', () => { }); it('keeps the CommonJS installation profile in parity', () => { - const commonJs = require('../src/node/installation-profile.cjs') as typeof import('../src/node/installation-profile'); + const commonJs = + require('../src/node/installation-profile.cjs') as typeof import('../src/node/installation-profile'); expect(commonJs.getInstallationProfile('local')).toEqual(getInstallationProfile('local')); expect(commonJs.getLodyDataDir('local', '/home/alice')).toBe( - getLodyDataDir('local', '/home/alice'), + getLodyDataDir('local', '/home/alice') ); }); @@ -81,8 +125,15 @@ describe('installation profile', () => { const commonJs = require('../src/node/local-terminal.cjs') as { getLocalTerminalSocketPath(platform?: 'local' | 'cloud'): string; }; - expect(commonJs.getLocalTerminalSocketPath('local')).toBe( - getLocalTerminalSocketPath('local'), - ); + expect(commonJs.getLocalTerminalSocketPath('local')).toBe(getLocalTerminalSocketPath('local')); + }); + + it('keeps the CommonJS E2E pipe parser in parity', () => { + vi.stubEnv('LODY_E2E', '1'); + vi.stubEnv('LODY_E2E_LOCAL_CLI_HOST_PIPE', '\\\\.\\pipe\\lody-e2e-parity'); + const commonJs = require('../src/node/local-cli-host-lease.cjs') as { + getE2eHostPipe(nodePlatform?: NodeJS.Platform): string | null; + }; + expect(commonJs.getE2eHostPipe('win32')).toBe(getE2eHostPipe('win32')); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4190b87e8..987195aa8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -550,6 +550,27 @@ importers: specifier: ^3.5.0 version: 3.5.0(vite@7.3.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2)) + e2e: + devDependencies: + '@agentclientprotocol/sdk': + specifier: 'catalog:' + version: 1.3.0(zod@4.3.6) + '@cucumber/cucumber': + specifier: ^13.2.1 + version: 13.2.1 + '@playwright/test': + specifier: ^1.58.2 + version: 1.58.2 + '@types/node': + specifier: 'catalog:' + version: 24.10.12 + tsx: + specifier: ^4.23.5 + version: 4.23.7 + typescript: + specifier: 'catalog:' + version: 5.9.3 + packages/acp-extension-claude: dependencies: '@agentclientprotocol/sdk': @@ -842,7 +863,7 @@ importers: version: 0.10.9(@assistant-ui/react@0.10.50(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(immer@11.1.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0)))(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@better-auth/electron': specifier: 'catalog:' - version: 1.5.5(patch_hash=c0bab8bf6f42816473109d61f757f961d4f3bcc1b1cd07c4fe7c2f012fd291ac)(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-auth@1.5.5(532ad3d3c7da62ce07897da849d81e18))(better-call@1.3.2(zod@4.3.6))(conf@15.1.0)(electron@39.5.1) + version: 1.5.5(patch_hash=c0bab8bf6f42816473109d61f757f961d4f3bcc1b1cd07c4fe7c2f012fd291ac)(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-auth@1.5.5(dcb2c8ba3b09f267bb48cd58cd0877bd))(better-call@1.3.2(zod@4.3.6))(conf@15.1.0)(electron@39.5.1) '@capacitor/browser': specifier: ^8.0.0 version: 8.0.3(@capacitor/core@8.5.0) @@ -854,7 +875,7 @@ importers: version: 8.0.1(@capacitor/core@8.5.0) '@convex-dev/better-auth': specifier: 'catalog:' - version: 0.11.2(@standard-schema/spec@1.1.0)(better-auth@1.5.5(532ad3d3c7da62ce07897da849d81e18))(convex@1.33.1(react@19.2.0))(hono@4.12.14)(react@19.2.0)(typescript@5.9.3) + version: 0.11.2(@standard-schema/spec@1.1.0)(better-auth@1.5.5(dcb2c8ba3b09f267bb48cd58cd0877bd))(convex@1.33.1(react@19.2.0))(hono@4.12.14)(react@19.2.0)(typescript@5.9.3) '@diceui/shared': specifier: 0.12.0 version: 0.12.0(@floating-ui/react@0.27.17(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -1019,10 +1040,10 @@ importers: version: 1.1.3 better-auth: specifier: 'catalog:' - version: 1.5.5(532ad3d3c7da62ce07897da849d81e18) + version: 1.5.5(dcb2c8ba3b09f267bb48cd58cd0877bd) better-auth-capacitor: specifier: 'catalog:' - version: 0.3.6(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.2.0))(@capacitor/app@8.1.0(@capacitor/core@8.5.0))(@capacitor/core@8.5.0)(@capacitor/preferences@8.0.1(@capacitor/core@8.5.0))(better-auth@1.5.5(532ad3d3c7da62ce07897da849d81e18)) + version: 0.3.6(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.2.0))(@capacitor/app@8.1.0(@capacitor/core@8.5.0))(@capacitor/core@8.5.0)(@capacitor/preferences@8.0.1(@capacitor/core@8.5.0))(better-auth@1.5.5(dcb2c8ba3b09f267bb48cd58cd0877bd)) broadcast-channel: specifier: ^7.0.0 version: 7.3.0 @@ -1043,7 +1064,7 @@ importers: version: 4.1.0 debug: specifier: ^4.4.3 - version: 4.4.3 + version: 4.4.3(supports-color@11.0.0) effect: specifier: 'catalog:' version: 3.18.4 @@ -1230,7 +1251,7 @@ importers: version: 1.159.4 '@tanstack/router-plugin': specifier: 'catalog:' - version: 1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.24.2)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2)) + version: 1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.28.2)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2)) '@types/debug': specifier: ^4.1.12 version: 4.1.12 @@ -2087,6 +2108,74 @@ packages: resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} + '@cucumber/ci-environment@14.0.0': + resolution: {integrity: sha512-CJbjPPGuk1fArPRVKB6FU2q9FHxOQOeq7IEJcB2NLNhOhV97uazLxvwD5ucAJb/flky+czOLhvaYaRAF6l5QYQ==} + + '@cucumber/cucumber-expressions@20.0.0': + resolution: {integrity: sha512-t2FBLKuU0U1nP5FM8pHiVonYcXVm07fvXr/H9osdvrjcLXSWc5OCbOOqV/k5S56w/YndPv1Sv2/m/x1KBYvIlA==} + + '@cucumber/cucumber@13.2.1': + resolution: {integrity: sha512-Km3mLbZKOE/TlR9ExGWcG7PwOG1ArMt/R6BexrMhGA8pLGaLCWYISTHStpftLuuhVKvVd3RO5M/pfARKyvcyWg==} + engines: {node: 22 || 24 || >=26} + hasBin: true + + '@cucumber/gherkin-streams@7.0.1': + resolution: {integrity: sha512-8W1lmof0hbQ1HgOEpPCkz1KCFaM6MfA7n7rr6vX/7ffgUQ0saOAwapKSVj7P68Aht0K9XqHaw8BDLtqaK/jRIg==} + hasBin: true + peerDependencies: + '@cucumber/gherkin': '>=22.0.0' + '@cucumber/message-streams': '>=4.0.0' + '@cucumber/messages': '>=17.1.1' + + '@cucumber/gherkin-utils@12.0.1': + resolution: {integrity: sha512-vImHbmTzvGcZP4SFSbx7P6janHZN+BqGWXc3EIQ7UeYMeDzwV9ybSlRfpECV340pxKYZyMTtuSCP3CDXfuWCQw==} + hasBin: true + + '@cucumber/gherkin@41.0.0': + resolution: {integrity: sha512-pKGx1EzNjtWbpw74kEevKDMj71dF3ZSaFJpLYuWVvRZKe+Cwoq5iEkuMaELIg1jxIu8jH/A2HPMpMR8UBvdG0w==} + + '@cucumber/gherkin@42.0.0': + resolution: {integrity: sha512-kBcv+NV4FGQYX6NIsSCjsjaX8MsRdEH559BQ9xEFPgkLQX/Z//JZrY94fpjxW6quo4V5kxlzFiiVC5xouF9Akg==} + + '@cucumber/html-formatter@24.1.0': + resolution: {integrity: sha512-5TBLnBT+tIvtamw/jtblBVgzQ2/ckd+md8I0rwkEk29vFMCF68jNwhyONGq6leUYMq4kVF+KdFhE8xGrSc3kJw==} + peerDependencies: + '@cucumber/messages': '>=18' + + '@cucumber/junit-xml-formatter@0.14.0': + resolution: {integrity: sha512-uDuJySsUr1b1VEAERC+YywvVhygBgYIAMyfSMzLZ3LrourudaWZIhfTND4uLNkRZZQcPr6IYv+qj5TP8RTJJ+g==} + peerDependencies: + '@cucumber/messages': '*' + + '@cucumber/message-streams@5.0.1': + resolution: {integrity: sha512-MXsD7ZAWWAiWMrn3Lzq2nwu9DJpPbbvdqXj2ot1BSM2gXnoSvg8d0pc6PzS9pI02swMSn/KN11mTdF5ScE7X0Q==} + peerDependencies: + '@cucumber/messages': '>=17.1.1' + + '@cucumber/messages@33.0.4': + resolution: {integrity: sha512-i6P1a0bnmhecOHfvIW0rhMxv/gMKfBKwDMxmD9/Uo1HSqpQ17ocHms1JwWW6uTMLAopqqWlcz7l6Pax+qUOTzg==} + + '@cucumber/messages@34.2.0': + resolution: {integrity: sha512-6X6T1TApmbQNqxC0Oh3GcnLU+gfE5fp+cEXE/ml6+Ldx76Q6BDmkgM5vagSSDXP+vetPV2Fb0kWVeP7QIR6+Aw==} + + '@cucumber/pretty-formatter@4.0.0': + resolution: {integrity: sha512-vWKHUfjMphtbeHHlQBZ1gQzPL3/HE/O2dNnpBqEOIDwvhp7tKvqSe8kscp2H7pArbxN6euEpR5JbstJdzo3iyg==} + peerDependencies: + '@cucumber/messages': '*' + + '@cucumber/query@16.0.0': + resolution: {integrity: sha512-CtGBHLc92y1RNogXGOFVF7so+XdLR5rVuJGsS6nH7yLeayyYhdV8lYW/LG8R05cm4irG/RO6Wn5I2CAgxMhmwQ==} + peerDependencies: + '@cucumber/messages': '*' + + '@cucumber/query@16.1.1': + resolution: {integrity: sha512-SBaP4Lc7xv55jY78Q8raoxvbBIPGhQKrui/Tq+l6jYBl1alV0TG1sW8TRtk9W7RQl27nVJo9DyyvASp374L9Tg==} + peerDependencies: + '@cucumber/messages': '*' + + '@cucumber/tag-expressions@11.0.0': + resolution: {integrity: sha512-7Uo6dYST8xZbHwwemXCt7Kv80Yh/SE85DyOed1/FTwxRXs3NYVOCm6e/h8DxlhTGIpD6rYXfpY3nHnIXAFMBlw==} + '@dabh/diagnostics@2.0.8': resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} @@ -2249,6 +2338,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.28.2': resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} @@ -2273,6 +2368,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.28.2': resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} engines: {node: '>=18'} @@ -2297,6 +2398,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.28.2': resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} engines: {node: '>=18'} @@ -2321,6 +2428,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.28.2': resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} engines: {node: '>=18'} @@ -2345,6 +2458,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.28.2': resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} engines: {node: '>=18'} @@ -2369,6 +2488,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.28.2': resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} engines: {node: '>=18'} @@ -2393,6 +2518,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.28.2': resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} engines: {node: '>=18'} @@ -2417,6 +2548,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.28.2': resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} engines: {node: '>=18'} @@ -2441,6 +2578,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.28.2': resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} engines: {node: '>=18'} @@ -2465,6 +2608,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.28.2': resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} @@ -2489,6 +2638,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.28.2': resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} @@ -2513,6 +2668,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.28.2': resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} engines: {node: '>=18'} @@ -2537,6 +2698,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.28.2': resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} engines: {node: '>=18'} @@ -2561,6 +2728,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.28.2': resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} engines: {node: '>=18'} @@ -2585,6 +2758,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.28.2': resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} engines: {node: '>=18'} @@ -2609,6 +2788,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.28.2': resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} engines: {node: '>=18'} @@ -2633,6 +2818,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.28.2': resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} engines: {node: '>=18'} @@ -2657,6 +2848,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.28.2': resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} engines: {node: '>=18'} @@ -2681,6 +2878,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.28.2': resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} engines: {node: '>=18'} @@ -2705,6 +2908,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.28.2': resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} engines: {node: '>=18'} @@ -2729,6 +2938,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.28.2': resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} engines: {node: '>=18'} @@ -2747,6 +2962,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/openharmony-arm64@0.28.2': resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} engines: {node: '>=18'} @@ -2771,6 +2992,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.28.2': resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} engines: {node: '>=18'} @@ -2795,6 +3022,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.28.2': resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} engines: {node: '>=18'} @@ -2819,6 +3052,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.28.2': resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} engines: {node: '>=18'} @@ -2843,6 +3082,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.28.2': resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} engines: {node: '>=18'} @@ -5588,6 +5833,10 @@ packages: resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==} engines: {node: '>=20.19'} + '@teppeis/multimaps@3.0.0': + resolution: {integrity: sha512-ID7fosbc50TbT0MK0EG12O+gAP3W3Aa/Pz4DaTtQtEvlc9Odaqi0de+xuZ7Li2GtK4HzEX7IuRWS/JmZLksR3Q==} + engines: {node: '>=14'} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -5831,6 +6080,9 @@ packages: '@types/node@26.2.0': resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==} + '@types/normalize-package-data@2.4.4': + resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/pg@8.15.6': resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} @@ -6599,6 +6851,9 @@ packages: resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==} engines: {node: '>=12.0.0'} + assertion-error-formatter@3.0.0: + resolution: {integrity: sha512-6YyAVLrEze0kQ7CmJfUgrLHb+Y7XghmL2Ie7ijVa2Y9ynP3LV+VDiwFk62Dn0qtqbmY0BT0ss6p1xxpiF2PYbQ==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -7061,6 +7316,10 @@ packages: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -7828,6 +8087,9 @@ packages: err-code@2.0.3: resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} + error-stack-parser@2.1.4: + resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} + es-abstract@1.24.1: resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} engines: {node: '>= 0.4'} @@ -7898,6 +8160,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.28.2: resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} @@ -8177,6 +8444,10 @@ packages: fflate@0.8.3: resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -8202,6 +8473,10 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-up-simple@1.0.1: + resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} + engines: {node: '>=18'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -8505,6 +8780,10 @@ packages: resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} engines: {node: '>=10.0'} + global-directory@4.0.1: + resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} + engines: {node: '>=18'} + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -8563,6 +8842,10 @@ packages: hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + has-ansi@6.0.2: + resolution: {integrity: sha512-vAyM+6+jAYwSwz0/M0jYKfU9AvAMCz0kH791RsUhvMKGUHXled/3FjcQB3YiQ4Astj5srHdb6B2FHGIfZkOQNg==} + engines: {node: '>=18'} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -8689,6 +8972,10 @@ packages: resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} engines: {node: '>=10'} + hosted-git-info@9.0.3: + resolution: {integrity: sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==} + engines: {node: ^20.17.0 || >=22.9.0} + html-encoding-sniffer@4.0.0: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} @@ -8787,6 +9074,14 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + + index-to-position@1.2.0: + resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} + engines: {node: '>=18'} + inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -8797,6 +9092,10 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + ini@4.1.1: + resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -8910,6 +9209,10 @@ packages: engines: {node: '>=14.16'} hasBin: true + is-installed-globally@1.0.0: + resolution: {integrity: sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==} + engines: {node: '>=18'} + is-interactive@2.0.0: resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} engines: {node: '>=12'} @@ -9191,6 +9494,9 @@ packages: khroma@2.1.0: resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + knuth-shuffle-seeded@1.0.6: + resolution: {integrity: sha512-9pFH0SplrfyKyojCLxZfMcvkhf5hH0d+UwR9nTVJ/DDQJGuzcXjTwB7TP7sDfehSudlGGaOLblmEWqv04ERVWg==} + kolorist@1.8.0: resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} @@ -9320,6 +9626,12 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.mergewith@4.6.2: + resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==} + + lodash.sortby@4.7.0: + resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} + lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -9458,6 +9770,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -9736,6 +10052,11 @@ packages: engines: {node: '>=4.0.0'} hasBin: true + mime@4.1.0: + resolution: {integrity: sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==} + engines: {node: '>=16'} + hasBin: true + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -9980,6 +10301,10 @@ packages: engines: {node: ^20.17.0 || >=22.9.0} hasBin: true + normalize-package-data@8.0.0: + resolution: {integrity: sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ==} + engines: {node: ^20.17.0 || >=22.9.0} + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -10155,6 +10480,10 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + pad-right@0.2.2: + resolution: {integrity: sha512-4cy8M95ioIGolCoMmm2cMntGR1lPLEbOMzOKu8bzjuJP6JpzEMQcDHmh7hHLYGgob+nKe1YHFMaG4V59HQa89g==} + engines: {node: '>=0.10.0'} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -10162,6 +10491,10 @@ packages: parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse-json@8.3.0: + resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} + engines: {node: '>=18'} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -10419,6 +10752,9 @@ packages: proper-lockfile@4.1.2: resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + property-expr@2.0.6: + resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==} + property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} @@ -10772,6 +11108,14 @@ packages: resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==} hasBin: true + read-package-up@12.0.0: + resolution: {integrity: sha512-Q5hMVBYur/eQNWDdbF4/Wqqr9Bjvtrw2kjGxxBbKLbx8bVCL8gcArjTy8zDUuLGQicftpMuU0riQNcAsbtOVsw==} + engines: {node: '>=20'} + + read-pkg@10.1.0: + resolution: {integrity: sha512-I8g2lArQiP78ll51UeMZojewtYgIRCKCWqZEgOO8c/uefTI+XDXvCSXu3+YNUaTNvZzobrL5+SqHjBrByRRTdg==} + engines: {node: '>=20'} + readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} @@ -10840,9 +11184,16 @@ packages: regex@6.1.0: resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + regexp-match-indices@1.0.2: + resolution: {integrity: sha512-DwZuAkt8NF5mKwGGER1EGh2PRqyvhRhhLviH+R8y8dIuaQROlUfXjt4s9ZTXstIsSkptf06BSvwcEmmfheJJWQ==} + regexp-to-ast@0.5.0: resolution: {integrity: sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==} + regexp-tree@0.1.27: + resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} + hasBin: true + regexp.prototype.flags@1.5.4: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} @@ -10901,6 +11252,10 @@ packages: remend@1.3.0: resolution: {integrity: sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw==} + repeat-string@1.6.1: + resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} + engines: {node: '>=0.10'} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -11058,6 +11413,9 @@ packages: secure-json-parse@4.1.0: resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + seed-random@2.2.0: + resolution: {integrity: sha512-34EQV6AAHQGhoc0tn/96a9Fsi6v2xdqe/dMUwljGRaFOzR3EgRmECvD0O8vi8X+/uQ50LGHfkNu/Eue5TPKZkQ==} + semver-compare@1.0.0: resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} @@ -11079,6 +11437,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + send@0.19.2: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} @@ -11247,6 +11610,18 @@ packages: sparse-bitfield@3.0.3: resolution: {integrity: sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==} + spdx-correct@3.2.0: + resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} + + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@3.0.1: + resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} + + spdx-license-ids@3.0.23: + resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + split-ca@1.0.1: resolution: {integrity: sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==} @@ -11275,6 +11650,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + stackframe@1.3.4: + resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} + standardwebhooks@1.0.0: resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} @@ -11416,6 +11794,10 @@ packages: resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} engines: {node: '>= 8.0'} + supports-color@11.0.0: + resolution: {integrity: sha512-/zyImLdxhdygBIaVX0xTlQhKaCDLCrm665aqHk8xqK/Pa6k61fL6gwQGQq1k31yNyz6x55PppQgF2DMyPQa5xw==} + engines: {node: '>=22'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -11500,6 +11882,9 @@ packages: tiny-async-pool@1.3.0: resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==} + tiny-case@1.0.3: + resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -11573,6 +11958,9 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + toposort@2.0.2: + resolution: {integrity: sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==} + tough-cookie@5.1.2: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} @@ -11678,6 +12066,10 @@ packages: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} + type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + type-fest@4.41.0: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} @@ -11930,6 +12322,9 @@ packages: utf8-byte-length@1.0.5: resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==} + util-arity@1.1.0: + resolution: {integrity: sha512-kkyIsXKwemfSy8ZEoaIz06ApApnWsk5hQO0vLjZS6UkBiGiW++Jsyb8vSBoc0WKlffGoGs5yYy/j5pp8zckrFA==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -11961,6 +12356,9 @@ packages: typescript: optional: true + validate-npm-package-license@3.0.4: + resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} + validator@13.15.35: resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==} engines: {node: '>= 0.10'} @@ -12515,6 +12913,9 @@ packages: resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} engines: {node: '>=18'} + yup@1.7.1: + resolution: {integrity: sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==} + zeptomatch@2.1.0: resolution: {integrity: sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==} @@ -12725,7 +13126,7 @@ snapshots: '@babel/types': 7.29.0 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -12816,7 +13217,7 @@ snapshots: '@babel/parser': 7.29.0 '@babel/template': 7.28.6 '@babel/types': 7.29.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) transitivePeerDependencies: - supports-color @@ -12888,24 +13289,24 @@ snapshots: optionalDependencies: drizzle-orm: 0.45.1(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.0(prisma@7.4.0(@types/react@19.2.17)(better-sqlite3@13.0.3)(magicast@0.3.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@13.0.3)(kysely@0.28.11)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.0(@types/react@19.2.17)(better-sqlite3@13.0.3)(magicast@0.3.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)) - '@better-auth/electron@1.5.5(patch_hash=c0bab8bf6f42816473109d61f757f961d4f3bcc1b1cd07c4fe7c2f012fd291ac)(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-auth@1.5.5(532ad3d3c7da62ce07897da849d81e18))(better-call@1.3.2(zod@4.3.6))(conf@15.1.0)(electron@39.5.1)': + '@better-auth/electron@1.5.5(patch_hash=c0bab8bf6f42816473109d61f757f961d4f3bcc1b1cd07c4fe7c2f012fd291ac)(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-auth@1.5.5(817ae14f18a6fb615413e202a25d2574))(better-call@1.3.2(zod@4.3.6))(conf@15.1.0)(electron@39.5.1)': dependencies: '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.2.0) '@better-auth/utils': 0.3.1 '@better-fetch/fetch': 1.1.21 - better-auth: 1.5.5(532ad3d3c7da62ce07897da849d81e18) + better-auth: 1.5.5(817ae14f18a6fb615413e202a25d2574) better-call: 1.3.2(zod@4.3.6) zod: 4.3.6 optionalDependencies: conf: 15.1.0 electron: 39.5.1 - '@better-auth/electron@1.5.5(patch_hash=c0bab8bf6f42816473109d61f757f961d4f3bcc1b1cd07c4fe7c2f012fd291ac)(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-auth@1.5.5(817ae14f18a6fb615413e202a25d2574))(better-call@1.3.2(zod@4.3.6))(conf@15.1.0)(electron@39.5.1)': + '@better-auth/electron@1.5.5(patch_hash=c0bab8bf6f42816473109d61f757f961d4f3bcc1b1cd07c4fe7c2f012fd291ac)(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-auth@1.5.5(dcb2c8ba3b09f267bb48cd58cd0877bd))(better-call@1.3.2(zod@4.3.6))(conf@15.1.0)(electron@39.5.1)': dependencies: '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.2.0) '@better-auth/utils': 0.3.1 '@better-fetch/fetch': 1.1.21 - better-auth: 1.5.5(817ae14f18a6fb615413e202a25d2574) + better-auth: 1.5.5(dcb2c8ba3b09f267bb48cd58cd0877bd) better-call: 1.3.2(zod@4.3.6) zod: 4.3.6 optionalDependencies: @@ -13265,10 +13666,10 @@ snapshots: '@colors/colors@1.6.0': {} - '@convex-dev/better-auth@0.11.2(@standard-schema/spec@1.1.0)(better-auth@1.5.5(532ad3d3c7da62ce07897da849d81e18))(convex@1.33.1(react@19.2.0))(hono@4.12.14)(react@19.2.0)(typescript@5.9.3)': + '@convex-dev/better-auth@0.11.2(@standard-schema/spec@1.1.0)(better-auth@1.5.5(817ae14f18a6fb615413e202a25d2574))(convex@1.33.1(react@19.2.0))(hono@4.12.14)(react@19.2.0)(typescript@5.9.3)': dependencies: '@better-fetch/fetch': 1.1.21 - better-auth: 1.5.5(532ad3d3c7da62ce07897da849d81e18) + better-auth: 1.5.5(817ae14f18a6fb615413e202a25d2574) common-tags: 1.8.2 convex: 1.33.1(react@19.2.0) convex-helpers: 0.1.111(@standard-schema/spec@1.1.0)(convex@1.33.1(react@19.2.0))(hono@4.12.14)(react@19.2.0)(typescript@5.9.3)(zod@4.3.6) @@ -13283,10 +13684,10 @@ snapshots: - hono - typescript - '@convex-dev/better-auth@0.11.2(@standard-schema/spec@1.1.0)(better-auth@1.5.5(817ae14f18a6fb615413e202a25d2574))(convex@1.33.1(react@19.2.0))(hono@4.12.14)(react@19.2.0)(typescript@5.9.3)': + '@convex-dev/better-auth@0.11.2(@standard-schema/spec@1.1.0)(better-auth@1.5.5(dcb2c8ba3b09f267bb48cd58cd0877bd))(convex@1.33.1(react@19.2.0))(hono@4.12.14)(react@19.2.0)(typescript@5.9.3)': dependencies: '@better-fetch/fetch': 1.1.21 - better-auth: 1.5.5(817ae14f18a6fb615413e202a25d2574) + better-auth: 1.5.5(dcb2c8ba3b09f267bb48cd58cd0877bd) common-tags: 1.8.2 convex: 1.33.1(react@19.2.0) convex-helpers: 0.1.111(@standard-schema/spec@1.1.0)(convex@1.33.1(react@19.2.0))(hono@4.12.14)(react@19.2.0)(typescript@5.9.3)(zod@4.3.6) @@ -13343,6 +13744,112 @@ snapshots: '@csstools/css-tokenizer@3.0.4': {} + '@cucumber/ci-environment@14.0.0': {} + + '@cucumber/cucumber-expressions@20.0.0': + dependencies: + regexp-match-indices: 1.0.2 + + '@cucumber/cucumber@13.2.1': + dependencies: + '@cucumber/ci-environment': 14.0.0 + '@cucumber/cucumber-expressions': 20.0.0 + '@cucumber/gherkin': 42.0.0 + '@cucumber/gherkin-streams': 7.0.1(@cucumber/gherkin@42.0.0)(@cucumber/message-streams@5.0.1(@cucumber/messages@34.2.0))(@cucumber/messages@34.2.0) + '@cucumber/gherkin-utils': 12.0.1 + '@cucumber/html-formatter': 24.1.0(@cucumber/messages@34.2.0) + '@cucumber/junit-xml-formatter': 0.14.0(@cucumber/messages@34.2.0) + '@cucumber/message-streams': 5.0.1(@cucumber/messages@34.2.0) + '@cucumber/messages': 34.2.0 + '@cucumber/pretty-formatter': 4.0.0(@cucumber/messages@34.2.0) + '@cucumber/tag-expressions': 11.0.0 + assertion-error-formatter: 3.0.0 + cli-table3: 0.6.5 + commander: 15.0.0 + debug: 4.4.3(supports-color@11.0.0) + error-stack-parser: 2.1.4 + figures: 6.1.0 + has-ansi: 6.0.2 + indent-string: 5.0.0 + is-installed-globally: 1.0.0 + knuth-shuffle-seeded: 1.0.6 + lodash.merge: 4.6.2 + lodash.mergewith: 4.6.2 + luxon: 3.7.2 + read-package-up: 12.0.0 + semver: 7.8.5 + string-argv: 0.3.2 + supports-color: 11.0.0 + type-fest: 5.8.0 + util-arity: 1.1.0 + yaml: 2.8.2 + yup: 1.7.1 + + '@cucumber/gherkin-streams@7.0.1(@cucumber/gherkin@42.0.0)(@cucumber/message-streams@5.0.1(@cucumber/messages@34.2.0))(@cucumber/messages@34.2.0)': + dependencies: + '@cucumber/gherkin': 42.0.0 + '@cucumber/message-streams': 5.0.1(@cucumber/messages@34.2.0) + '@cucumber/messages': 34.2.0 + commander: 15.0.0 + source-map-support: 0.5.21 + + '@cucumber/gherkin-utils@12.0.1': + dependencies: + '@cucumber/gherkin': 41.0.0 + '@cucumber/messages': 33.0.4 + '@teppeis/multimaps': 3.0.0 + commander: 15.0.0 + source-map-support: 0.5.21 + + '@cucumber/gherkin@41.0.0': + dependencies: + '@cucumber/messages': 33.0.4 + + '@cucumber/gherkin@42.0.0': + dependencies: + '@cucumber/messages': 34.2.0 + + '@cucumber/html-formatter@24.1.0(@cucumber/messages@34.2.0)': + dependencies: + '@cucumber/messages': 34.2.0 + + '@cucumber/junit-xml-formatter@0.14.0(@cucumber/messages@34.2.0)': + dependencies: + '@cucumber/messages': 34.2.0 + '@cucumber/query': 16.1.1(@cucumber/messages@34.2.0) + '@teppeis/multimaps': 3.0.0 + luxon: 3.7.2 + xmlbuilder: 15.1.1 + + '@cucumber/message-streams@5.0.1(@cucumber/messages@34.2.0)': + dependencies: + '@cucumber/messages': 34.2.0 + mime: 4.1.0 + + '@cucumber/messages@33.0.4': {} + + '@cucumber/messages@34.2.0': {} + + '@cucumber/pretty-formatter@4.0.0(@cucumber/messages@34.2.0)': + dependencies: + '@cucumber/messages': 34.2.0 + '@cucumber/query': 16.0.0(@cucumber/messages@34.2.0) + luxon: 3.7.2 + + '@cucumber/query@16.0.0(@cucumber/messages@34.2.0)': + dependencies: + '@cucumber/messages': 34.2.0 + '@teppeis/multimaps': 3.0.0 + lodash.sortby: 4.7.0 + + '@cucumber/query@16.1.1(@cucumber/messages@34.2.0)': + dependencies: + '@cucumber/messages': 34.2.0 + '@teppeis/multimaps': 3.0.0 + lodash.sortby: 4.7.0 + + '@cucumber/tag-expressions@11.0.0': {} + '@dabh/diagnostics@2.0.8': dependencies: '@so-ric/colorspace': 1.1.6 @@ -13446,7 +13953,7 @@ snapshots: '@electron/get@2.0.3': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) env-paths: 2.2.1 fs-extra: 8.1.0 got: 11.8.6 @@ -13460,7 +13967,7 @@ snapshots: '@electron/get@3.1.0': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) env-paths: 2.2.1 fs-extra: 8.1.0 got: 11.8.6 @@ -13474,7 +13981,7 @@ snapshots: '@electron/notarize@2.5.0': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) fs-extra: 9.1.0 promise-retry: 2.0.1 transitivePeerDependencies: @@ -13483,7 +13990,7 @@ snapshots: '@electron/osx-sign@1.3.3': dependencies: compare-version: 0.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) fs-extra: 10.1.0 isbinaryfile: 4.0.10 minimist: 1.2.8 @@ -13494,7 +14001,7 @@ snapshots: '@electron/rebuild@4.0.4': dependencies: '@malept/cross-spawn-promise': 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) node-abi: 4.26.0 node-api-version: 0.2.1 node-gyp: 12.4.0 @@ -13506,7 +14013,7 @@ snapshots: dependencies: '@electron/asar': 3.4.1 '@malept/cross-spawn-promise': 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) dir-compare: 4.2.0 fs-extra: 11.3.4 minimatch: 9.0.5 @@ -13517,7 +14024,7 @@ snapshots: '@electron/windows-sign@1.2.2': dependencies: cross-dirname: 0.1.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) fs-extra: 11.3.4 minimist: 1.2.8 postject: 1.0.0-alpha.6 @@ -13558,6 +14065,9 @@ snapshots: '@esbuild/aix-ppc64@0.27.0': optional: true + '@esbuild/aix-ppc64@0.28.1': + optional: true + '@esbuild/aix-ppc64@0.28.2': optional: true @@ -13570,6 +14080,9 @@ snapshots: '@esbuild/android-arm64@0.27.0': optional: true + '@esbuild/android-arm64@0.28.1': + optional: true + '@esbuild/android-arm64@0.28.2': optional: true @@ -13582,6 +14095,9 @@ snapshots: '@esbuild/android-arm@0.27.0': optional: true + '@esbuild/android-arm@0.28.1': + optional: true + '@esbuild/android-arm@0.28.2': optional: true @@ -13594,6 +14110,9 @@ snapshots: '@esbuild/android-x64@0.27.0': optional: true + '@esbuild/android-x64@0.28.1': + optional: true + '@esbuild/android-x64@0.28.2': optional: true @@ -13606,6 +14125,9 @@ snapshots: '@esbuild/darwin-arm64@0.27.0': optional: true + '@esbuild/darwin-arm64@0.28.1': + optional: true + '@esbuild/darwin-arm64@0.28.2': optional: true @@ -13618,6 +14140,9 @@ snapshots: '@esbuild/darwin-x64@0.27.0': optional: true + '@esbuild/darwin-x64@0.28.1': + optional: true + '@esbuild/darwin-x64@0.28.2': optional: true @@ -13630,6 +14155,9 @@ snapshots: '@esbuild/freebsd-arm64@0.27.0': optional: true + '@esbuild/freebsd-arm64@0.28.1': + optional: true + '@esbuild/freebsd-arm64@0.28.2': optional: true @@ -13642,6 +14170,9 @@ snapshots: '@esbuild/freebsd-x64@0.27.0': optional: true + '@esbuild/freebsd-x64@0.28.1': + optional: true + '@esbuild/freebsd-x64@0.28.2': optional: true @@ -13654,6 +14185,9 @@ snapshots: '@esbuild/linux-arm64@0.27.0': optional: true + '@esbuild/linux-arm64@0.28.1': + optional: true + '@esbuild/linux-arm64@0.28.2': optional: true @@ -13666,6 +14200,9 @@ snapshots: '@esbuild/linux-arm@0.27.0': optional: true + '@esbuild/linux-arm@0.28.1': + optional: true + '@esbuild/linux-arm@0.28.2': optional: true @@ -13678,6 +14215,9 @@ snapshots: '@esbuild/linux-ia32@0.27.0': optional: true + '@esbuild/linux-ia32@0.28.1': + optional: true + '@esbuild/linux-ia32@0.28.2': optional: true @@ -13690,6 +14230,9 @@ snapshots: '@esbuild/linux-loong64@0.27.0': optional: true + '@esbuild/linux-loong64@0.28.1': + optional: true + '@esbuild/linux-loong64@0.28.2': optional: true @@ -13702,6 +14245,9 @@ snapshots: '@esbuild/linux-mips64el@0.27.0': optional: true + '@esbuild/linux-mips64el@0.28.1': + optional: true + '@esbuild/linux-mips64el@0.28.2': optional: true @@ -13714,6 +14260,9 @@ snapshots: '@esbuild/linux-ppc64@0.27.0': optional: true + '@esbuild/linux-ppc64@0.28.1': + optional: true + '@esbuild/linux-ppc64@0.28.2': optional: true @@ -13726,6 +14275,9 @@ snapshots: '@esbuild/linux-riscv64@0.27.0': optional: true + '@esbuild/linux-riscv64@0.28.1': + optional: true + '@esbuild/linux-riscv64@0.28.2': optional: true @@ -13738,6 +14290,9 @@ snapshots: '@esbuild/linux-s390x@0.27.0': optional: true + '@esbuild/linux-s390x@0.28.1': + optional: true + '@esbuild/linux-s390x@0.28.2': optional: true @@ -13750,6 +14305,9 @@ snapshots: '@esbuild/linux-x64@0.27.0': optional: true + '@esbuild/linux-x64@0.28.1': + optional: true + '@esbuild/linux-x64@0.28.2': optional: true @@ -13762,6 +14320,9 @@ snapshots: '@esbuild/netbsd-arm64@0.27.0': optional: true + '@esbuild/netbsd-arm64@0.28.1': + optional: true + '@esbuild/netbsd-arm64@0.28.2': optional: true @@ -13774,6 +14335,9 @@ snapshots: '@esbuild/netbsd-x64@0.27.0': optional: true + '@esbuild/netbsd-x64@0.28.1': + optional: true + '@esbuild/netbsd-x64@0.28.2': optional: true @@ -13786,6 +14350,9 @@ snapshots: '@esbuild/openbsd-arm64@0.27.0': optional: true + '@esbuild/openbsd-arm64@0.28.1': + optional: true + '@esbuild/openbsd-arm64@0.28.2': optional: true @@ -13798,6 +14365,9 @@ snapshots: '@esbuild/openbsd-x64@0.27.0': optional: true + '@esbuild/openbsd-x64@0.28.1': + optional: true + '@esbuild/openbsd-x64@0.28.2': optional: true @@ -13807,6 +14377,9 @@ snapshots: '@esbuild/openharmony-arm64@0.27.0': optional: true + '@esbuild/openharmony-arm64@0.28.1': + optional: true + '@esbuild/openharmony-arm64@0.28.2': optional: true @@ -13819,6 +14392,9 @@ snapshots: '@esbuild/sunos-x64@0.27.0': optional: true + '@esbuild/sunos-x64@0.28.1': + optional: true + '@esbuild/sunos-x64@0.28.2': optional: true @@ -13831,6 +14407,9 @@ snapshots: '@esbuild/win32-arm64@0.27.0': optional: true + '@esbuild/win32-arm64@0.28.1': + optional: true + '@esbuild/win32-arm64@0.28.2': optional: true @@ -13843,6 +14422,9 @@ snapshots: '@esbuild/win32-ia32@0.27.0': optional: true + '@esbuild/win32-ia32@0.28.1': + optional: true + '@esbuild/win32-ia32@0.28.2': optional: true @@ -13855,6 +14437,9 @@ snapshots: '@esbuild/win32-x64@0.27.0': optional: true + '@esbuild/win32-x64@0.28.1': + optional: true + '@esbuild/win32-x64@0.28.2': optional: true @@ -13873,7 +14458,7 @@ snapshots: '@eslint/config-array@0.21.1': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -13881,7 +14466,7 @@ snapshots: '@eslint/config-array@0.23.5': dependencies: '@eslint/object-schema': 3.0.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) minimatch: 10.2.5 transitivePeerDependencies: - supports-color @@ -13905,7 +14490,7 @@ snapshots: '@eslint/eslintrc@3.3.3': dependencies: ajv: 6.15.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -14388,7 +14973,7 @@ snapshots: '@malept/flatpak-bundler@0.4.0': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) fs-extra: 9.1.0 lodash: 4.18.1 tmp-promise: 3.0.3 @@ -16592,14 +17177,14 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) - '@tanstack/react-start-rsc@0.1.32(esbuild@0.24.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2))': + '@tanstack/react-start-rsc@0.1.32(esbuild@0.24.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2))': dependencies: '@tanstack/react-router': 1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@tanstack/router-core': 1.171.15 '@tanstack/router-utils': 1.162.2 '@tanstack/start-client-core': 1.170.14 '@tanstack/start-fn-stubs': 1.162.0 - '@tanstack/start-plugin-core': 1.171.24(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.24.2)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2)) + '@tanstack/start-plugin-core': 1.171.24(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.24.2)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) '@tanstack/start-server-core': 1.169.17 '@tanstack/start-storage-context': 1.167.17 pathe: 2.0.3 @@ -16620,14 +17205,14 @@ snapshots: - webpack optional: true - '@tanstack/react-start-rsc@0.1.32(esbuild@0.24.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2))': + '@tanstack/react-start-rsc@0.1.32(esbuild@0.28.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2))': dependencies: '@tanstack/react-router': 1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@tanstack/router-core': 1.171.15 '@tanstack/router-utils': 1.162.2 '@tanstack/start-client-core': 1.170.14 '@tanstack/start-fn-stubs': 1.162.0 - '@tanstack/start-plugin-core': 1.171.24(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.24.2)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) + '@tanstack/start-plugin-core': 1.171.24(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.28.2)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2)) '@tanstack/start-server-core': 1.169.17 '@tanstack/start-storage-context': 1.167.17 pathe: 2.0.3 @@ -16741,21 +17326,21 @@ snapshots: transitivePeerDependencies: - crossws - '@tanstack/react-start@1.168.33(esbuild@0.24.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2))': + '@tanstack/react-start@1.168.33(esbuild@0.24.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2))': dependencies: '@tanstack/react-router': 1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@tanstack/react-start-client': 1.168.16(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@tanstack/react-start-rsc': 0.1.32(esbuild@0.24.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2)) + '@tanstack/react-start-rsc': 0.1.32(esbuild@0.24.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) '@tanstack/react-start-server': 1.167.22(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@tanstack/router-utils': 1.162.2 '@tanstack/start-client-core': 1.170.14 - '@tanstack/start-plugin-core': 1.171.24(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.24.2)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2)) + '@tanstack/start-plugin-core': 1.171.24(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.24.2)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) '@tanstack/start-server-core': 1.169.17 pathe: 2.0.3 react: 19.2.0 react-dom: 19.2.0(react@19.2.0) optionalDependencies: - vite: 6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2) + vite: 6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2) transitivePeerDependencies: - '@farmfe/core' - '@rspack/core' @@ -16771,21 +17356,21 @@ snapshots: - webpack optional: true - '@tanstack/react-start@1.168.33(esbuild@0.24.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2))': + '@tanstack/react-start@1.168.33(esbuild@0.28.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2))': dependencies: '@tanstack/react-router': 1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@tanstack/react-start-client': 1.168.16(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@tanstack/react-start-rsc': 0.1.32(esbuild@0.24.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) + '@tanstack/react-start-rsc': 0.1.32(esbuild@0.28.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2)) '@tanstack/react-start-server': 1.167.22(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@tanstack/router-utils': 1.162.2 '@tanstack/start-client-core': 1.170.14 - '@tanstack/start-plugin-core': 1.171.24(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.24.2)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) + '@tanstack/start-plugin-core': 1.171.24(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.28.2)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2)) '@tanstack/start-server-core': 1.169.17 pathe: 2.0.3 react: 19.2.0 react-dom: 19.2.0(react@19.2.0) optionalDependencies: - vite: 6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2) + vite: 6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2) transitivePeerDependencies: - '@farmfe/core' - '@rspack/core' @@ -16984,7 +17569,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.24.2)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2))': + '@tanstack/router-plugin@1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.24.2)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2))': dependencies: '@babel/core': 7.29.0 '@babel/template': 7.28.6 @@ -16993,11 +17578,11 @@ snapshots: '@tanstack/router-generator': 1.167.21 '@tanstack/router-utils': 1.162.2 chokidar: 5.0.0 - unplugin: 3.3.0(esbuild@0.24.2)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2)) + unplugin: 3.3.0(esbuild@0.24.2)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) zod: 4.3.6 optionalDependencies: '@tanstack/react-router': 1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - vite: 6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2) + vite: 6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2) transitivePeerDependencies: - '@farmfe/core' - '@rspack/core' @@ -17007,8 +17592,9 @@ snapshots: - rollup - supports-color - unloader + optional: true - '@tanstack/router-plugin@1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.24.2)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2))': + '@tanstack/router-plugin@1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.28.2)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2))': dependencies: '@babel/core': 7.29.0 '@babel/template': 7.28.6 @@ -17017,11 +17603,11 @@ snapshots: '@tanstack/router-generator': 1.167.21 '@tanstack/router-utils': 1.162.2 chokidar: 5.0.0 - unplugin: 3.3.0(esbuild@0.24.2)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) + unplugin: 3.3.0(esbuild@0.28.2)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2)) zod: 4.3.6 optionalDependencies: '@tanstack/react-router': 1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - vite: 6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2) + vite: 6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2) transitivePeerDependencies: - '@farmfe/core' - '@rspack/core' @@ -17031,7 +17617,6 @@ snapshots: - rollup - supports-color - unloader - optional: true '@tanstack/router-plugin@1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.28.2)(rolldown@1.1.5)(rollup@4.57.1)(vite@8.1.5(@types/node@24.10.12)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2))': dependencies: @@ -17143,14 +17728,14 @@ snapshots: '@tanstack/start-fn-stubs@1.162.0': {} - '@tanstack/start-plugin-core@1.171.24(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.24.2)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2))': + '@tanstack/start-plugin-core@1.171.24(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.24.2)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2))': dependencies: '@babel/code-frame': 7.27.1 '@babel/core': 7.29.0 '@babel/types': 7.29.0 '@tanstack/router-core': 1.171.15 '@tanstack/router-generator': 1.167.21 - '@tanstack/router-plugin': 1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.24.2)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2)) + '@tanstack/router-plugin': 1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.24.2)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) '@tanstack/router-utils': 1.162.2 '@tanstack/start-server-core': 1.169.17 exsolve: 1.0.8 @@ -17162,11 +17747,11 @@ snapshots: srvx: 0.11.22 tinyglobby: 0.2.17 ufo: 1.6.3 - vitefu: 1.1.3(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2)) + vitefu: 1.1.3(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) xmlbuilder2: 4.0.3 zod: 4.3.6 optionalDependencies: - vite: 6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2) + vite: 6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2) transitivePeerDependencies: - '@farmfe/core' - '@rspack/core' @@ -17182,14 +17767,14 @@ snapshots: - webpack optional: true - '@tanstack/start-plugin-core@1.171.24(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.24.2)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2))': + '@tanstack/start-plugin-core@1.171.24(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.28.2)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2))': dependencies: '@babel/code-frame': 7.27.1 '@babel/core': 7.29.0 '@babel/types': 7.29.0 '@tanstack/router-core': 1.171.15 '@tanstack/router-generator': 1.167.21 - '@tanstack/router-plugin': 1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.24.2)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) + '@tanstack/router-plugin': 1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.28.2)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2)) '@tanstack/router-utils': 1.162.2 '@tanstack/start-server-core': 1.169.17 exsolve: 1.0.8 @@ -17201,11 +17786,11 @@ snapshots: srvx: 0.11.22 tinyglobby: 0.2.17 ufo: 1.6.3 - vitefu: 1.1.3(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) + vitefu: 1.1.3(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2)) xmlbuilder2: 4.0.3 zod: 4.3.6 optionalDependencies: - vite: 6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2) + vite: 6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2) transitivePeerDependencies: - '@farmfe/core' - '@rspack/core' @@ -17372,6 +17957,8 @@ snapshots: '@tanstack/virtual-file-routes@1.162.0': {} + '@teppeis/multimaps@3.0.0': {} + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0 @@ -17664,6 +18251,8 @@ snapshots: dependencies: undici-types: 8.3.0 + '@types/normalize-package-data@2.4.4': {} + '@types/pg@8.15.6': dependencies: '@types/node': 26.2.0 @@ -17785,7 +18374,7 @@ snapshots: '@typescript-eslint/types': 8.55.0 '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.55.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) eslint: 9.39.2(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: @@ -17797,7 +18386,7 @@ snapshots: '@typescript-eslint/types': 8.67.0 '@typescript-eslint/typescript-estree': 8.67.0(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.67.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) eslint: 10.8.1(jiti@2.7.0) typescript: 6.0.3 transitivePeerDependencies: @@ -17807,7 +18396,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.9.3) '@typescript-eslint/types': 8.65.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -17816,7 +18405,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@6.0.3) '@typescript-eslint/types': 8.67.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -17848,7 +18437,7 @@ snapshots: '@typescript-eslint/types': 8.55.0 '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.7.0))(typescript@5.9.3) - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) eslint: 9.39.2(jiti@2.7.0) ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 @@ -17860,7 +18449,7 @@ snapshots: '@typescript-eslint/types': 8.67.0 '@typescript-eslint/typescript-estree': 8.67.0(typescript@6.0.3) '@typescript-eslint/utils': 8.67.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3) - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) eslint: 10.8.1(jiti@2.7.0) ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 @@ -17879,7 +18468,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3) '@typescript-eslint/types': 8.55.0 '@typescript-eslint/visitor-keys': 8.55.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) minimatch: 9.0.5 semver: 7.7.4 tinyglobby: 0.2.17 @@ -17894,7 +18483,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@6.0.3) '@typescript-eslint/types': 8.67.0 '@typescript-eslint/visitor-keys': 8.67.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) minimatch: 10.2.5 semver: 7.7.4 tinyglobby: 0.2.17 @@ -18086,7 +18675,7 @@ snapshots: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 ast-v8-to-istanbul: 0.3.11 - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-lib-source-maps: 5.0.6 @@ -18538,7 +19127,7 @@ snapshots: builder-util-runtime: 9.7.0 chromium-pickle-js: 0.2.0 ci-info: 4.3.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) dmg-builder: 26.15.3(electron-builder-squirrel-windows@26.15.3) dotenv: 16.6.1 dotenv-expand: 11.0.7 @@ -18653,6 +19242,12 @@ snapshots: pvutils: 1.1.5 tslib: 2.8.1 + assertion-error-formatter@3.0.0: + dependencies: + diff: 4.0.4 + pad-right: 0.2.2 + repeat-string: 1.6.1 + assertion-error@2.0.1: {} assistant-cloud@0.1.17: @@ -18735,17 +19330,17 @@ snapshots: elkjs: 0.11.1 entities: 7.0.1 - better-auth-capacitor@0.3.6(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.2.0))(@capacitor/app@8.1.0(@capacitor/core@8.5.0))(@capacitor/core@8.5.0)(@capacitor/preferences@8.0.1(@capacitor/core@8.5.0))(better-auth@1.5.5(532ad3d3c7da62ce07897da849d81e18)): + better-auth-capacitor@0.3.6(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.2.0))(@capacitor/app@8.1.0(@capacitor/core@8.5.0))(@capacitor/core@8.5.0)(@capacitor/preferences@8.0.1(@capacitor/core@8.5.0))(better-auth@1.5.5(dcb2c8ba3b09f267bb48cd58cd0877bd)): dependencies: '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.2.0) '@capacitor/core': 8.5.0 '@capacitor/preferences': 8.0.1(@capacitor/core@8.5.0) - better-auth: 1.5.5(532ad3d3c7da62ce07897da849d81e18) + better-auth: 1.5.5(dcb2c8ba3b09f267bb48cd58cd0877bd) zod: 4.3.6 optionalDependencies: '@capacitor/app': 8.1.0(@capacitor/core@8.5.0) - better-auth@1.5.5(532ad3d3c7da62ce07897da849d81e18): + better-auth@1.5.5(817ae14f18a6fb615413e202a25d2574): dependencies: '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.2.0) '@better-auth/drizzle-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.0(prisma@7.4.0(@types/react@19.2.17)(better-sqlite3@12.10.0)(magicast@0.3.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.10.0)(kysely@0.28.11)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.0(@types/react@19.2.17)(better-sqlite3@12.10.0)(magicast@0.3.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))) @@ -18766,7 +19361,7 @@ snapshots: zod: 4.3.6 optionalDependencies: '@prisma/client': 7.4.0(prisma@7.4.0(@types/react@19.2.17)(better-sqlite3@12.10.0)(magicast@0.3.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3) - '@tanstack/react-start': 1.168.33(esbuild@0.24.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2)) + '@tanstack/react-start': 1.168.33(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(vite@7.3.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2)) better-sqlite3: 12.10.0 drizzle-orm: 0.45.1(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.0(prisma@7.4.0(@types/react@19.2.17)(better-sqlite3@12.10.0)(magicast@0.3.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.10.0)(kysely@0.28.11)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.0(@types/react@19.2.17)(better-sqlite3@12.10.0)(magicast@0.3.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)) mongodb: 7.1.0(socks@2.8.7) @@ -18774,12 +19369,12 @@ snapshots: prisma: 7.4.0(@types/react@19.2.17)(better-sqlite3@12.10.0)(magicast@0.3.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3) react: 19.2.0 react-dom: 19.2.0(react@19.2.0) - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.12)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2) + vitest: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@24.10.12)(jsdom@26.1.0)(vite@7.3.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2)) vue: 3.5.28(typescript@5.9.3) transitivePeerDependencies: - '@cloudflare/workers-types' - better-auth@1.5.5(817ae14f18a6fb615413e202a25d2574): + better-auth@1.5.5(9278b3e7a79d4cef76e921e6c2a42420): dependencies: '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.2.0) '@better-auth/drizzle-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.0(prisma@7.4.0(@types/react@19.2.17)(better-sqlite3@12.10.0)(magicast@0.3.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.10.0)(kysely@0.28.11)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.0(@types/react@19.2.17)(better-sqlite3@12.10.0)(magicast@0.3.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))) @@ -18800,7 +19395,7 @@ snapshots: zod: 4.3.6 optionalDependencies: '@prisma/client': 7.4.0(prisma@7.4.0(@types/react@19.2.17)(better-sqlite3@12.10.0)(magicast@0.3.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3) - '@tanstack/react-start': 1.168.33(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(vite@7.3.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2)) + '@tanstack/react-start': 1.168.33(esbuild@0.28.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(vite@8.1.5(@types/node@24.10.12)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2)) better-sqlite3: 12.10.0 drizzle-orm: 0.45.1(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.0(prisma@7.4.0(@types/react@19.2.17)(better-sqlite3@12.10.0)(magicast@0.3.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.10.0)(kysely@0.28.11)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.0(@types/react@19.2.17)(better-sqlite3@12.10.0)(magicast@0.3.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)) mongodb: 7.1.0(socks@2.8.7) @@ -18808,12 +19403,12 @@ snapshots: prisma: 7.4.0(@types/react@19.2.17)(better-sqlite3@12.10.0)(magicast@0.3.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3) react: 19.2.0 react-dom: 19.2.0(react@19.2.0) - vitest: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@24.10.12)(jsdom@26.1.0)(vite@7.3.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2)) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.12)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2) vue: 3.5.28(typescript@5.9.3) transitivePeerDependencies: - '@cloudflare/workers-types' - better-auth@1.5.5(9278b3e7a79d4cef76e921e6c2a42420): + better-auth@1.5.5(dcb2c8ba3b09f267bb48cd58cd0877bd): dependencies: '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.2.0) '@better-auth/drizzle-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.0(prisma@7.4.0(@types/react@19.2.17)(better-sqlite3@12.10.0)(magicast@0.3.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.10.0)(kysely@0.28.11)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.0(@types/react@19.2.17)(better-sqlite3@12.10.0)(magicast@0.3.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))) @@ -18834,7 +19429,7 @@ snapshots: zod: 4.3.6 optionalDependencies: '@prisma/client': 7.4.0(prisma@7.4.0(@types/react@19.2.17)(better-sqlite3@12.10.0)(magicast@0.3.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3) - '@tanstack/react-start': 1.168.33(esbuild@0.28.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(vite@8.1.5(@types/node@24.10.12)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2)) + '@tanstack/react-start': 1.168.33(esbuild@0.28.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2)) better-sqlite3: 12.10.0 drizzle-orm: 0.45.1(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.0(prisma@7.4.0(@types/react@19.2.17)(better-sqlite3@12.10.0)(magicast@0.3.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.10.0)(kysely@0.28.11)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.0(@types/react@19.2.17)(better-sqlite3@12.10.0)(magicast@0.3.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)) mongodb: 7.1.0(socks@2.8.7) @@ -18938,7 +19533,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 @@ -19004,14 +19599,14 @@ snapshots: builder-util-runtime@9.5.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) sax: 1.4.4 transitivePeerDependencies: - supports-color builder-util-runtime@9.7.0: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) sax: 1.4.4 transitivePeerDependencies: - supports-color @@ -19022,7 +19617,7 @@ snapshots: builder-util-runtime: 9.7.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) fs-extra: 10.1.0 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 @@ -19251,6 +19846,8 @@ snapshots: commander@12.1.0: {} + commander@15.0.0: {} + commander@2.20.3: optional: true @@ -19603,9 +20200,11 @@ snapshots: dependencies: ms: 2.0.0 - debug@4.4.3: + debug@4.4.3(supports-color@11.0.0): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 11.0.0 decimal.js-light@2.5.1: {} @@ -19711,7 +20310,7 @@ snapshots: docker-modem@5.0.6: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) readable-stream: 3.6.2 split-ca: 1.0.1 ssh2: 1.17.0 @@ -19900,7 +20499,7 @@ snapshots: electron-winstaller@5.4.0: dependencies: '@electron/asar': 3.4.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) fs-extra: 7.0.1 lodash: 4.18.1 temp: 0.9.4 @@ -19962,6 +20561,10 @@ snapshots: err-code@2.0.3: {} + error-stack-parser@2.1.4: + dependencies: + stackframe: 1.3.4 + es-abstract@1.24.1: dependencies: array-buffer-byte-length: 1.0.2 @@ -20088,7 +20691,7 @@ snapshots: esbuild-register@3.6.0(esbuild@0.24.2): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) esbuild: 0.24.2 transitivePeerDependencies: - supports-color @@ -20179,6 +20782,35 @@ snapshots: '@esbuild/win32-ia32': 0.27.0 '@esbuild/win32-x64': 0.27.0 + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + esbuild@0.28.2: optionalDependencies: '@esbuild/aix-ppc64': 0.28.2 @@ -20302,7 +20934,7 @@ snapshots: '@types/estree': 1.0.8 ajv: 6.15.0 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) escape-string-regexp: 4.0.0 eslint-scope: 9.1.2 eslint-visitor-keys: 5.0.1 @@ -20342,7 +20974,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -20512,7 +21144,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -20549,7 +21181,7 @@ snapshots: extract-zip@2.0.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -20611,6 +21243,10 @@ snapshots: fflate@0.8.3: {} + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -20643,7 +21279,7 @@ snapshots: finalhandler@2.1.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -20652,6 +21288,8 @@ snapshots: transitivePeerDependencies: - supports-color + find-up-simple@1.0.1: {} + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -20948,6 +21586,10 @@ snapshots: serialize-error: 7.0.1 optional: true + global-directory@4.0.1: + dependencies: + ini: 4.1.1 + globals@14.0.0: {} globals@16.5.0: {} @@ -21005,6 +21647,10 @@ snapshots: hachure-fill@0.5.2: {} + has-ansi@6.0.2: + dependencies: + ansi-regex: 6.2.2 + has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -21247,6 +21893,10 @@ snapshots: dependencies: lru-cache: 6.0.0 + hosted-git-info@9.0.3: + dependencies: + lru-cache: 11.2.5 + html-encoding-sniffer@4.0.0: dependencies: whatwg-encoding: 3.1.1 @@ -21274,7 +21924,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) transitivePeerDependencies: - supports-color @@ -21289,7 +21939,7 @@ snapshots: https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) transitivePeerDependencies: - supports-color @@ -21337,6 +21987,10 @@ snapshots: indent-string@4.0.0: {} + indent-string@5.0.0: {} + + index-to-position@1.2.0: {} + inflight@1.0.6: dependencies: once: 1.4.0 @@ -21346,6 +22000,8 @@ snapshots: ini@1.3.8: {} + ini@4.1.1: {} + inline-style-parser@0.2.7: {} inquirer@10.2.2: @@ -21458,6 +22114,11 @@ snapshots: dependencies: is-docker: 3.0.0 + is-installed-globally@1.0.0: + dependencies: + global-directory: 4.0.1 + is-path-inside: 4.0.0 + is-interactive@2.0.0: {} is-map@2.0.3: {} @@ -21564,7 +22225,7 @@ snapshots: istanbul-lib-source-maps@5.0.6: dependencies: '@jridgewell/trace-mapping': 0.3.31 - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) istanbul-lib-coverage: 3.2.2 transitivePeerDependencies: - supports-color @@ -21715,6 +22376,10 @@ snapshots: khroma@2.1.0: {} + knuth-shuffle-seeded@1.0.6: + dependencies: + seed-random: 2.2.0 + kolorist@1.8.0: {} konsta@5.0.9: @@ -21812,6 +22477,10 @@ snapshots: lodash.merge@4.6.2: {} + lodash.mergewith@4.6.2: {} + + lodash.sortby@4.7.0: {} + lodash@4.17.21: optional: true @@ -21960,6 +22629,8 @@ snapshots: dependencies: react: 19.2.0 + luxon@3.7.2: {} + lz-string@1.5.0: {} magic-string@0.30.21: @@ -22478,7 +23149,7 @@ snapshots: micromark@4.0.2: dependencies: '@types/debug': 4.1.12 - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) decode-named-character-reference: 1.3.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -22518,6 +23189,8 @@ snapshots: mime@2.6.0: {} + mime@4.1.0: {} + mimic-fn@2.1.0: {} mimic-function@5.0.1: {} @@ -22724,6 +23397,12 @@ snapshots: dependencies: abbrev: 4.0.0 + normalize-package-data@8.0.0: + dependencies: + hosted-git-info: 9.0.3 + semver: 7.8.5 + validate-npm-package-license: 3.0.4 + normalize-path@3.0.0: {} normalize-url@6.1.0: {} @@ -22937,6 +23616,10 @@ snapshots: package-manager-detector@1.6.0: {} + pad-right@0.2.2: + dependencies: + repeat-string: 1.6.1 + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -22951,6 +23634,12 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 + parse-json@8.3.0: + dependencies: + '@babel/code-frame': 7.29.0 + index-to-position: 1.2.0 + type-fest: 4.41.0 + parse5@7.3.0: dependencies: entities: 6.0.1 @@ -23222,6 +23911,8 @@ snapshots: retry: 0.12.0 signal-exit: 3.0.7 + property-expr@2.0.6: {} + property-information@7.1.0: {} prosemirror-changeset@2.4.1: @@ -23613,10 +24304,24 @@ snapshots: read-binary-file-arch@1.0.6: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) transitivePeerDependencies: - supports-color + read-package-up@12.0.0: + dependencies: + find-up-simple: 1.0.1 + read-pkg: 10.1.0 + type-fest: 5.8.0 + + read-pkg@10.1.0: + dependencies: + '@types/normalize-package-data': 2.4.4 + normalize-package-data: 8.0.0 + parse-json: 8.3.0 + type-fest: 5.8.0 + unicorn-magic: 0.4.0 + readable-stream@2.3.8: dependencies: core-util-is: 1.0.2 @@ -23729,9 +24434,15 @@ snapshots: dependencies: regex-utilities: 2.3.0 + regexp-match-indices@1.0.2: + dependencies: + regexp-tree: 0.1.27 + regexp-to-ast@0.5.0: optional: true + regexp-tree@0.1.27: {} + regexp.prototype.flags@1.5.4: dependencies: call-bind: 1.0.8 @@ -23859,6 +24570,8 @@ snapshots: remend@1.3.0(patch_hash=1dca30d74402e3a26ca417addef5ccc4f7b566116a0604122b032aa9744fa00c): {} + repeat-string@1.6.1: {} + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -23988,7 +24701,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -24057,6 +24770,8 @@ snapshots: secure-json-parse@4.1.0: {} + seed-random@2.2.0: {} + semver-compare@1.0.0: optional: true @@ -24070,6 +24785,8 @@ snapshots: semver@7.7.4: {} + semver@7.8.5: {} + send@0.19.2: dependencies: debug: 2.6.9 @@ -24090,7 +24807,7 @@ snapshots: send@1.2.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -24300,6 +25017,20 @@ snapshots: dependencies: memory-pager: 1.5.0 + spdx-correct@3.2.0: + dependencies: + spdx-expression-parse: 3.0.1 + spdx-license-ids: 3.0.23 + + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@3.0.1: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.23 + + spdx-license-ids@3.0.23: {} + split-ca@1.0.1: {} sprintf-js@1.0.3: {} @@ -24324,6 +25055,8 @@ snapshots: stackback@0.0.2: {} + stackframe@1.3.4: {} + standardwebhooks@1.0.0: dependencies: '@stablelib/base64': 1.0.1 @@ -24539,10 +25272,12 @@ snapshots: sumchecker@3.0.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) transitivePeerDependencies: - supports-color + supports-color@11.0.0: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -24632,6 +25367,8 @@ snapshots: dependencies: semver: 5.7.2 + tiny-case@1.0.3: {} + tiny-invariant@1.3.3: {} tiny-typed-emitter@2.1.0: {} @@ -24648,8 +25385,8 @@ snapshots: tinyglobby@0.2.15: dependencies: - fdir: 6.5.0(picomatch@4.0.5) - picomatch: 4.0.5 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 tinyglobby@0.2.17: dependencies: @@ -24686,6 +25423,8 @@ snapshots: toidentifier@1.0.1: {} + toposort@2.0.2: {} + tough-cookie@5.1.2: dependencies: tldts: 6.1.86 @@ -24766,7 +25505,7 @@ snapshots: tsx@4.23.7: dependencies: - esbuild: 0.28.2 + esbuild: 0.28.1 optionalDependencies: fsevents: 2.3.3 @@ -24787,6 +25526,8 @@ snapshots: type-fest@0.21.3: {} + type-fest@2.19.0: {} + type-fest@4.41.0: {} type-fest@5.8.0: @@ -24966,27 +25707,27 @@ snapshots: acorn: 8.16.0 webpack-virtual-modules: 0.6.2 - unplugin@3.3.0(esbuild@0.24.2)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2)): + unplugin@3.3.0(esbuild@0.24.2)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)): dependencies: '@jridgewell/remapping': 2.3.5 picomatch: 4.0.5 webpack-virtual-modules: 0.6.2 optionalDependencies: esbuild: 0.24.2 - rolldown: 1.1.5 rollup: 4.57.1 - vite: 6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2) + vite: 6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2) + optional: true - unplugin@3.3.0(esbuild@0.24.2)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)): + unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2)): dependencies: '@jridgewell/remapping': 2.3.5 picomatch: 4.0.5 webpack-virtual-modules: 0.6.2 optionalDependencies: - esbuild: 0.24.2 + esbuild: 0.28.2 + rolldown: 1.1.5 rollup: 4.57.1 - vite: 6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2) - optional: true + vite: 6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2) unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.1.5)(rollup@4.57.1)(vite@8.1.5(@types/node@24.10.12)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2)): dependencies: @@ -25070,6 +25811,8 @@ snapshots: utf8-byte-length@1.0.5: {} + util-arity@1.1.0: {} + util-deprecate@1.0.2: {} utils-merge@1.0.1: {} @@ -25087,6 +25830,11 @@ snapshots: typescript: 5.9.3 optional: true + validate-npm-package-license@3.0.4: + dependencies: + spdx-correct: 3.2.0 + spdx-expression-parse: 3.0.1 + validator@13.15.35: {} vary@1.1.2: {} @@ -25141,7 +25889,7 @@ snapshots: vite-node@3.2.4(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2): dependencies: cac: 6.7.14 - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) es-module-lexer: 1.7.0 pathe: 2.0.3 vite: 7.3.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2) @@ -25162,7 +25910,7 @@ snapshots: vite-node@3.2.4(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: cac: 6.7.14 - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) es-module-lexer: 1.7.0 pathe: 2.0.3 vite: 7.3.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) @@ -25183,7 +25931,7 @@ snapshots: vite-node@3.2.4(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2): dependencies: cac: 6.7.14 - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) es-module-lexer: 1.7.0 pathe: 2.0.3 vite: 7.3.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2) @@ -25204,7 +25952,7 @@ snapshots: vite-node@3.2.4(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2): dependencies: cac: 6.7.14 - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) es-module-lexer: 1.7.0 pathe: 2.0.3 vite: 7.3.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2) @@ -25229,7 +25977,7 @@ snapshots: '@volar/typescript': 2.4.28 '@vue/language-core': 2.2.0(typescript@5.9.3) compare-versions: 6.1.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) kolorist: 1.8.0 local-pkg: 1.1.2 magic-string: 0.30.21 @@ -25282,7 +26030,7 @@ snapshots: vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.13)(yaml@2.8.2)): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) optionalDependencies: @@ -25488,7 +26236,7 @@ snapshots: '@vitest/spy': 3.2.4 '@vitest/utils': 3.2.4 chai: 5.3.3 - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) expect-type: 1.3.0 magic-string: 0.30.21 pathe: 2.0.3 @@ -25531,7 +26279,7 @@ snapshots: '@vitest/spy': 3.2.4 '@vitest/utils': 3.2.4 chai: 5.3.3 - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) expect-type: 1.3.0 magic-string: 0.30.21 pathe: 2.0.3 @@ -25574,7 +26322,7 @@ snapshots: '@vitest/spy': 3.2.4 '@vitest/utils': 3.2.4 chai: 5.3.3 - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) expect-type: 1.3.0 magic-string: 0.30.21 pathe: 2.0.3 @@ -25617,7 +26365,7 @@ snapshots: '@vitest/spy': 3.2.4 '@vitest/utils': 3.2.4 chai: 5.3.3 - debug: 4.4.3 + debug: 4.4.3(supports-color@11.0.0) expect-type: 1.3.0 magic-string: 0.30.21 pathe: 2.0.3 @@ -25961,6 +26709,13 @@ snapshots: yoctocolors-cjs@2.1.3: {} + yup@1.7.1: + dependencies: + property-expr: 2.0.6 + tiny-case: 1.0.3 + toposort: 2.0.2 + type-fest: 2.19.0 + zeptomatch@2.1.0: dependencies: grammex: 3.1.12 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9634fe5b5..923448d09 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ packages: - '!packages/acp-extension-kimi' - apps/cli - apps/electron + - e2e - site-docs catalog: