Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion e2e/flows/f01-add-text.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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-.*
1 change: 0 additions & 1 deletion e2e/flows/f04-export.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions e2e/subflows/scroll-and-apply-text-edits.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ appId: com.leonzym.plogkit
TARGET_ID: commit-text
- tapOn:
id: commit-text
- assertNotVisible:
id: commit-text
36 changes: 29 additions & 7 deletions scripts/e2e/ios.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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,
Expand All @@ -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) {
Expand All @@ -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.");
Expand Down
34 changes: 34 additions & 0 deletions scripts/e2e/ios.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
48 changes: 28 additions & 20 deletions scripts/e2e/runtime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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));
};
Expand Down Expand Up @@ -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) {
Expand Down
68 changes: 67 additions & 1 deletion scripts/e2e/runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
Expand Down Expand Up @@ -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" },
Expand Down Expand Up @@ -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;
Expand All @@ -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" },
Expand Down Expand Up @@ -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");
Expand Down
25 changes: 25 additions & 0 deletions scripts/e2e/suite-config.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading