mirror of
https://github.com/morluto/rea
synced 2026-10-25 14:01:13 -04:00
fix(async): own completion across test and provider boundaries
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { createVerifierHttpServer } from "../lib/verifier-http-server.mjs";
|
||||
import { once } from "node:events";
|
||||
import { createServer } from "node:http";
|
||||
import { gzipSync } from "node:zlib";
|
||||
|
||||
export const networkFixtureSecret = "私密-network-fixture";
|
||||
@@ -23,7 +23,7 @@ const html = `<!doctype html><meta charset="utf-8"><body><button id="start">Run<
|
||||
export async function startBrowserNetworkSite() {
|
||||
const counts = new Map();
|
||||
const timers = new Set();
|
||||
const server = createServer(async (request, response) => {
|
||||
const handleRequest = async (request, response) => {
|
||||
const path = new URL(request.url, "http://fixture.test").pathname;
|
||||
counts.set(path, (counts.get(path) ?? 0) + 1);
|
||||
if (path === "/same") {
|
||||
@@ -89,7 +89,8 @@ export async function startBrowserNetworkSite() {
|
||||
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
response.end(html);
|
||||
}
|
||||
});
|
||||
};
|
||||
const { server, close } = createVerifierHttpServer(handleRequest);
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
@@ -100,10 +101,7 @@ export async function startBrowserNetworkSite() {
|
||||
counts,
|
||||
async close() {
|
||||
for (const timer of timers) clearTimeout(timer);
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve, reject) =>
|
||||
server.close((error) => (error ? reject(error) : resolve())),
|
||||
);
|
||||
await close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createVerifierHttpServer } from "../lib/verifier-http-server.mjs";
|
||||
import { once } from "node:events";
|
||||
import { createServer } from "node:http";
|
||||
|
||||
/** The CSRF cookie and the stored bearer token the page holds; request steps must send both and the capture must not keep them. */
|
||||
export const requestFixtureCsrf = "csrf-fixture-7c1d2e";
|
||||
@@ -52,7 +52,7 @@ export async function startBrowserRequestSite() {
|
||||
};
|
||||
const writes = [];
|
||||
const hosts = [];
|
||||
const server = createServer(async (request, response) => {
|
||||
const handleRequest = async (request, response) => {
|
||||
const url = new URL(request.url, "http://fixture.test");
|
||||
hosts.push(`${request.headers.host} ${url.pathname}`);
|
||||
const chunks = [];
|
||||
@@ -108,7 +108,8 @@ export async function startBrowserRequestSite() {
|
||||
}
|
||||
const [status, value] = answerItems(store, request.method, id, body);
|
||||
json(response, status, value);
|
||||
});
|
||||
};
|
||||
const { server, close } = createVerifierHttpServer(handleRequest);
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
@@ -120,11 +121,6 @@ export async function startBrowserRequestSite() {
|
||||
/** Host header and path of every request the server received. */
|
||||
hosts,
|
||||
items: store.items,
|
||||
async close() {
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve, reject) =>
|
||||
server.close((error) => (error ? reject(error) : resolve())),
|
||||
);
|
||||
},
|
||||
close,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { IncomingMessage, Server, ServerResponse } from "node:http";
|
||||
|
||||
/** Own admitted fixture requests through transport closure and report unexpected failures. */
|
||||
export function createVerifierHttpServer(
|
||||
handleRequest: (
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
) => Promise<void>,
|
||||
): {
|
||||
readonly server: Server;
|
||||
close(): Promise<void>;
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createServer } from "node:http";
|
||||
import { withVerifierCleanup } from "./verifier-cleanup.mjs";
|
||||
|
||||
/** Own admitted fixture requests through transport closure and report unexpected failures. */
|
||||
export function createVerifierHttpServer(handleRequest) {
|
||||
const pending = new Set();
|
||||
let failure;
|
||||
const server = createServer((request, response) => {
|
||||
const work = handleRequest(request, response)
|
||||
.catch((cause) => {
|
||||
failure ??= { cause };
|
||||
response.destroy();
|
||||
})
|
||||
.finally(() => {
|
||||
pending.delete(work);
|
||||
});
|
||||
pending.add(work);
|
||||
});
|
||||
return {
|
||||
server,
|
||||
close() {
|
||||
return withVerifierCleanup(
|
||||
() =>
|
||||
new Promise((resolve, reject) => {
|
||||
server.close((cause) =>
|
||||
cause === undefined ? resolve() : reject(cause),
|
||||
);
|
||||
server.closeAllConnections();
|
||||
}),
|
||||
[
|
||||
async () => {
|
||||
await Promise.all(pending);
|
||||
if (failure !== undefined) throw failure.cause;
|
||||
},
|
||||
],
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -78,12 +78,16 @@ export const resolveAppBundleExecutable = async (
|
||||
try {
|
||||
const layout = await presentLayout(path);
|
||||
if (layout !== undefined)
|
||||
return resolveLayoutExecutable(path, layout, fileSystem);
|
||||
return await resolveLayoutExecutable(path, layout, fileSystem);
|
||||
const wrapped = await wrappedBundle(path);
|
||||
if (wrapped !== undefined) {
|
||||
const wrappedLayout = await presentLayout(wrapped);
|
||||
if (wrappedLayout !== undefined)
|
||||
return resolveLayoutExecutable(wrapped, wrappedLayout, fileSystem);
|
||||
return await resolveLayoutExecutable(
|
||||
wrapped,
|
||||
wrappedLayout,
|
||||
fileSystem,
|
||||
);
|
||||
}
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof BinaryTargetError) return err(cause);
|
||||
|
||||
@@ -772,7 +772,7 @@ export class LinuxHopperPublisher {
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
return this.#verifyArchive(
|
||||
return await this.#verifyArchive(
|
||||
parsed.data.family,
|
||||
release.value,
|
||||
join(root, "archive"),
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
effectiveClientConfiguration,
|
||||
readConfigurationText,
|
||||
type ClientConfigurationSource,
|
||||
type ClientConfigurationFileError,
|
||||
} from "./ClientConfigurationFiles.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { ConfigurationError } from "../domain/configurationErrors.js";
|
||||
@@ -55,7 +56,6 @@ import {
|
||||
preserveCurrentRecoveryBackup,
|
||||
selectCurrentRecoveryBackup,
|
||||
} from "./SetupRecoveryBackup.js";
|
||||
import type { ClientConfigurationFileError } from "./ClientConfigurationFiles.js";
|
||||
import type {
|
||||
ClientConfigurationInspection,
|
||||
ClientConfigurationResult,
|
||||
@@ -376,7 +376,7 @@ const persistClientConfiguration = async ({
|
||||
!clientConfigurationValuesEqual(readback.servers[name], entry),
|
||||
)
|
||||
) {
|
||||
return rollbackOrReadbackFailure(
|
||||
return await rollbackOrReadbackFailure(
|
||||
publication,
|
||||
original,
|
||||
recoveryBackupPath,
|
||||
|
||||
@@ -243,7 +243,7 @@ export const environmentExportEvidenceFile = async (
|
||||
const refused = headroomRefusal(operation, path, size);
|
||||
if (refused !== undefined) throw refused;
|
||||
});
|
||||
return environmentExportFile(role, path, bytes, operation);
|
||||
return await environmentExportFile(role, path, bytes, operation);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof AnalysisResourceConstraintError) return err(cause);
|
||||
throw cause;
|
||||
|
||||
@@ -746,18 +746,20 @@ it.each([false, true])(
|
||||
release = deferred();
|
||||
const handles = new Set<FileHandle>();
|
||||
const stat = borrowed.stat;
|
||||
const prototype: Pick<FileHandle, "stat"> =
|
||||
Object.getPrototypeOf(borrowed);
|
||||
const spy = vi
|
||||
.spyOn(Object.getPrototypeOf(borrowed), "stat")
|
||||
.spyOn(prototype, "stat")
|
||||
.mockImplementation(async function (
|
||||
this: FileHandle,
|
||||
...args: unknown[]
|
||||
...args: Parameters<FileHandle["stat"]>
|
||||
) {
|
||||
if (this !== borrowed) {
|
||||
handles.add(this);
|
||||
entered.resolve();
|
||||
await release.promise;
|
||||
}
|
||||
return Reflect.apply(stat, this, args);
|
||||
return stat.call(this, ...args);
|
||||
});
|
||||
const caller = new AbortController(),
|
||||
reason = new Error("one admission waiter leaves");
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import { AnalysisCleanupLeaseGroup } from "../artifacts/AnalysisCleanupLease.js";
|
||||
import {
|
||||
AnalysisCleanupLeaseGroup,
|
||||
AnalysisCleanupLease,
|
||||
} from "../artifacts/AnalysisCleanupLease.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { PlaywrightBrowserScenarioProvider } from "./PlaywrightBrowserScenarioProvider.js";
|
||||
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
import { beforeEach, expect, it, vi, type Mock } from "vitest";
|
||||
import type { PlaywrightScenarioStorage } from "./PlaywrightScenarioStorage.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
openBrowser: vi.fn(),
|
||||
storage: undefined as
|
||||
| {
|
||||
initialize: ReturnType<typeof vi.fn>;
|
||||
settle: ReturnType<typeof vi.fn>;
|
||||
limitations: ReturnType<typeof vi.fn>;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
initialize: Mock<PlaywrightScenarioStorage["initialize"]>;
|
||||
settle: Mock<PlaywrightScenarioStorage["settle"]>;
|
||||
limitations: Mock<PlaywrightScenarioStorage["limitations"]>;
|
||||
close: Mock<PlaywrightScenarioStorage["close"]>;
|
||||
}
|
||||
| undefined,
|
||||
finishEvents: vi.fn(),
|
||||
@@ -216,12 +219,16 @@ const fixture = () => {
|
||||
profilePath: "/tmp/profile",
|
||||
});
|
||||
mocks.storage = {
|
||||
initialize: vi.fn(async () => undefined),
|
||||
settle: vi.fn(async () => undefined),
|
||||
limitations: vi.fn(() => []),
|
||||
close: vi.fn(async (stopLoading = false) => ({
|
||||
loadingStopped: stopLoading,
|
||||
})),
|
||||
initialize: vi.fn<PlaywrightScenarioStorage["initialize"]>(
|
||||
async () => undefined,
|
||||
),
|
||||
settle: vi.fn<PlaywrightScenarioStorage["settle"]>(async () => undefined),
|
||||
limitations: vi.fn<PlaywrightScenarioStorage["limitations"]>(() => []),
|
||||
close: vi.fn<PlaywrightScenarioStorage["close"]>(
|
||||
async (stopLoading = false) => ({
|
||||
loadingStopped: stopLoading,
|
||||
}),
|
||||
),
|
||||
};
|
||||
return { cleanup, page, storage: mocks.storage };
|
||||
};
|
||||
@@ -1328,9 +1335,7 @@ it("preserves both cleanup failures and retries retained owners", async () => {
|
||||
const { cleanup, storage } = fixture();
|
||||
const detachFailure = new Error("CDP detach rejected");
|
||||
const transportFailure = new Error("Playwright transport close failed");
|
||||
storage.close
|
||||
.mockRejectedValueOnce(detachFailure)
|
||||
.mockResolvedValueOnce(undefined);
|
||||
storage.close.mockRejectedValueOnce(detachFailure);
|
||||
cleanup.close
|
||||
.mockRejectedValueOnce(transportFailure)
|
||||
.mockImplementationOnce(async (finishEvents?: () => Promise<void>) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ABORTED, waitForAbortable } from "../process/AbortablePromise.js";
|
||||
import { snapshotGdbCommandRecords } from "./GdbCommandRecords.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
@@ -2430,25 +2431,8 @@ export const waitForGdbCommandTurn = (
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> => {
|
||||
if (signal === undefined) return previous;
|
||||
if (signal.aborted)
|
||||
return Promise.reject(
|
||||
new AnalysisCancelledError("gdb_console", { cause: signal.reason }),
|
||||
);
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const onAbort = (): void =>
|
||||
reject(
|
||||
new AnalysisCancelledError("gdb_console", { cause: signal.reason }),
|
||||
);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
previous.then(
|
||||
() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
},
|
||||
(cause: unknown) => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(cause);
|
||||
},
|
||||
);
|
||||
return waitForAbortable(previous, signal).then((outcome) => {
|
||||
if (outcome === ABORTED)
|
||||
throw new AnalysisCancelledError("gdb_console", { cause: signal.reason });
|
||||
});
|
||||
};
|
||||
|
||||
@@ -858,7 +858,7 @@ export class GhidraClient {
|
||||
try {
|
||||
const result = await this.#startWithin(deadline);
|
||||
return result.ok && deadline.signal.aborted
|
||||
? this.#startupInterrupted(deadline)
|
||||
? await this.#startupInterrupted(deadline)
|
||||
: result;
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof GhidraSessionError) return err(cause);
|
||||
|
||||
@@ -453,7 +453,7 @@ export class HopperClient {
|
||||
try {
|
||||
const result = await this.#startWithin(deadline);
|
||||
return result.ok && deadline.signal.aborted
|
||||
? this.#startupFailure(await this.#interruptionFailure(deadline))
|
||||
? await this.#startupFailure(await this.#interruptionFailure(deadline))
|
||||
: result;
|
||||
} finally {
|
||||
deadline.signal.removeEventListener("abort", onStartupAbort);
|
||||
|
||||
@@ -291,7 +291,7 @@ export class KunaTargetCommands {
|
||||
this.lifetime.signal,
|
||||
);
|
||||
if (digest !== this.target.sha256)
|
||||
return this.#workspaceFailure(
|
||||
return await this.#workspaceFailure(
|
||||
new AnalysisArtifactChangedError(
|
||||
"kuna:workspace",
|
||||
this.target.path,
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
type OwnedDebuggerDetachResult,
|
||||
} from "../process/ownedDebuggerSession.js";
|
||||
import { absoluteCallerPath } from "../filesystem/callerPath.js";
|
||||
import { ABORTED, waitForAbortable } from "../process/AbortablePromise.js";
|
||||
|
||||
const MAX_SESSIONS = 8;
|
||||
const MAX_PENDING_COMMANDS = 16;
|
||||
@@ -1839,19 +1840,11 @@ const waitForTurn = (
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> => {
|
||||
if (signal === undefined) return previous;
|
||||
if (signal.aborted)
|
||||
return Promise.reject(rizinCancellation("rizin_debug_command", signal));
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const onAbort = (): void =>
|
||||
reject(rizinCancellation("rizin_debug_command", signal));
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
previous.then(() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
});
|
||||
return waitForAbortable(previous, signal).then((outcome) => {
|
||||
if (outcome === ABORTED)
|
||||
throw rizinCancellation("rizin_debug_command", signal);
|
||||
});
|
||||
};
|
||||
|
||||
/** Translate only this transport's typed cancellation, preserving opaque/null caller reasons. */
|
||||
const rizinCancellation = (
|
||||
operation: string,
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import {
|
||||
createCliSessionFrameReader,
|
||||
encodeCliSessionFrame,
|
||||
} from "../../support/cli/cliCommandSessionFrames.mjs";
|
||||
import {
|
||||
processTest,
|
||||
waitForExit,
|
||||
} from "../../support/process/processFixture.js";
|
||||
|
||||
const worker = fileURLToPath(
|
||||
new URL("../../support/cli/cliCommandSessionWorker.mjs", import.meta.url),
|
||||
);
|
||||
|
||||
processTest(
|
||||
"keeps a real CLI worker frame failure nonzero when EOF joins command completion",
|
||||
async ({ processes }) => {
|
||||
const root = await createTestTempDirectory("rea-cli-worker-completion-");
|
||||
const preload = join(root, "deny-response-frame.mjs");
|
||||
await writeFile(
|
||||
preload,
|
||||
`
|
||||
const write = process.stdout.write.bind(process.stdout);
|
||||
const failure = new Error("selected CLI response frame write failed");
|
||||
process.stdout.write = (chunk, ...arguments_) => {
|
||||
const frame = Buffer.isBuffer(chunk)
|
||||
? JSON.parse(chunk.subarray(4).toString("utf8"))
|
||||
: undefined;
|
||||
if (frame?.ready === true) return write(chunk, ...arguments_);
|
||||
const callback = arguments_.find((value) => typeof value === "function");
|
||||
const deny = () => callback(failure);
|
||||
if (process.stdin.readableEnded) setImmediate(deny);
|
||||
else process.stdin.once("end", () => setImmediate(deny));
|
||||
return true;
|
||||
};
|
||||
`,
|
||||
);
|
||||
const result = await processes.run(
|
||||
process.execPath,
|
||||
["--import", preload, worker],
|
||||
{
|
||||
input: encodeCliSessionFrame({
|
||||
id: 1,
|
||||
arguments: ["--help"],
|
||||
cwd: root,
|
||||
env: { PATH: process.env.PATH ?? "", REA_LOG_LEVEL: "silent" },
|
||||
}),
|
||||
timeoutMs: 15_000,
|
||||
},
|
||||
);
|
||||
expect(result.signal, result.stderr).toBeNull();
|
||||
expect(result.exitCode, result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain("selected CLI response frame write failed");
|
||||
},
|
||||
);
|
||||
|
||||
processTest(
|
||||
"settles an active CLI response and skips queued work after malformed input",
|
||||
async ({ processes }) => {
|
||||
const root = await createTestTempDirectory("rea-cli-worker-malformed-");
|
||||
const marker = join(root, "response-completed");
|
||||
const preload = join(root, "hold-response-frame.mjs");
|
||||
await writeFile(
|
||||
preload,
|
||||
`
|
||||
import { writeFileSync } from "node:fs";
|
||||
const write = process.stdout.write.bind(process.stdout);
|
||||
process.stdout.write = (chunk, ...arguments_) => {
|
||||
const frame = Buffer.isBuffer(chunk)
|
||||
? JSON.parse(chunk.subarray(4).toString("utf8"))
|
||||
: undefined;
|
||||
if (frame?.id !== 1 || frame.fatal === true) return write(chunk, ...arguments_);
|
||||
const callback = arguments_.find((value) => typeof value === "function");
|
||||
const continued = new Promise((resolve) => process.stdin.once("data", resolve));
|
||||
return write(chunk, (error) => {
|
||||
if (error) callback(error);
|
||||
else continued.then(() => {
|
||||
writeFileSync(${JSON.stringify(marker)}, "response completed");
|
||||
callback();
|
||||
});
|
||||
});
|
||||
};
|
||||
`,
|
||||
);
|
||||
const child = processes.spawn(process.execPath, [
|
||||
"--import",
|
||||
preload,
|
||||
worker,
|
||||
]);
|
||||
const closed = new Promise<void>((resolve) => {
|
||||
child.once("close", () => resolve());
|
||||
});
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString("utf8");
|
||||
});
|
||||
const ids: number[] = [];
|
||||
const request = (id: number) =>
|
||||
encodeCliSessionFrame({
|
||||
id,
|
||||
arguments: ["--help"],
|
||||
cwd: root,
|
||||
env: { PATH: process.env.PATH ?? "", REA_LOG_LEVEL: "silent" },
|
||||
});
|
||||
const reader = createCliSessionFrameReader((value: unknown) => {
|
||||
const frame = z
|
||||
.object({ ready: z.boolean().optional(), id: z.number().optional() })
|
||||
.parse(value);
|
||||
if (frame.ready === true) child.stdin.write(request(1));
|
||||
if (frame.id === undefined) return;
|
||||
ids.push(frame.id);
|
||||
if (frame.id === 1) {
|
||||
const malformed = Buffer.from([0, 0, 0, 1, 123]);
|
||||
child.stdin.write(Buffer.concat([request(2), malformed]));
|
||||
}
|
||||
});
|
||||
child.stdout.on("data", reader);
|
||||
|
||||
expect(await waitForExit(child, 15_000), stderr).toBe(true);
|
||||
await closed;
|
||||
expect(child.signalCode, stderr).toBeNull();
|
||||
expect(child.exitCode, stderr).toBe(1);
|
||||
expect(stderr).toMatch(/JSON/u);
|
||||
expect(ids).toEqual([1]);
|
||||
expect(await readFile(marker, "utf8")).toBe("response completed");
|
||||
},
|
||||
);
|
||||
|
||||
processTest(
|
||||
"publishes one fatal CLI response and does not dispatch commands queued behind it",
|
||||
async ({ processes }) => {
|
||||
const root = await createTestTempDirectory("rea-cli-worker-fatal-");
|
||||
const result = await processes.run(process.execPath, [worker], {
|
||||
input: Buffer.concat([
|
||||
encodeCliSessionFrame({ id: 1, arguments: null, cwd: root, env: {} }),
|
||||
encodeCliSessionFrame({
|
||||
id: 2,
|
||||
arguments: ["--help"],
|
||||
cwd: root,
|
||||
env: { PATH: process.env.PATH ?? "", REA_LOG_LEVEL: "silent" },
|
||||
}),
|
||||
]),
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
expect(result.signal, result.stderr).toBeNull();
|
||||
expect(result.exitCode, result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain(
|
||||
"CLI session command is missing its arguments, cwd, or environment",
|
||||
);
|
||||
expect(result.stdout).toContain('"id":1');
|
||||
expect(result.stdout).toContain('"fatal":true');
|
||||
expect(result.stdout).not.toContain('"id":2');
|
||||
},
|
||||
);
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createServer } from "node:http";
|
||||
import { createVerifierHttpServer } from "../../../scripts/lib/verifier-http-server.mjs";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js";
|
||||
@@ -38,132 +39,156 @@ type StartupMode =
|
||||
| "unexpected-worker"
|
||||
| "success";
|
||||
|
||||
type StartupSupervisorState = {
|
||||
readonly mode: StartupMode;
|
||||
readonly calls: string[];
|
||||
database: string | undefined;
|
||||
input: string | undefined;
|
||||
};
|
||||
const handleStartupRequest = async (
|
||||
state: StartupSupervisorState,
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
) => {
|
||||
if (request.method !== "POST") {
|
||||
response.writeHead(405).end();
|
||||
return;
|
||||
}
|
||||
let body = "";
|
||||
for await (const chunk of request) body += String(chunk);
|
||||
const rpc = requestSchema.parse(JSON.parse(body));
|
||||
if (rpc.id === undefined) {
|
||||
response.writeHead(202).end();
|
||||
return;
|
||||
}
|
||||
const send = (result: unknown) =>
|
||||
response
|
||||
.writeHead(200, { "Content-Type": "application/json" })
|
||||
.end(JSON.stringify({ jsonrpc: "2.0", id: rpc.id, result }));
|
||||
if (rpc.method === "initialize") {
|
||||
if (state.mode === "connect-timeout") return;
|
||||
send({
|
||||
protocolVersion: rpc.params?.protocolVersion,
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: { name: "ida-startup-fixture", version: "1" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (rpc.method === "tools/list") {
|
||||
if (state.mode === "catalog-timeout") return;
|
||||
if (state.mode === "catalog-refused") {
|
||||
response.writeHead(200, { "Content-Type": "application/json" }).end(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: rpc.id,
|
||||
error: { code: -32603, message: "fixture catalog refused" },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
send({
|
||||
tools: tools
|
||||
.filter(
|
||||
(name) => state.mode !== "missing-profile" || name !== "list_funcs",
|
||||
)
|
||||
.map((name) => ({
|
||||
name,
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
})),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const name = rpc.params?.name;
|
||||
const args = rpc.params?.arguments;
|
||||
if (name === undefined || args === undefined)
|
||||
throw new Error("Unexpected RPC");
|
||||
state.calls.push(name);
|
||||
let result: unknown;
|
||||
if (name === "idb_open") {
|
||||
state.database = z.string().parse(args.preferred_session_id);
|
||||
state.input = z.string().parse(args.input_path);
|
||||
result = {
|
||||
success: true,
|
||||
session: {
|
||||
session_id:
|
||||
state.mode === "unexpected-worker"
|
||||
? "unexpected-worker"
|
||||
: state.database,
|
||||
input_path: state.input,
|
||||
},
|
||||
};
|
||||
} else if (name === "idb_list")
|
||||
result = {
|
||||
sessions:
|
||||
state.database === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
session_id: state.database,
|
||||
input_path: state.input,
|
||||
owned: true,
|
||||
backend: "worker",
|
||||
is_active: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
else if (name === "idb_close") {
|
||||
result = {
|
||||
success: true,
|
||||
session_id: state.database,
|
||||
saved: null,
|
||||
owned: true,
|
||||
backend: "worker",
|
||||
};
|
||||
state.database = undefined;
|
||||
} else if (name === "server_health")
|
||||
result = {
|
||||
status: "ok",
|
||||
input_path:
|
||||
state.mode === "headless-input-mismatch"
|
||||
? state.input + ".other"
|
||||
: state.input,
|
||||
idb_path: state.input + ".i64",
|
||||
module: "target.elf",
|
||||
imagebase: "0x1000",
|
||||
auto_analysis_ready: true,
|
||||
};
|
||||
else if (name === "lookup_funcs")
|
||||
result = z
|
||||
.array(z.string())
|
||||
.parse(args.queries)
|
||||
.map((query) => ({
|
||||
query,
|
||||
fn: { addr: "0x1000", name: "main", size: "0x20" },
|
||||
error: null,
|
||||
}));
|
||||
else if (name === "decompile")
|
||||
result = {
|
||||
code: "int main(void) { return 42; }",
|
||||
cursor: { done: true },
|
||||
};
|
||||
else throw new Error("Unexpected tool: " + name);
|
||||
send({ content: [{ type: "text", text: JSON.stringify(result) }] });
|
||||
};
|
||||
|
||||
const createStartupSupervisor = async (mode: StartupMode) => {
|
||||
const calls: string[] = [];
|
||||
let database: string | undefined;
|
||||
let input: string | undefined;
|
||||
const server = createServer(async (request, response) => {
|
||||
if (request.method !== "POST") {
|
||||
response.writeHead(405).end();
|
||||
return;
|
||||
}
|
||||
let body = "";
|
||||
for await (const chunk of request) body += String(chunk);
|
||||
const rpc = requestSchema.parse(JSON.parse(body));
|
||||
if (rpc.id === undefined) {
|
||||
response.writeHead(202).end();
|
||||
return;
|
||||
}
|
||||
const send = (result: unknown) =>
|
||||
response
|
||||
.writeHead(200, { "Content-Type": "application/json" })
|
||||
.end(JSON.stringify({ jsonrpc: "2.0", id: rpc.id, result }));
|
||||
if (rpc.method === "initialize") {
|
||||
if (mode === "connect-timeout") return;
|
||||
send({
|
||||
protocolVersion: rpc.params?.protocolVersion,
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: { name: "ida-startup-fixture", version: "1" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (rpc.method === "tools/list") {
|
||||
if (mode === "catalog-timeout") return;
|
||||
if (mode === "catalog-refused") {
|
||||
response.writeHead(200, { "Content-Type": "application/json" }).end(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: rpc.id,
|
||||
error: { code: -32603, message: "fixture catalog refused" },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
send({
|
||||
tools: tools
|
||||
.filter((name) => mode !== "missing-profile" || name !== "list_funcs")
|
||||
.map((name) => ({
|
||||
name,
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
})),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const name = rpc.params?.name;
|
||||
const args = rpc.params?.arguments;
|
||||
if (name === undefined || args === undefined)
|
||||
throw new Error("Unexpected RPC");
|
||||
calls.push(name);
|
||||
let result: unknown;
|
||||
if (name === "idb_open") {
|
||||
database = z.string().parse(args.preferred_session_id);
|
||||
input = z.string().parse(args.input_path);
|
||||
result = {
|
||||
success: true,
|
||||
session: {
|
||||
session_id:
|
||||
mode === "unexpected-worker" ? "unexpected-worker" : database,
|
||||
input_path: input,
|
||||
},
|
||||
};
|
||||
} else if (name === "idb_list")
|
||||
result = {
|
||||
sessions:
|
||||
database === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
session_id: database,
|
||||
input_path: input,
|
||||
owned: true,
|
||||
backend: "worker",
|
||||
is_active: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
else if (name === "idb_close") {
|
||||
result = {
|
||||
success: true,
|
||||
session_id: database,
|
||||
saved: null,
|
||||
owned: true,
|
||||
backend: "worker",
|
||||
};
|
||||
database = undefined;
|
||||
} else if (name === "server_health")
|
||||
result = {
|
||||
status: "ok",
|
||||
input_path:
|
||||
mode === "headless-input-mismatch" ? input + ".other" : input,
|
||||
idb_path: input + ".i64",
|
||||
module: "target.elf",
|
||||
imagebase: "0x1000",
|
||||
auto_analysis_ready: true,
|
||||
};
|
||||
else if (name === "lookup_funcs")
|
||||
result = z
|
||||
.array(z.string())
|
||||
.parse(args.queries)
|
||||
.map((query) => ({
|
||||
query,
|
||||
fn: { addr: "0x1000", name: "main", size: "0x20" },
|
||||
error: null,
|
||||
}));
|
||||
else if (name === "decompile")
|
||||
result = {
|
||||
code: "int main(void) { return 42; }",
|
||||
cursor: { done: true },
|
||||
};
|
||||
else throw new Error("Unexpected tool: " + name);
|
||||
send({ content: [{ type: "text", text: JSON.stringify(result) }] });
|
||||
});
|
||||
const state: StartupSupervisorState = {
|
||||
mode,
|
||||
calls: [],
|
||||
database: undefined,
|
||||
input: undefined,
|
||||
};
|
||||
const { server, close } = createVerifierHttpServer((request, response) =>
|
||||
handleStartupRequest(state, request, response),
|
||||
);
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
return {
|
||||
server,
|
||||
calls,
|
||||
calls: state.calls,
|
||||
get database() {
|
||||
return database;
|
||||
return state.database;
|
||||
},
|
||||
close,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -278,12 +303,7 @@ workspaceCliTest.for([
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
server.close((error) =>
|
||||
error === undefined ? resolve() : reject(error),
|
||||
),
|
||||
);
|
||||
await supervisor.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { processTest } from "../../support/process/processFixture.js";
|
||||
|
||||
for (const [fixture, factory, ordinaryPath, malformedPath] of [
|
||||
["browser-network-site", "startBrowserNetworkSite", "/binary", "/same"],
|
||||
[
|
||||
"browser-request-site",
|
||||
"startBrowserRequestSite",
|
||||
"/api/items",
|
||||
"/api/items",
|
||||
],
|
||||
] as const) {
|
||||
processTest(
|
||||
`${fixture} retains malformed request failure through physical close`,
|
||||
async ({ processes }) => {
|
||||
const module = new URL(
|
||||
`../../../scripts/fixtures/${fixture}.mjs`,
|
||||
import.meta.url,
|
||||
).href;
|
||||
const cleanup = new URL(
|
||||
"../../../scripts/lib/verifier-cleanup.mjs",
|
||||
import.meta.url,
|
||||
).href;
|
||||
const program = `
|
||||
import assert from "node:assert/strict";
|
||||
import { withVerifierCleanup } from ${JSON.stringify(cleanup)};
|
||||
const { ${factory}: start } = await import(${JSON.stringify(module)});
|
||||
const site = await start();
|
||||
let closed = false;
|
||||
await withVerifierCleanup(async () => {
|
||||
const ordinary = await fetch(site.origin + ${JSON.stringify(ordinaryPath)}, { signal: AbortSignal.timeout(2000) });
|
||||
assert.equal(ordinary.status, 200);
|
||||
await ordinary.arrayBuffer();
|
||||
await assert.rejects(fetch(site.origin + ${JSON.stringify(malformedPath)}, {
|
||||
method: "POST", body: "{", signal: AbortSignal.timeout(2000),
|
||||
}));
|
||||
const outcome = await site.close().then(
|
||||
() => ({ ok: true }), cause => ({ ok: false, cause }),
|
||||
);
|
||||
closed = true;
|
||||
assert.equal(outcome.ok, false);
|
||||
assert(outcome.cause instanceof AggregateError);
|
||||
assert.equal(outcome.cause.errors.length, 1);
|
||||
assert(outcome.cause.errors[0] instanceof SyntaxError);
|
||||
await assert.rejects(fetch(site.origin, { signal: AbortSignal.timeout(2000) }));
|
||||
console.log("ordinary response preserved; malformed request retained; endpoint closed");
|
||||
}, [async () => { if (!closed) await site.close(); }]);
|
||||
`;
|
||||
const result = await processes.run(
|
||||
process.execPath,
|
||||
["--input-type=module", "-e", program],
|
||||
{
|
||||
cwd: fileURLToPath(new URL("../../../", import.meta.url)),
|
||||
timeoutMs: 10_000,
|
||||
},
|
||||
);
|
||||
expect(result.signal, result.stderr).toBeNull();
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
expect(result.stdout).toBe(
|
||||
"ordinary response preserved; malformed request retained; endpoint closed\n",
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -744,8 +744,9 @@ describe.skipIf(process.platform === "win32")(
|
||||
controls.directory = (handle) => {
|
||||
controls.directory = undefined;
|
||||
vi.spyOn(handle, "read").mockRejectedValue(readFailure);
|
||||
const close = handle.close.bind(handle);
|
||||
vi.spyOn(handle, "close").mockImplementation(async () => {
|
||||
const directory: { close(): Promise<void> } = handle;
|
||||
const close = directory.close.bind(handle);
|
||||
vi.spyOn(directory, "close").mockImplementation(async () => {
|
||||
closes++;
|
||||
await close();
|
||||
controller.abort({ caller: "directory finalization" });
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type FileHandle,
|
||||
} from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { Dirent } from "node:fs";
|
||||
|
||||
import { afterEach, beforeEach, expect, it, onTestFinished, vi } from "vitest";
|
||||
|
||||
@@ -28,6 +29,11 @@ const openMock = vi.mocked(fsOpen);
|
||||
const opendirMock = vi.mocked(fsOpendir);
|
||||
let actual: typeof import("node:fs/promises");
|
||||
|
||||
interface DirectoryOperations {
|
||||
read(): Promise<Dirent | null>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
actual =
|
||||
await vi.importActual<typeof import("node:fs/promises")>(
|
||||
@@ -63,13 +69,14 @@ it.each([
|
||||
let readCalls = 0;
|
||||
opendirMock.mockImplementation(async (...args) => {
|
||||
const directory = await actual.opendir(...args);
|
||||
const read = directory.read.bind(directory);
|
||||
vi.spyOn(directory, "read").mockImplementation(async () => {
|
||||
const operations: DirectoryOperations = directory;
|
||||
const read = operations.read.bind(directory);
|
||||
vi.spyOn(operations, "read").mockImplementation(async () => {
|
||||
readCalls += 1;
|
||||
return read();
|
||||
});
|
||||
const close = directory.close.bind(directory);
|
||||
vi.spyOn(directory, "close").mockImplementation(async () => {
|
||||
const close = operations.close.bind(directory);
|
||||
vi.spyOn(operations, "close").mockImplementation(async () => {
|
||||
closeCalls += 1;
|
||||
if (outcome === "refusal-late-cancel") controller.abort(reason);
|
||||
await close();
|
||||
@@ -296,13 +303,14 @@ it("preserves a directory read failure when closing that directory also fails",
|
||||
|
||||
opendirMock.mockImplementation(async (...args) => {
|
||||
const directory = await actual.opendir(...args);
|
||||
const read = directory.read.bind(directory);
|
||||
vi.spyOn(directory, "read").mockImplementation(async () => {
|
||||
const operations: DirectoryOperations = directory;
|
||||
const read = operations.read.bind(directory);
|
||||
vi.spyOn(operations, "read").mockImplementation(async () => {
|
||||
if (readCalls++ > 0) throw readFailure;
|
||||
return read();
|
||||
});
|
||||
const close = directory.close.bind(directory);
|
||||
vi.spyOn(directory, "close").mockImplementation(async () => {
|
||||
const close = operations.close.bind(directory);
|
||||
vi.spyOn(operations, "close").mockImplementation(async () => {
|
||||
closeCalls += 1;
|
||||
await close();
|
||||
throw closeFailure;
|
||||
@@ -361,15 +369,16 @@ it("keeps cancelled traversal typed when native directory close rejects after in
|
||||
|
||||
opendirMock.mockImplementation(async (...args) => {
|
||||
const directory = await actual.opendir(...args);
|
||||
const operations: DirectoryOperations = directory;
|
||||
openedPath = String(args[0]);
|
||||
const read = directory.read.bind(directory);
|
||||
vi.spyOn(directory, "read").mockImplementation(async () => {
|
||||
const read = operations.read.bind(directory);
|
||||
vi.spyOn(operations, "read").mockImplementation(async () => {
|
||||
const child = await read();
|
||||
if (child !== null) controller.abort();
|
||||
return child;
|
||||
});
|
||||
const close = directory.close.bind(directory);
|
||||
vi.spyOn(directory, "close").mockImplementation(async () => {
|
||||
const close = operations.close.bind(directory);
|
||||
vi.spyOn(operations, "close").mockImplementation(async () => {
|
||||
closeCalls += 1;
|
||||
await close();
|
||||
throw closeFailure;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createServer } from "node:http";
|
||||
import { createVerifierHttpServer } from "../../../scripts/lib/verifier-http-server.mjs";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { inspect } from "node:util";
|
||||
import { ProtocolError } from "@modelcontextprotocol/client";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
@@ -49,23 +50,90 @@ type IdaTransportFixtureOptions = {
|
||||
readonly configurationHeaders?: Readonly<Record<string, string>>;
|
||||
readonly connectionDependencies?: IdaMcpConnectionDependencies;
|
||||
};
|
||||
const fixture = async ({
|
||||
redirectUrl,
|
||||
cycle = false,
|
||||
native = false,
|
||||
toolResult,
|
||||
resourceResponseText,
|
||||
toolRpcError,
|
||||
toolStatus = 200,
|
||||
toolStatusText,
|
||||
toolContentType = "application/json",
|
||||
toolResponseBody,
|
||||
configurationHeaders = { Authorization: "Bearer transport-fixture-secret" },
|
||||
connectionDependencies,
|
||||
}: IdaTransportFixtureOptions = {}) => {
|
||||
const fixtureResponse = (
|
||||
input: z.infer<typeof requestSchema>,
|
||||
options: IdaTransportFixtureOptions,
|
||||
): unknown =>
|
||||
input.method === "initialize"
|
||||
? {
|
||||
protocolVersion: input.params?.protocolVersion,
|
||||
capabilities: {
|
||||
tools: {},
|
||||
...(options.native ? { resources: {} } : {}),
|
||||
},
|
||||
serverInfo: {
|
||||
name: "ida-transport-fixture",
|
||||
version: "observed-server-version",
|
||||
},
|
||||
}
|
||||
: input.method === "tools/list"
|
||||
? {
|
||||
tools: [
|
||||
{
|
||||
name: input.params?.cursor === undefined ? "first" : "second",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
],
|
||||
...(input.params?.cursor === undefined
|
||||
? { nextCursor: "next-page" }
|
||||
: options.cycle
|
||||
? {
|
||||
nextCursor:
|
||||
input.params.cursor === "next-page"
|
||||
? "other-page"
|
||||
: "next-page",
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: input.method === "resources/read"
|
||||
? {
|
||||
contents: [
|
||||
{
|
||||
uri: "ida://idb/metadata",
|
||||
mimeType: "application/json",
|
||||
text:
|
||||
options.resourceResponseText ??
|
||||
JSON.stringify({
|
||||
path: "/fixture/input.i64",
|
||||
sha256: "a".repeat(64),
|
||||
base: "0x1000",
|
||||
module: "input",
|
||||
}),
|
||||
},
|
||||
],
|
||||
}
|
||||
: options.toolResult !== undefined
|
||||
? options.toolResult
|
||||
: options.native
|
||||
? {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify([{ observed: true }]),
|
||||
},
|
||||
],
|
||||
structuredContent: { result: [], _output_truncated: true },
|
||||
}
|
||||
: { content: [], structuredContent: { observed: true } };
|
||||
|
||||
const fixture = async (options: IdaTransportFixtureOptions = {}) => {
|
||||
const {
|
||||
redirectUrl,
|
||||
native = false,
|
||||
toolRpcError,
|
||||
toolStatus = 200,
|
||||
toolStatusText,
|
||||
toolContentType = "application/json",
|
||||
toolResponseBody,
|
||||
configurationHeaders = { Authorization: "Bearer transport-fixture-secret" },
|
||||
connectionDependencies,
|
||||
} = options;
|
||||
const methods: string[] = [];
|
||||
const headers: (string | undefined)[] = [];
|
||||
const server = createServer(async (request, response) => {
|
||||
const handleRequest = async (
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
) => {
|
||||
if (redirectUrl !== undefined) {
|
||||
response.writeHead(307, { Location: redirectUrl });
|
||||
response.end();
|
||||
@@ -86,65 +154,7 @@ const fixture = async ({
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
const result =
|
||||
input.method === "initialize"
|
||||
? {
|
||||
protocolVersion: input.params?.protocolVersion,
|
||||
capabilities: { tools: {}, ...(native ? { resources: {} } : {}) },
|
||||
serverInfo: {
|
||||
name: "ida-transport-fixture",
|
||||
version: "observed-server-version",
|
||||
},
|
||||
}
|
||||
: input.method === "tools/list"
|
||||
? {
|
||||
tools: [
|
||||
{
|
||||
name: input.params?.cursor === undefined ? "first" : "second",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
],
|
||||
...(input.params?.cursor === undefined
|
||||
? { nextCursor: "next-page" }
|
||||
: cycle
|
||||
? {
|
||||
nextCursor:
|
||||
input.params.cursor === "next-page"
|
||||
? "other-page"
|
||||
: "next-page",
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: input.method === "resources/read"
|
||||
? {
|
||||
contents: [
|
||||
{
|
||||
uri: "ida://idb/metadata",
|
||||
mimeType: "application/json",
|
||||
text:
|
||||
resourceResponseText ??
|
||||
JSON.stringify({
|
||||
path: "/fixture/input.i64",
|
||||
sha256: "a".repeat(64),
|
||||
base: "0x1000",
|
||||
module: "input",
|
||||
}),
|
||||
},
|
||||
],
|
||||
}
|
||||
: toolResult !== undefined
|
||||
? toolResult
|
||||
: native
|
||||
? {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify([{ observed: true }]),
|
||||
},
|
||||
],
|
||||
structuredContent: { result: [], _output_truncated: true },
|
||||
}
|
||||
: { content: [], structuredContent: { observed: true } };
|
||||
const result = fixtureResponse(input, options);
|
||||
if (input.method === "tools/call" && toolStatusText !== undefined)
|
||||
response.statusMessage = toolStatusText;
|
||||
response.writeHead(input.method === "tools/call" ? toolStatus : 200, {
|
||||
@@ -162,17 +172,10 @@ const fixture = async ({
|
||||
: { result }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
};
|
||||
const { server, close } = createVerifierHttpServer(handleRequest);
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
cleanups.push(
|
||||
() =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
server.close((error) =>
|
||||
error === undefined ? resolve() : reject(error),
|
||||
);
|
||||
server.closeAllConnections();
|
||||
}),
|
||||
);
|
||||
cleanups.push(close);
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string")
|
||||
throw new Error("Missing HTTP fixture address");
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { copyFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { processTest } from "../../support/process/processFixture.js";
|
||||
|
||||
processTest(
|
||||
"renderer DOM callbacks report rejected bridge actions and preserve successful actions",
|
||||
async ({ processes }) => {
|
||||
const cases = [
|
||||
{
|
||||
path: fileURLToPath(
|
||||
new URL(
|
||||
"../../../website/public/examples/notes-electron/renderer.js",
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
button: "#export",
|
||||
output: "#status",
|
||||
failure: "Export failed",
|
||||
diagnostic: "Notes export failed",
|
||||
successfulReply: "/selected/notes.csv",
|
||||
success: "Saved to /selected/notes.csv",
|
||||
api: "notes",
|
||||
},
|
||||
{
|
||||
path: fileURLToPath(
|
||||
new URL(
|
||||
"../../conformance/readiness/electron/renderer.js",
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
button: "#run",
|
||||
output: "#result",
|
||||
failure: "Echo failed",
|
||||
diagnostic: "Renderer echo failed",
|
||||
successfulReply: "renderer reply",
|
||||
success: "renderer reply",
|
||||
api: "readiness",
|
||||
},
|
||||
];
|
||||
// Execute the real classic scripts with controlled DOM/bridge seams. This
|
||||
// covers discarded event returns, not a live Electron or browser runtime.
|
||||
const source = `
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { runInNewContext } from "node:vm";
|
||||
for (const scenario of ${JSON.stringify(cases)}) {
|
||||
const failure = new Error("selected bridge rejected");
|
||||
const errors = [];
|
||||
let action, deepLink, finish, text = "";
|
||||
let reply = () => Promise.reject(failure);
|
||||
const output = {
|
||||
get textContent() { return text; },
|
||||
set textContent(value) { text = value; finish(); },
|
||||
};
|
||||
const button = { addEventListener(type, callback) {
|
||||
assert.equal(type, "click"); action = callback;
|
||||
}};
|
||||
const api = scenario.api === "notes"
|
||||
? { exportCsv: () => reply() }
|
||||
: { echo: (value) => { assert.equal(value, "renderer"); return reply(); },
|
||||
onDeepLink: (callback) => { deepLink = callback; } };
|
||||
runInNewContext(readFileSync(scenario.path, "utf8"), {
|
||||
document: { querySelector(selector) {
|
||||
if (selector === scenario.button) return button;
|
||||
assert.equal(selector, scenario.output); return output;
|
||||
}},
|
||||
window: { [scenario.api]: api },
|
||||
console: { error: (...arguments_) => errors.push(arguments_) },
|
||||
}, { filename: scenario.path });
|
||||
let completed = new Promise((resolve) => { finish = resolve; });
|
||||
action(); // DOM event dispatch ignores any callback return value.
|
||||
await completed;
|
||||
assert.equal(text, scenario.failure);
|
||||
assert.deepEqual(errors, [[scenario.diagnostic, failure]]);
|
||||
reply = () => Promise.resolve(scenario.successfulReply);
|
||||
completed = new Promise((resolve) => { finish = resolve; });
|
||||
action();
|
||||
await completed;
|
||||
assert.equal(text, scenario.success);
|
||||
assert.equal(errors.length, 1);
|
||||
if (deepLink) {
|
||||
deepLink({}, "rea://selected/link");
|
||||
assert.equal(text, "rea://selected/link");
|
||||
}
|
||||
}
|
||||
`;
|
||||
const result = await processes.run(
|
||||
process.execPath,
|
||||
[
|
||||
"--unhandled-rejections=strict",
|
||||
"--input-type=module",
|
||||
"--eval",
|
||||
source,
|
||||
],
|
||||
{ timeoutMs: 10_000 },
|
||||
);
|
||||
expect(result.signal, result.stderr).toBeNull();
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
processTest(
|
||||
"theme translation failure preserves the applied base theme and hides the toggle",
|
||||
async ({ processes }) => {
|
||||
const failureRoot = await createTestTempDirectory("rea-theme-failure-");
|
||||
const successRoot = await createTestTempDirectory("rea-theme-success-");
|
||||
const theme = fileURLToPath(
|
||||
new URL("../../../website/public/assets/theme.js", import.meta.url),
|
||||
);
|
||||
await Promise.all([
|
||||
...[failureRoot, successRoot].map((root) =>
|
||||
copyFile(theme, join(root, "theme.js")),
|
||||
),
|
||||
...[failureRoot, successRoot].map((root) =>
|
||||
writeFile(join(root, "package.json"), '{"type":"module"}\n'),
|
||||
),
|
||||
writeFile(
|
||||
join(failureRoot, "i18n.js"),
|
||||
"throw globalThis.translationFailure;\n",
|
||||
),
|
||||
writeFile(
|
||||
join(successRoot, "i18n.js"),
|
||||
'export const t = (value) => "translated:" + value;\n',
|
||||
),
|
||||
]);
|
||||
const source = `
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { Script } from "node:vm";
|
||||
globalThis.translationFailure = new Error("selected theme translation failed");
|
||||
for (const [directory, fails] of ${JSON.stringify([
|
||||
[failureRoot, true],
|
||||
[successRoot, false],
|
||||
])}) {
|
||||
const errors = [], listeners = new Map(), clicks = new Map(), writes = [];
|
||||
const root = { dataset: {} }, meta = { content: "" };
|
||||
let ready, finish, hidden = true;
|
||||
const settled = new Promise((resolve) => { finish = resolve; });
|
||||
const button = {
|
||||
get hidden() { return hidden; },
|
||||
set hidden(value) { hidden = value; if (!value) finish(); },
|
||||
setAttribute(key, value) { this[key] = value; },
|
||||
addEventListener(type, callback) { clicks.set(type, callback); },
|
||||
};
|
||||
const path = join(directory, "theme.js");
|
||||
new Script(readFileSync(path, "utf8"), {
|
||||
filename: path,
|
||||
importModuleDynamically: (specifier) => import(new URL(specifier, pathToFileURL(path)).href),
|
||||
}).runInNewContext({
|
||||
document: {
|
||||
documentElement: root,
|
||||
querySelector(selector) {
|
||||
if (selector === 'meta[name="theme-color"]') return meta;
|
||||
assert.equal(selector, "[data-theme-toggle]"); return button;
|
||||
},
|
||||
addEventListener(type, callback) { assert.equal(type, "DOMContentLoaded"); ready = callback; },
|
||||
},
|
||||
window: {
|
||||
localStorage: { getItem: () => null, setItem: (...values) => writes.push(values) },
|
||||
matchMedia: () => ({ matches: true, addEventListener() {} }),
|
||||
addEventListener(type, callback) { listeners.set(type, callback); },
|
||||
dispatchEvent(event) { listeners.get(event.type)?.(event); },
|
||||
},
|
||||
Event,
|
||||
console: { error: (...arguments_) => { errors.push(arguments_); finish(); } },
|
||||
});
|
||||
assert.equal(root.dataset.theme, "dark");
|
||||
assert.equal(meta.content, "#1e1e2e");
|
||||
assert.equal(hidden, true);
|
||||
ready(); // No event dispatcher awaits this return value.
|
||||
await settled;
|
||||
if (fails) {
|
||||
assert.deepEqual(errors, [["Theme toggle initialization failed", globalThis.translationFailure]]);
|
||||
assert.equal(hidden, true);
|
||||
assert.equal(root.dataset.theme, "dark");
|
||||
assert.equal(meta.content, "#1e1e2e");
|
||||
assert.equal(clicks.size, 0);
|
||||
} else {
|
||||
assert.deepEqual(errors, []);
|
||||
assert.equal(hidden, false);
|
||||
assert.equal(button["aria-label"], "translated:Switch to light mode");
|
||||
clicks.get("click")();
|
||||
assert.equal(root.dataset.theme, "light");
|
||||
assert.equal(meta.content, "#ffffff");
|
||||
assert.equal(button["aria-label"], "translated:Switch to dark mode");
|
||||
assert.deepEqual(writes, [["rea-theme", "light"]]);
|
||||
}
|
||||
}
|
||||
`;
|
||||
const result = await processes.run(
|
||||
process.execPath,
|
||||
[
|
||||
"--experimental-vm-modules",
|
||||
"--unhandled-rejections=strict",
|
||||
"--input-type=module",
|
||||
"--eval",
|
||||
source,
|
||||
],
|
||||
{ timeoutMs: 10_000 },
|
||||
);
|
||||
expect(result.signal, result.stderr).toBeNull();
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,78 @@
|
||||
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { processTest } from "../../support/process/processFixture.js";
|
||||
|
||||
const hopper = fileURLToPath(
|
||||
new URL("../../fixtures/hopperLaunchAdmission.mjs", import.meta.url),
|
||||
);
|
||||
const ghidra = fileURLToPath(
|
||||
new URL("../../fixtures/fakeGhidra.mjs", import.meta.url),
|
||||
);
|
||||
|
||||
for (const blocked of [false, true]) {
|
||||
processTest(
|
||||
`Hopper admission completes handoff publication before denial exit (blocked=${blocked})`,
|
||||
async ({ processes }) => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-hopper-fixture-publication-",
|
||||
);
|
||||
const mode = join(root, "mode");
|
||||
const receipt = join(root, "receipt");
|
||||
const handoff = `${receipt}.document-created`;
|
||||
await writeFile(mode, "denied-after-handoff");
|
||||
if (blocked) await mkdir(handoff);
|
||||
const result = await processes.run(
|
||||
process.execPath,
|
||||
[hopper, mode, receipt, "-Y", "controlled-bootstrap"],
|
||||
{ timeoutMs: 5_000 },
|
||||
);
|
||||
expect(result.signal, result.stderr).toBeNull();
|
||||
expect(result.exitCode, result.stderr).toBe(blocked ? 1 : 2);
|
||||
expect(result.stderr).toContain("Not authorized to send Apple events");
|
||||
expect(await readFile(receipt, "utf8")).toBe("controlled-bootstrap");
|
||||
if (blocked) {
|
||||
expect(result.stderr).toContain(handoff);
|
||||
expect((await stat(handoff)).isDirectory()).toBe(true);
|
||||
} else {
|
||||
expect(await readFile(handoff, "utf8")).toBe("unconfirmed");
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
processTest(
|
||||
"Ghidra endpoint publication reports exclusive-file refusal without declaring readiness",
|
||||
async ({ processes }) => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-ghidra-fixture-publication-",
|
||||
);
|
||||
const endpointPath = join(root, "endpoint.json");
|
||||
const pending = `${endpointPath}.pending`;
|
||||
await writeFile(pending, "previous selected evidence");
|
||||
const result = await processes.run(process.execPath, [ghidra], {
|
||||
timeoutMs: 5_000,
|
||||
env: {
|
||||
...process.env,
|
||||
REA_GHIDRA_FIXTURE_CONFIG: JSON.stringify({
|
||||
endpointPath,
|
||||
transport: "authenticated-loopback-tcp",
|
||||
token: "fixture-token",
|
||||
runId: "fixture-run",
|
||||
providerVersion: "fixture-version",
|
||||
profileDigest: "a".repeat(64),
|
||||
targetSha256: "b".repeat(64),
|
||||
}),
|
||||
},
|
||||
});
|
||||
expect(result.signal, result.stderr).toBeNull();
|
||||
expect(result.exitCode, result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain("EEXIST");
|
||||
expect(result.stderr).toContain(pending);
|
||||
expect(await readFile(pending, "utf8")).toBe("previous selected evidence");
|
||||
await expect(stat(endpointPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,47 @@
|
||||
import { copyFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { processTest } from "../../support/process/processFixture.js";
|
||||
|
||||
processTest(
|
||||
"reports the original website translation import failure at the startup boundary",
|
||||
async ({ processes }) => {
|
||||
const root = await createTestTempDirectory("rea-website-initialization-");
|
||||
const main = join(root, "main.mjs");
|
||||
await Promise.all([
|
||||
copyFile(
|
||||
fileURLToPath(
|
||||
new URL("../../../website/public/assets/main.js", import.meta.url),
|
||||
),
|
||||
main,
|
||||
),
|
||||
writeFile(join(root, "package.json"), '{"type":"module"}\n'),
|
||||
writeFile(
|
||||
join(root, "i18n.js"),
|
||||
"throw globalThis.translationFailure;\n",
|
||||
),
|
||||
]);
|
||||
// Execute the public startup code; this does not exercise browser DOM behavior.
|
||||
const source = `
|
||||
import assert from "node:assert/strict";
|
||||
globalThis.translationFailure = new Error("selected translation import failed");
|
||||
const reported = new Promise((resolve) => {
|
||||
console.error = (...arguments_) => resolve(arguments_);
|
||||
});
|
||||
await import(${JSON.stringify(pathToFileURL(main).href)});
|
||||
const [message, cause] = await reported;
|
||||
assert.equal(message, "Website initialization failed");
|
||||
assert.equal(cause, globalThis.translationFailure);
|
||||
`;
|
||||
const result = await processes.run(
|
||||
process.execPath,
|
||||
["--input-type=module", "--eval", source],
|
||||
{ timeoutMs: 10_000 },
|
||||
);
|
||||
expect(result.signal, result.stderr).toBeNull();
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
},
|
||||
);
|
||||
@@ -1,4 +1,6 @@
|
||||
import { createServer } from "node:http";
|
||||
import { createVerifierHttpServer } from "../../../scripts/lib/verifier-http-server.mjs";
|
||||
import { withVerifierCleanup } from "../../../scripts/lib/verifier-cleanup.mjs";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
|
||||
import { expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
@@ -12,13 +14,18 @@ const requestSchema = z.object({
|
||||
params: z.object({ cursor: z.string().optional() }).passthrough().optional(),
|
||||
});
|
||||
|
||||
it("reads every JEB catalog page and refreshes the connected engine catalog", async () => {
|
||||
let includeOpen = true;
|
||||
let repeatedCursor = false;
|
||||
let infinitePages = false;
|
||||
let pageCount = 0;
|
||||
const createCatalogServer = async () => {
|
||||
const controls = {
|
||||
includeOpen: true,
|
||||
repeatedCursor: false,
|
||||
infinitePages: false,
|
||||
pageCount: 0,
|
||||
};
|
||||
const cursors: (string | undefined)[] = [];
|
||||
const server = createServer(async (request, response) => {
|
||||
const handleRequest = async (
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
) => {
|
||||
if (request.method !== "POST") {
|
||||
request.resume();
|
||||
response.writeHead(request.method === "DELETE" ? 200 : 405).end();
|
||||
@@ -45,12 +52,12 @@ it("reads every JEB catalog page and refreshes the connected engine catalog", as
|
||||
};
|
||||
} else if (message.method === "tools/list") {
|
||||
const cursor = message.params?.cursor;
|
||||
pageCount += 1;
|
||||
if (!infinitePages) cursors.push(cursor);
|
||||
controls.pageCount += 1;
|
||||
if (!controls.infinitePages) cursors.push(cursor);
|
||||
const names =
|
||||
cursor === undefined
|
||||
? ["get_client_information"]
|
||||
: includeOpen
|
||||
: controls.includeOpen
|
||||
? ["open_project"]
|
||||
: ["list_units"];
|
||||
result = {
|
||||
@@ -58,9 +65,9 @@ it("reads every JEB catalog page and refreshes the connected engine catalog", as
|
||||
name,
|
||||
inputSchema: { type: "object" },
|
||||
})),
|
||||
...(infinitePages
|
||||
? { nextCursor: `fresh-page-${pageCount}` }
|
||||
: cursor === undefined || repeatedCursor
|
||||
...(controls.infinitePages
|
||||
? { nextCursor: `fresh-page-${controls.pageCount}` }
|
||||
: cursor === undefined || controls.repeatedCursor
|
||||
? { nextCursor: "second-page" }
|
||||
: {}),
|
||||
};
|
||||
@@ -69,21 +76,32 @@ it("reads every JEB catalog page and refreshes the connected engine catalog", as
|
||||
}
|
||||
response.writeHead(200, { "Content-Type": "application/json" });
|
||||
response.end(JSON.stringify({ jsonrpc: "2.0", id: message.id, result }));
|
||||
});
|
||||
};
|
||||
const { server, close } = createVerifierHttpServer(handleRequest);
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string")
|
||||
throw new Error("Expected an ephemeral TCP listener");
|
||||
const connection = createStreamableHttpJebMcpConnection(
|
||||
new URL(`http://127.0.0.1:${address.port}/mcp`),
|
||||
{ discoveryTimeoutMs: 1_000 },
|
||||
);
|
||||
|
||||
return {
|
||||
controls,
|
||||
cursors,
|
||||
endpoint: `http://127.0.0.1:${address.port}/mcp`,
|
||||
close,
|
||||
};
|
||||
};
|
||||
|
||||
it("reads every JEB catalog page and refreshes the connected engine catalog", async () => {
|
||||
const { controls, cursors, endpoint, close } = await createCatalogServer();
|
||||
const connection = createStreamableHttpJebMcpConnection(new URL(endpoint), {
|
||||
discoveryTimeoutMs: 1_000,
|
||||
});
|
||||
try {
|
||||
expect(await connection.listTools()).toEqual([
|
||||
"get_client_information",
|
||||
"open_project",
|
||||
]);
|
||||
includeOpen = false;
|
||||
controls.includeOpen = false;
|
||||
expect(await connection.listTools()).toEqual([
|
||||
"get_client_information",
|
||||
"list_units",
|
||||
@@ -94,29 +112,28 @@ it("reads every JEB catalog page and refreshes the connected engine catalog", as
|
||||
undefined,
|
||||
"second-page",
|
||||
]);
|
||||
infinitePages = true;
|
||||
const before = pageCount;
|
||||
controls.infinitePages = true;
|
||||
const before = controls.pageCount;
|
||||
await expect(connection.listTools()).rejects.toBeInstanceOf(
|
||||
AnalysisTimeoutError,
|
||||
);
|
||||
expect(pageCount - before).toBeGreaterThan(2);
|
||||
infinitePages = false;
|
||||
expect(controls.pageCount - before).toBeGreaterThan(2);
|
||||
controls.infinitePages = false;
|
||||
expect(await connection.listTools()).toEqual([
|
||||
"get_client_information",
|
||||
"list_units",
|
||||
]);
|
||||
|
||||
// Shared initial acquisition also owns a finite deadline and can recover.
|
||||
const acquiring = createStreamableHttpJebMcpConnection(
|
||||
new URL(`http://127.0.0.1:${address.port}/mcp`),
|
||||
{ discoveryTimeoutMs: 1_000 },
|
||||
);
|
||||
const acquiring = createStreamableHttpJebMcpConnection(new URL(endpoint), {
|
||||
discoveryTimeoutMs: 1_000,
|
||||
});
|
||||
try {
|
||||
infinitePages = true;
|
||||
controls.infinitePages = true;
|
||||
await expect(acquiring.listTools()).rejects.toBeInstanceOf(
|
||||
AnalysisTimeoutError,
|
||||
);
|
||||
infinitePages = false;
|
||||
controls.infinitePages = false;
|
||||
expect(await acquiring.listTools()).toEqual([
|
||||
"get_client_information",
|
||||
"list_units",
|
||||
@@ -124,21 +141,16 @@ it("reads every JEB catalog page and refreshes the connected engine catalog", as
|
||||
} finally {
|
||||
await acquiring.close();
|
||||
}
|
||||
repeatedCursor = true;
|
||||
controls.repeatedCursor = true;
|
||||
await expect(connection.listTools()).rejects.toThrow(
|
||||
"repeated a tool catalog cursor",
|
||||
);
|
||||
repeatedCursor = false;
|
||||
controls.repeatedCursor = false;
|
||||
expect(await connection.listTools()).toEqual([
|
||||
"get_client_information",
|
||||
"list_units",
|
||||
]);
|
||||
} finally {
|
||||
await connection.close();
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
server.close((cause) =>
|
||||
cause === undefined ? resolve() : reject(cause),
|
||||
),
|
||||
);
|
||||
await withVerifierCleanup(() => connection.close(), [close]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
document.querySelector("#run").addEventListener("click", async () => {
|
||||
async function echoRenderer() {
|
||||
document.querySelector("#result").textContent =
|
||||
await window.readiness.echo("renderer");
|
||||
}
|
||||
|
||||
document.querySelector("#run").addEventListener("click", () => {
|
||||
echoRenderer().catch((cause) => {
|
||||
document.querySelector("#result").textContent = "Echo failed";
|
||||
console.error("Renderer echo failed", cause);
|
||||
});
|
||||
});
|
||||
window.readiness.onDeepLink((_event, url) => {
|
||||
document.querySelector("#result").textContent = url;
|
||||
|
||||
+26
-18
@@ -24,7 +24,7 @@ const require = createRequire(import.meta.url);
|
||||
const fs = require("node:fs/promises");
|
||||
const owned = (path) => String(path).startsWith(input.prefix);
|
||||
let signalled = input.signal === undefined;
|
||||
const deliver = (observe) => {
|
||||
const recordDelivery = () => {
|
||||
writeFileSync(
|
||||
input.marker,
|
||||
JSON.stringify({
|
||||
@@ -34,24 +34,32 @@ const deliver = (observe) => {
|
||||
listeners: process.listenerCount(input.signal),
|
||||
}),
|
||||
);
|
||||
};
|
||||
const emitSignal = () => {
|
||||
if (input.delivery === "os") process.kill(process.pid, input.signal);
|
||||
else process.emit(input.signal);
|
||||
};
|
||||
const deliver = () => {
|
||||
recordDelivery();
|
||||
emitSignal();
|
||||
};
|
||||
const deliverAndObserve = async () => {
|
||||
recordDelivery();
|
||||
// Registered after the marker so it never stands in for the CLI handler.
|
||||
// Signal watchers do not keep the event loop alive; the timer does, and
|
||||
// reports a signal that was never observed. It never selects the phase.
|
||||
const observed = observe
|
||||
? new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => reject(new Error(`${input.signal} was not observed`)),
|
||||
5_000,
|
||||
);
|
||||
process.once(input.signal, () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
})
|
||||
: undefined;
|
||||
if (input.delivery === "os") process.kill(process.pid, input.signal);
|
||||
else process.emit(input.signal);
|
||||
return observed;
|
||||
const observed = new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => reject(new Error(`${input.signal} was not observed`)),
|
||||
5_000,
|
||||
);
|
||||
process.once(input.signal, () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
emitSignal();
|
||||
await observed;
|
||||
};
|
||||
|
||||
const remove = fs.rmdir;
|
||||
@@ -65,7 +73,7 @@ fs.rmdir = async (...arguments_) => {
|
||||
const result = await remove(...arguments_);
|
||||
if (phase === "cleanup" && !signalled && owned(arguments_[0])) {
|
||||
signalled = true;
|
||||
setImmediate(() => deliver(false));
|
||||
setImmediate(deliver);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -75,7 +83,7 @@ fs.mkdtemp = async (...arguments_) => {
|
||||
const directory = await stage(...arguments_);
|
||||
if (phase === "staging" && !signalled && owned(directory)) {
|
||||
signalled = true;
|
||||
await deliver(true);
|
||||
await deliverAndObserve();
|
||||
}
|
||||
return directory;
|
||||
};
|
||||
|
||||
Vendored
+12
-2
@@ -62,6 +62,7 @@ const cutIdentifierAtBudget = async () => {
|
||||
const writeThen = (stream, text, then) =>
|
||||
stream.write(text, () => setTimeout(then, 10));
|
||||
|
||||
let endpointPublication;
|
||||
if (mode === "exit") process.exit(73);
|
||||
if (mode === "flood_silent") {
|
||||
await floodStdout();
|
||||
@@ -102,7 +103,14 @@ else {
|
||||
});
|
||||
if (transport === "unix-socket") server.listen(endpointPath);
|
||||
else {
|
||||
server.listen(0, "127.0.0.1", async () => {
|
||||
endpointPublication = (async () => {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string") process.exit(65);
|
||||
const pending = `${endpointPath}.pending`;
|
||||
@@ -115,7 +123,7 @@ else {
|
||||
{ flag: "wx" },
|
||||
);
|
||||
await rename(pending, endpointPath);
|
||||
});
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,3 +517,5 @@ const stringSearchItem = () => ({
|
||||
address: "0x2000",
|
||||
value: "fixture value",
|
||||
});
|
||||
|
||||
await endpointPublication;
|
||||
|
||||
+13
-11
@@ -7,25 +7,27 @@ const bootstrapIndex = process.argv.indexOf("-Y") + 1;
|
||||
if (bootstrapIndex === 0) throw new Error("Missing Hopper bootstrap argument");
|
||||
const bootstrapPath = process.argv[bootstrapIndex];
|
||||
await writeFile(receiptPath, bootstrapPath);
|
||||
const writeDiagnostic = (text) =>
|
||||
new Promise((resolve, reject) => {
|
||||
process.stderr.write(text, (cause) => (cause ? reject(cause) : resolve()));
|
||||
});
|
||||
if (mode === "denied" || mode === "denied-after-handoff") {
|
||||
process.stderr.write(
|
||||
await writeDiagnostic(
|
||||
"Cannot launch Hopper!\nNot authorized to send Apple events to Hopper Disassembler.\n",
|
||||
async () => {
|
||||
if (mode === "denied-after-handoff") {
|
||||
// A real native helper can create a document before emitting this same
|
||||
// diagnostic when caller literals alter its AppleScript construction.
|
||||
await writeFile(`${receiptPath}.document-created`, "unconfirmed");
|
||||
}
|
||||
process.exit(2);
|
||||
},
|
||||
);
|
||||
if (mode === "denied-after-handoff") {
|
||||
// A real native helper can create a document before emitting this same
|
||||
// diagnostic when caller literals alter its AppleScript construction.
|
||||
await writeFile(`${receiptPath}.document-created`, "unconfirmed");
|
||||
}
|
||||
process.exit(2);
|
||||
} else if (mode === "not-found") {
|
||||
// A custom wrapper can forward the native diagnostic after a handoff.
|
||||
await writeFile(`${receiptPath}.document-created`, "unconfirmed");
|
||||
process.stderr.write(
|
||||
await writeDiagnostic(
|
||||
"Hopper has not been found.\nPlease make sure that Spotlight is active in order for this tool to find Hopper's executable.\n",
|
||||
() => process.exit(3),
|
||||
);
|
||||
process.exit(3);
|
||||
} else if (mode === "handoff") {
|
||||
process.exit(0);
|
||||
} else if (mode === "bridge") {
|
||||
|
||||
+31
-10
@@ -1,6 +1,9 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { rename, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { withVerifierCleanup } from "../../scripts/lib/verifier-cleanup.mjs";
|
||||
|
||||
const [mode, root] = process.argv.slice(2);
|
||||
|
||||
@@ -15,8 +18,11 @@ if (mode === "settlement") {
|
||||
const child = spawn(process.execPath, [process.argv[1], "workload", root], {
|
||||
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
||||
});
|
||||
child.on("message", async (message) => {
|
||||
if (message !== "ready" || child.pid === undefined) return;
|
||||
let handedOff = false;
|
||||
await withVerifierCleanup(async () => {
|
||||
const [message] = await once(child, "message");
|
||||
if (message !== "ready" || child.pid === undefined)
|
||||
throw new Error("Owned capture workload did not report ready");
|
||||
await publishReadyMarker({
|
||||
rootPid: process.pid,
|
||||
childPid: child.pid,
|
||||
@@ -24,17 +30,28 @@ if (mode === "settlement") {
|
||||
});
|
||||
child.disconnect();
|
||||
child.unref();
|
||||
process.stdout.write("root exiting; owned workload remains\n");
|
||||
process.exit(0);
|
||||
});
|
||||
handedOff = true;
|
||||
}, [
|
||||
async () => {
|
||||
if (
|
||||
!handedOff &&
|
||||
child.pid !== undefined &&
|
||||
child.exitCode === null &&
|
||||
child.signalCode === null
|
||||
) {
|
||||
const stopped = once(child, "exit");
|
||||
child.kill("SIGTERM");
|
||||
await stopped;
|
||||
}
|
||||
},
|
||||
]);
|
||||
process.stdout.write("root exiting; owned workload remains\n");
|
||||
process.exit(0);
|
||||
} else {
|
||||
// A closed PTY must not masquerade as capture cancellation in this fixture.
|
||||
process.on("SIGHUP", () => undefined);
|
||||
setTimeout(
|
||||
() => void writeFile(join(root, "late-write"), "still running"),
|
||||
6_000,
|
||||
);
|
||||
setTimeout(() => process.exit(0), 12_000);
|
||||
const lateWrite = delay(6_000);
|
||||
const lifetime = delay(12_000);
|
||||
if (mode === "workload") process.send?.("ready");
|
||||
else {
|
||||
await publishReadyMarker({
|
||||
@@ -43,4 +60,8 @@ if (mode === "settlement") {
|
||||
});
|
||||
process.stdout.write("owned workload ready\n");
|
||||
}
|
||||
await lateWrite;
|
||||
await writeFile(join(root, "late-write"), "still running");
|
||||
await lifetime;
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -89,8 +89,9 @@ vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
if (args[0] === race.target && race.mode === "directory_missing")
|
||||
race.opened = true;
|
||||
if (args[0] === race.target && race.mode === "cancel_directory_close") {
|
||||
const close = directory.close.bind(directory);
|
||||
vi.spyOn(directory, "close").mockImplementation(async () => {
|
||||
const closing: { close(): Promise<void> } = directory;
|
||||
const close = closing.close.bind(directory);
|
||||
vi.spyOn(closing, "close").mockImplementation(async () => {
|
||||
await close();
|
||||
race.closeCompleted = true;
|
||||
race.controller?.abort(race.reason);
|
||||
|
||||
@@ -182,8 +182,8 @@ it("snapshots the runtime-selected environment before optional loading and reuse
|
||||
return server;
|
||||
},
|
||||
serve: (factory) => {
|
||||
factory({ era: "modern" });
|
||||
factory({ era: "legacy" });
|
||||
expect(factory({ era: "modern" })).toBe(observed[0]);
|
||||
expect(factory({ era: "legacy" })).toBe(observed[1]);
|
||||
return {
|
||||
close: async () => {
|
||||
await Promise.all(observed.map((server) => server.close()));
|
||||
|
||||
@@ -16,8 +16,9 @@ vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
opendir: async (...args: Parameters<typeof filesystem.opendir>) => {
|
||||
const directory = await filesystem.opendir(...args);
|
||||
if (String(args[0]) === race.directory) {
|
||||
const close = directory.close.bind(directory);
|
||||
vi.spyOn(directory, "close").mockImplementation(async () => {
|
||||
const closing: { close(): Promise<void> } = directory;
|
||||
const close = closing.close.bind(directory);
|
||||
vi.spyOn(closing, "close").mockImplementation(async () => {
|
||||
await close();
|
||||
const hook = race.afterDirectoryClose;
|
||||
race.afterDirectoryClose = undefined;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { prepareCliProcessEnvironment } from "../../../scripts/lib/cli-process-environment.mjs";
|
||||
import { withVerifierCleanup } from "../../../scripts/lib/verifier-cleanup.mjs";
|
||||
import {
|
||||
createCliSessionFrameReader,
|
||||
encodeCliSessionFrame,
|
||||
@@ -64,28 +65,51 @@ const writeFrame = (value) =>
|
||||
await writeFrame({ ready: true });
|
||||
|
||||
let chain = Promise.resolve();
|
||||
// EOF drains admitted commands; failure seals input and skips commands not yet started.
|
||||
/** @type {{ kind: "open" } | { kind: "ended" } | { kind: "failed", cause: unknown }} */
|
||||
let completion = { kind: "open" };
|
||||
/** @type {(() => void) | undefined} */
|
||||
let resolveInput;
|
||||
/** @type {Promise<void>} */
|
||||
const inputStopped = new Promise((resolve) => {
|
||||
resolveInput = resolve;
|
||||
});
|
||||
const failInput = (cause) => {
|
||||
if (completion.kind !== "failed") completion = { kind: "failed", cause };
|
||||
process.stdin.pause();
|
||||
resolveInput?.();
|
||||
};
|
||||
process.stdin.once("end", () => {
|
||||
if (completion.kind === "open") completion = { kind: "ended" };
|
||||
resolveInput?.();
|
||||
});
|
||||
const reader = createCliSessionFrameReader((value) => {
|
||||
chain = chain.then(
|
||||
() => handle(value),
|
||||
() => handle(value),
|
||||
);
|
||||
if (completion.kind !== "open") return;
|
||||
chain = chain
|
||||
.then(async () => {
|
||||
if (completion.kind !== "failed") await handle(value);
|
||||
})
|
||||
.catch(failInput);
|
||||
});
|
||||
process.stdin.on("data", (chunk) => {
|
||||
if (completion.kind !== "open") return;
|
||||
try {
|
||||
reader(chunk);
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
`${error instanceof Error ? error.message : String(error)}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
} catch (cause) {
|
||||
failInput(cause);
|
||||
}
|
||||
});
|
||||
process.stdin.on("end", () => {
|
||||
chain.finally(async () => {
|
||||
await closeCliJavaScriptResources();
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
try {
|
||||
await withVerifierCleanup(async () => {
|
||||
await inputStopped;
|
||||
await chain;
|
||||
if (completion.kind === "failed") throw completion.cause;
|
||||
}, [closeCliJavaScriptResources]);
|
||||
} catch (cause) {
|
||||
console.error(cause);
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(0);
|
||||
|
||||
async function handle(value) {
|
||||
const request = value;
|
||||
@@ -120,6 +144,7 @@ async function handle(value) {
|
||||
...result,
|
||||
});
|
||||
} catch (error) {
|
||||
failInput(error);
|
||||
const id =
|
||||
request !== null && typeof request === "object" ? request.id : undefined;
|
||||
await writeFrame({
|
||||
@@ -131,7 +156,6 @@ async function handle(value) {
|
||||
error instanceof Error ? (error.stack ?? error.message) : String(error),
|
||||
fatal: true,
|
||||
});
|
||||
process.exit(1);
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) delete process.env[key];
|
||||
Object.assign(process.env, savedEnv);
|
||||
|
||||
@@ -165,7 +165,9 @@ async function initializeWebsite() {
|
||||
}
|
||||
}
|
||||
|
||||
initializeGitHubStars();
|
||||
return initializeGitHubStars();
|
||||
}
|
||||
|
||||
initializeWebsite();
|
||||
initializeWebsite().catch((cause) => {
|
||||
console.error("Website initialization failed", cause);
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
const initializeToggle = async () => {
|
||||
const button = document.querySelector("[data-theme-toggle]");
|
||||
if (button === null) return;
|
||||
const { t } = await import("./i18n.js");
|
||||
@@ -66,5 +66,10 @@
|
||||
});
|
||||
updateLabel();
|
||||
button.hidden = false;
|
||||
};
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initializeToggle().catch((cause) => {
|
||||
console.error("Theme toggle initialization failed", cause);
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
document.querySelector("#export").addEventListener("click", async () => {
|
||||
async function exportNotes() {
|
||||
const destination = await window.notes.exportCsv();
|
||||
document.querySelector("#status").textContent = `Saved to ${destination}`;
|
||||
}
|
||||
|
||||
document.querySelector("#export").addEventListener("click", () => {
|
||||
exportNotes().catch((cause) => {
|
||||
document.querySelector("#status").textContent = "Export failed";
|
||||
console.error("Notes export failed", cause);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user