diff --git a/e2e/flows/f01-add-text.yaml b/e2e/flows/f01-add-text.yaml index 4259d92..e5fd8da 100644 --- a/e2e/flows/f01-add-text.yaml +++ b/e2e/flows/f01-add-text.yaml @@ -14,6 +14,5 @@ tags: - pasteText - runFlow: ../subflows/dismiss-keyboard.yaml - runFlow: ../subflows/scroll-and-apply-text-edits.yaml -- assertVisible: 周末的海边日记 - assertVisible: id: canvas-text-hit-0-.* diff --git a/e2e/flows/f04-export.yaml b/e2e/flows/f04-export.yaml index 0e22000..a76e86f 100644 --- a/e2e/flows/f04-export.yaml +++ b/e2e/flows/f04-export.yaml @@ -45,7 +45,6 @@ tags: - pasteText - runFlow: ../subflows/dismiss-keyboard.yaml - runFlow: ../subflows/scroll-and-apply-text-edits.yaml -- assertVisible: "导出后更新 ✨" - tapOn: id: editor-open-export - runFlow: diff --git a/e2e/subflows/scroll-and-apply-text-edits.yaml b/e2e/subflows/scroll-and-apply-text-edits.yaml index d35febe..c3f446e 100644 --- a/e2e/subflows/scroll-and-apply-text-edits.yaml +++ b/e2e/subflows/scroll-and-apply-text-edits.yaml @@ -6,3 +6,5 @@ appId: com.leonzym.plogkit TARGET_ID: commit-text - tapOn: id: commit-text +- assertNotVisible: + id: commit-text diff --git a/scripts/e2e/ios.mjs b/scripts/e2e/ios.mjs index de91b77..df1f43a 100644 --- a/scripts/e2e/ios.mjs +++ b/scripts/e2e/ios.mjs @@ -36,6 +36,7 @@ const iosPrepareEvidenceProbeTimeoutMs = 5000; const iosGuestHealthTimeoutMs = 60000; const iosGuestHealthMaxBytes = 1024 * 1024; const iosCleanupStageErrorMaxBytes = 64 * 1024; +const cocoaPodsVersionProbeTimeoutMs = 60 * 1000; function boundedEvidence(value, maxBytes) { const source = Buffer.isBuffer(value) ? value : Buffer.from(value); @@ -59,7 +60,13 @@ export function validateIosHost() { } } -export function validateIosToolchain() { +export function validateIosToolchain({ + captureCocoaPodsVersion = (timeoutMs) => + capture("pod", ["--version"], { + timeoutMs, + }), + cocoaPodsProbeTimeoutMs = cocoaPodsVersionProbeTimeoutMs, +} = {}) { const xcodePath = capture("xcode-select", ["-p"], { allowFailure: true, timeoutMs: 15000, @@ -72,11 +79,26 @@ export function validateIosToolchain() { if (xcodeVersion) { for (const line of xcodeVersion.split("\n")) log("ios", ` ${line}`); } - const cocoaPodsVersion = capture("pod", ["--version"], { - allowFailure: true, - timeoutMs: 15000, - }); - log("ios", `CocoaPods: ${cocoaPodsVersion ?? "unknown"}`); + let cocoaPodsVersion; + try { + cocoaPodsVersion = captureCocoaPodsVersion(cocoaPodsProbeTimeoutMs); + } catch (error) { + if (error?.code === "ENOENT") { + throw new Error(`CocoaPods ${requiredCocoaPodsVersion} is required but was not found on PATH.`, { + cause: error, + }); + } + if (error?.code === "ETIMEDOUT") { + throw Object.assign( + new Error(`CocoaPods version probe timed out after ${cocoaPodsProbeTimeoutMs}ms.`, { + cause: error, + }), + { code: "E2E_COMMAND_TIMEOUT" }, + ); + } + throw new Error("Unable to execute the installed CocoaPods version probe.", { cause: error }); + } + log("ios", `CocoaPods: ${cocoaPodsVersion}`); const selectedXcodeVersion = xcodeVersion?.match(/^Xcode\s+(.+)$/m)?.[1] ?? "unknown"; const selectedXcodeBuild = xcodeVersion?.match(/^Build version\s+(.+)$/m)?.[1] ?? "unknown"; if (selectedXcodeVersion !== requiredXcodeVersion || selectedXcodeBuild !== requiredXcodeBuild) { @@ -87,7 +109,7 @@ export function validateIosToolchain() { } if (cocoaPodsVersion !== requiredCocoaPodsVersion) { throw new Error( - `CocoaPods ${requiredCocoaPodsVersion} is required, but ${cocoaPodsVersion ?? "unknown"} is installed.`, + `CocoaPods ${requiredCocoaPodsVersion} is required, but ${cocoaPodsVersion} is installed.`, ); } log("ios", "iOS toolchain validation passed."); diff --git a/scripts/e2e/ios.test.mjs b/scripts/e2e/ios.test.mjs index 1d4802e..1e7c62e 100644 --- a/scripts/e2e/ios.test.mjs +++ b/scripts/e2e/ios.test.mjs @@ -604,6 +604,40 @@ test("iOS rejects a host outside the pinned Xcode toolchain", async (t) => { }, /Xcode 26\.6 \(17F113\) is required, but Xcode 27\.0 \(27A5218g\) is selected/); }); +test("iOS preserves a CocoaPods cold-start timeout instead of reporting an unknown version", async (t) => { + const directory = createTemporaryTestDirectory(t, "plogkit-ios-cocoapods-timeout-"); + writeExecutable( + join(directory, "xcode-select"), + "#!/bin/sh\nprintf '%s\\n' '/Applications/Xcode_26.6.app/Contents/Developer'\n", + ); + writeExecutable( + join(directory, "xcodebuild"), + "#!/bin/sh\nprintf '%s\\n' 'Xcode 26.6' 'Build version 17F113'\n", + ); + writeExecutable(join(directory, "pod"), "#!/bin/sh\nprintf '%s\\n' '1.17.0'\n"); + const timeoutError = Object.assign(new Error("spawnSync pod ETIMEDOUT"), { + code: "ETIMEDOUT", + }); + + await withEnvironment({ PATH: `${directory}:${process.env.PATH}` }, async () => { + assert.throws( + () => + validateIosToolchain({ + captureCocoaPodsVersion(timeoutMs) { + assert.equal(timeoutMs, 60_000); + throw timeoutError; + }, + }), + (error) => { + assert.equal(error.code, "E2E_COMMAND_TIMEOUT"); + assert.match(error.message, /CocoaPods version probe timed out after 60000ms/); + assert.doesNotMatch(error.message, /unknown is installed/); + return true; + }, + ); + }); +}); + function writeIosSimulatorHostBinary(binaries) { writeExecutable( join(binaries, "xcrun"), diff --git a/scripts/e2e/runtime.mjs b/scripts/e2e/runtime.mjs index bf5cee7..953bb2d 100644 --- a/scripts/e2e/runtime.mjs +++ b/scripts/e2e/runtime.mjs @@ -291,22 +291,14 @@ function boundedCommandError(command, args, result, { includeOutputInError, maxB result.terminationError.message, { cause: error }, ); - aggregate.code = "E2E_PROCESS_TREE_TERMINATION_FAILED"; + aggregate.code = error.code ?? "E2E_PROCESS_TREE_TERMINATION_FAILED"; return aggregate; } export async function captureBoundedCommand( command, args, - { - cleanup, - cwd, - env = process.env, - includeOutputInError = true, - maxBytes, - terminate, - timeoutMs, - }, + { cleanup, cwd, env = process.env, includeOutputInError = true, maxBytes, terminate, timeoutMs }, ) { const result = await captureDiagnostic(command, args, { captureStdout: true, @@ -428,10 +420,10 @@ export function run( .join("; ")}`, { cause: primaryError }, ); - if (terminationError) { - aggregate.code = "E2E_PROCESS_TREE_TERMINATION_FAILED"; - } else if (primaryError.code) { + if (primaryError.code) { aggregate.code = primaryError.code; + } else if (terminationError) { + aggregate.code = "E2E_PROCESS_TREE_TERMINATION_FAILED"; } reject(attachCommandMetadata(aggregate)); }; @@ -761,16 +753,32 @@ const MAESTRO_VERSION = readFileSync(join(runtimeRoot, ".maestro-version"), "utf const MAESTRO_FLOW_TIMEOUT_MS = 10 * 60 * 1000; const MAESTRO_SUITE_TIMEOUT_MS = 60 * 60 * 1000; const IOS_MAESTRO_DRIVER_STARTUP_TIMEOUT_MS = 2 * 60 * 1000; +const MAESTRO_VERSION_PROBE_TIMEOUT_MS = 60 * 1000; -export function validateMaestroVersion() { +export function validateMaestroVersion({ + captureVersion = (timeoutMs) => + capture("maestro", ["--version"], { + env: createMaestroEnvironment(), + timeoutMs, + }), + timeoutMs = MAESTRO_VERSION_PROBE_TIMEOUT_MS, +} = {}) { let output; try { - output = capture("maestro", ["--version"], { - env: createMaestroEnvironment(), - timeoutMs: 15000, - }); - } catch { - throw new Error(`Maestro ${MAESTRO_VERSION} is required but was not found on PATH.`); + output = captureVersion(timeoutMs); + } catch (error) { + if (error?.code === "ENOENT") { + throw new Error(`Maestro ${MAESTRO_VERSION} is required but was not found on PATH.`, { + cause: error, + }); + } + if (error?.code === "ETIMEDOUT") { + throw Object.assign( + new Error(`Maestro version probe timed out after ${timeoutMs}ms.`, { cause: error }), + { code: "E2E_COMMAND_TIMEOUT" }, + ); + } + throw new Error("Unable to execute the installed Maestro version probe.", { cause: error }); } const installedVersion = output.match(/\d+\.\d+\.\d+/)?.[0]; if (!installedVersion) { diff --git a/scripts/e2e/runtime.test.mjs b/scripts/e2e/runtime.test.mjs index d5ebe6b..99626e4 100644 --- a/scripts/e2e/runtime.test.mjs +++ b/scripts/e2e/runtime.test.mjs @@ -150,7 +150,8 @@ test("bounded commands can omit sensitive output from their primary error", asyn captureBoundedCommand(command, [], { includeOutputInError: false, maxBytes: 1024, - timeoutMs: 1000, + // This asserts redaction, not process-start latency; shared CI needs scheduling headroom. + timeoutMs: 5000, }), (error) => { assert.doesNotMatch(error.message, /Users\/runner|private-catalog/); @@ -186,6 +187,27 @@ test("bounded command finalization failures are not replayed by global cleanup", assert.equal(terminationAttempts, 1); }); +test("bounded command timeouts preserve their primary failure identity when finalization fails", async () => { + const terminationError = Object.assign(new Error("kill EPERM"), { code: "EPERM" }); + + await assert.rejects( + captureBoundedCommand(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + maxBytes: 1024, + terminate: async (child) => { + child.kill("SIGKILL"); + throw terminationError; + }, + timeoutMs: 500, + }), + (error) => { + assert.equal(error.code, "E2E_COMMAND_TIMEOUT"); + assert.equal(error.cause.code, "E2E_COMMAND_TIMEOUT"); + assert.equal(error.errors.includes(terminationError), true); + return true; + }, + ); +}); + test( "bounded command capture kills a TERM-resistant process tree retaining output pipes", { skip: process.platform === "win32" }, @@ -372,6 +394,7 @@ test("run preserves a command failure when its output artifact also fails", asyn (error) => { assert.ok(error instanceof AggregateError); assert.match(error.cause.message, /Command failed \(23\):/); + assert.equal(error.code, undefined); assert.equal(error.errors[0], error.cause); assert.match(error.errors[1].message, /Unable to write command output/); return true; @@ -395,6 +418,27 @@ test("run bounds a hung process tree and reports the stage timeout", async () => assert.ok(Date.now() - startedAt < 2000); }); +test("run preserves a timed-out command identity when process finalization fails", async () => { + const terminationError = Object.assign(new Error("kill EPERM"), { code: "EPERM" }); + + await assert.rejects( + run(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + stdio: "ignore", + terminate: async (child) => { + child.kill("SIGKILL"); + throw terminationError; + }, + timeoutMs: 500, + }), + (error) => { + assert.equal(error.code, "E2E_COMMAND_TIMEOUT"); + assert.equal(error.cause.code, "E2E_COMMAND_TIMEOUT"); + assert.equal(error.errors.includes(terminationError), true); + return true; + }, + ); +}); + test( "process cleanup escalates TERM to KILL and waits for the entire process group", { skip: process.platform === "win32" }, @@ -570,6 +614,28 @@ esac ); }); +test("Maestro version validation distinguishes a bounded cold start from a missing executable", () => { + const timeoutError = Object.assign(new Error("spawnSync maestro ETIMEDOUT"), { + code: "ETIMEDOUT", + }); + + assert.throws( + () => + validateMaestroVersion({ + captureVersion(timeoutMs) { + assert.equal(timeoutMs, 60_000); + throw timeoutError; + }, + }), + (error) => { + assert.equal(error.code, "E2E_COMMAND_TIMEOUT"); + assert.match(error.message, /version probe timed out after 60000ms/i); + assert.doesNotMatch(error.message, /not found on PATH/i); + return true; + }, + ); +}); + test("Maestro validation and execution use the same PATH executable", async (t) => { const directory = createTemporaryTestDirectory(t, "plogkit-e2e-maestro-path-"); const binaries = join(directory, "bin"); diff --git a/scripts/e2e/suite-config.test.mjs b/scripts/e2e/suite-config.test.mjs index e52b9f7..1639196 100644 --- a/scripts/e2e/suite-config.test.mjs +++ b/scripts/e2e/suite-config.test.mjs @@ -132,6 +132,31 @@ test("the export flow asserts the system photo delta after each successful expor assert.ok(successOffsets[1] < assertionCalls[1].index); }); +test("text edit flows require the editor panel to close before their next assertion", () => { + const commitSubflow = readFileSync( + join(root, "e2e/subflows/scroll-and-apply-text-edits.yaml"), + "utf8", + ); + const addTextFlow = readFileSync(join(root, "e2e/flows/f01-add-text.yaml"), "utf8"); + const exportFlow = readFileSync(join(root, "e2e/flows/f04-export.yaml"), "utf8"); + + assert.match( + commitSubflow, + /- tapOn:\n id: commit-text\n- assertNotVisible:\n id: commit-text/, + "the shared text commit must prove that the editor panel closed", + ); + assert.match( + addTextFlow, + /- runFlow: \.\.\/subflows\/scroll-and-apply-text-edits\.yaml\n- assertVisible:\n id: canvas-text-hit-0-\.\*/, + ); + assert.match( + exportFlow, + /- runFlow: \.\.\/subflows\/scroll-and-apply-text-edits\.yaml\n- tapOn:\n id: editor-open-export/, + ); + assert.doesNotMatch(addTextFlow, /- assertVisible: 周末的海边日记/); + assert.doesNotMatch(exportFlow, /- assertVisible: "导出后更新 ✨"/); +}); + test("the iOS picker waits for two interactive photos before selecting them", () => { const source = readFileSync(join(root, "e2e/subflows/select-two-photos-ios.yaml"), "utf8"); const gridWait = source.indexOf("visible:\n id: PXGGridLayout-Info");