mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix: restore JavaScript analysis and current regression checks (#3080)
Restore source-position-aware ambient aliases, scalar env/argv reads, candidate endpoint worker data, Chrome DOMSnapshot empty strings, and accumulated Ghidra LaunchSupport diagnostics. Align regression fixtures with declared current contracts, while preserving identity, provenance, cancellation, and cleanup checks. Assert workflow caveats independently at both established locations. Validation: original five source failures reproduced on main; 307 focused tests passed with one platform skip, 22 fixture follow-up tests and 22 workflow/MCP tests passed; full static and docs checks passed. Real public http-server CLI analyses preserve identical artifact identity, statistics, and 581-token selected output. Real Chromium confirms restored DOM form destinations; isolated real service-worker verification passes on main and the change. The complete local browser run retained an unclassified later marker timeout; current-head CI provides complete real-browser and recovery lane results. Native IDA/Ghidra execution and complete local suite execution are not claimed. Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
25 files changed
+395
-145
No files matched your search
@@ -130,14 +130,13 @@ describe("CDP DOM string and namespace validity", () => {
|
||||
{ field: "nodeName", index: -1 },
|
||||
{ field: "nodeName", index: 20 },
|
||||
{ field: "nodeName", index: 2, malformedString: true },
|
||||
{ field: "nodeValue", index: -1 },
|
||||
{ field: "nodeValue", index: 20 },
|
||||
{ field: "nodeValue", index: 5, malformedString: true },
|
||||
{ field: "attributeName", index: -1 },
|
||||
{ field: "attributeName", index: 20 },
|
||||
{ field: "attributeValue", index: -1 },
|
||||
{ field: "attributeValue", index: 20 },
|
||||
] as const)(
|
||||
// -1 is DOMSnapshot's empty string for values, never for names.
|
||||
"omits a node when its $field string index is invalid ($index)",
|
||||
({
|
||||
field,
|
||||
@@ -353,3 +352,62 @@ describe("CDP accessibility capture", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CDP DOM snapshot empty strings", () => {
|
||||
it("reads Chrome's -1 string index as an empty node or attribute value", () => {
|
||||
const completeness = new CdpCaptureCompleteness();
|
||||
const generator = captureDom(
|
||||
{
|
||||
strings: [
|
||||
origin + "/app",
|
||||
"#document",
|
||||
"A",
|
||||
"href",
|
||||
"hidden",
|
||||
"#text",
|
||||
"go",
|
||||
],
|
||||
documents: [
|
||||
{
|
||||
documentURL: 0,
|
||||
baseURL: -1,
|
||||
nodes: {
|
||||
nodeType: [9, 1, 3],
|
||||
nodeName: [1, 2, 5],
|
||||
nodeValue: [-1, -1, 6],
|
||||
parentIndex: [-1, 0, 1],
|
||||
attributes: [[], [3, -1, 4, -1], []],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
new Set([origin]),
|
||||
inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: "http://127.0.0.1:9222",
|
||||
allowed_origins: [origin],
|
||||
target_id: "page-1",
|
||||
}),
|
||||
completeness,
|
||||
);
|
||||
let captured = generator.next([]);
|
||||
while (!captured.done) captured = generator.next([]);
|
||||
expect(
|
||||
captured.value.nodes.map(
|
||||
({ node_name, node_value_length, attribute_names }) => ({
|
||||
node_name,
|
||||
node_value_length,
|
||||
attribute_names,
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
{ node_name: "#document", node_value_length: 0, attribute_names: [] },
|
||||
{
|
||||
node_name: "A",
|
||||
node_value_length: 0,
|
||||
attribute_names: ["href", "hidden"],
|
||||
},
|
||||
{ node_name: "#text", node_value_length: 2, attribute_names: [] },
|
||||
]);
|
||||
expect(completeness.snapshot().excluded).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
cdpStringValue,
|
||||
type UnknownRecord,
|
||||
} from "./CdpCaptureValues.js";
|
||||
import {
|
||||
cdpSnapshotAttributesResolve,
|
||||
cdpSnapshotString,
|
||||
cdpSnapshotValueString,
|
||||
} from "./CdpSnapshotStrings.js";
|
||||
import { exclusionReasonForUrl } from "./CdpCaptureEventHelpers.js";
|
||||
import type { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
|
||||
import {
|
||||
@@ -183,7 +188,7 @@ export function* captureDom(
|
||||
let total = 0;
|
||||
let unresolvedSvgNodes = 0;
|
||||
for (const document of recordsValue(root.documents)) {
|
||||
const documentUrl = indexedString(strings, document.documentURL);
|
||||
const documentUrl = cdpSnapshotString(strings, document.documentURL);
|
||||
if (documentUrl === undefined) {
|
||||
completeness?.exclude("dom", "invalid_protocol_value");
|
||||
continue;
|
||||
@@ -195,7 +200,7 @@ export function* captureDom(
|
||||
const baseUrlValue =
|
||||
document.baseURL === undefined
|
||||
? documentUrl
|
||||
: indexedString(strings, document.baseURL);
|
||||
: cdpSnapshotValueString(strings, document.baseURL);
|
||||
if (baseUrlValue === undefined) {
|
||||
completeness?.exclude("dom", "invalid_protocol_value");
|
||||
continue;
|
||||
@@ -248,8 +253,8 @@ export function* captureDom(
|
||||
const nodeValueIndex = rawNodeValues[index];
|
||||
const parent = rawParents[index];
|
||||
const attributeIndexes = numberArray(rawAttributes[index]);
|
||||
const nodeName = indexedString(strings, nodeNameIndex);
|
||||
const nodeValue = indexedString(strings, nodeValueIndex);
|
||||
const nodeName = cdpSnapshotString(strings, nodeNameIndex);
|
||||
const nodeValue = cdpSnapshotValueString(strings, nodeValueIndex);
|
||||
if (
|
||||
nodeType === undefined ||
|
||||
nodeName === undefined ||
|
||||
@@ -259,11 +264,7 @@ export function* captureDom(
|
||||
parent === index ||
|
||||
parent >= rawNodeTypes.length ||
|
||||
attributeIndexes === undefined ||
|
||||
attributeIndexes.length % 2 !== 0 ||
|
||||
attributeIndexes.some(
|
||||
(value) =>
|
||||
value === undefined || indexedString(strings, value) === undefined,
|
||||
)
|
||||
!cdpSnapshotAttributesResolve(strings, attributeIndexes)
|
||||
) {
|
||||
completeness?.exclude("dom", "invalid_protocol_value");
|
||||
continue;
|
||||
@@ -514,16 +515,6 @@ const accessibilityStates = (
|
||||
const axText = (value: unknown): string | null =>
|
||||
cdpStringValue(recordValue(value)?.value) ?? null;
|
||||
|
||||
const indexedString = (
|
||||
strings: readonly (string | undefined)[],
|
||||
index: unknown,
|
||||
): string | undefined => {
|
||||
const integer = numberValue(index);
|
||||
if (integer === undefined || !Number.isSafeInteger(integer) || integer < 0)
|
||||
return undefined;
|
||||
return strings[integer];
|
||||
};
|
||||
|
||||
const uniqueAttributeNames = (
|
||||
attributeIndexes: readonly number[],
|
||||
strings: readonly (string | undefined)[],
|
||||
@@ -531,7 +522,7 @@ const uniqueAttributeNames = (
|
||||
const names: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (let index = 0; index < attributeIndexes.length; index += 2) {
|
||||
const name = indexedString(strings, attributeIndexes[index]);
|
||||
const name = cdpSnapshotString(strings, attributeIndexes[index]);
|
||||
if (name === undefined) continue;
|
||||
if (seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
@@ -597,8 +588,8 @@ const domMetadata = (
|
||||
const pairs = new Map<string, string>();
|
||||
for (let index = 0; index + 1 < attributes.length; index += 2) {
|
||||
// Chrome already folds HTML names; SVG/XML names remain case-sensitive.
|
||||
const name = indexedString(strings, attributes[index]);
|
||||
const value = indexedString(strings, attributes[index + 1]);
|
||||
const name = cdpSnapshotString(strings, attributes[index]);
|
||||
const value = cdpSnapshotValueString(strings, attributes[index + 1]);
|
||||
if (name === undefined || value === undefined) continue;
|
||||
// HTML keeps the first declaration. A later repeat must not move dom_urls.
|
||||
if (!pairs.has(name)) pairs.set(name, value);
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { numberValue, recordValue } from "./CdpCaptureValues.js";
|
||||
import {
|
||||
cdpSnapshotAttributesResolve,
|
||||
cdpSnapshotString,
|
||||
cdpSnapshotValueString,
|
||||
} from "./CdpSnapshotStrings.js";
|
||||
|
||||
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
|
||||
const XLINK_NAMESPACE = "http://www.w3.org/1999/xlink";
|
||||
@@ -52,16 +57,6 @@ interface DomSvgLink {
|
||||
readonly xlinkHref: string | undefined;
|
||||
}
|
||||
|
||||
const stringAt = (
|
||||
strings: readonly (string | undefined)[],
|
||||
index: unknown,
|
||||
): string | undefined => {
|
||||
const integer = numberValue(index);
|
||||
if (integer === undefined || !Number.isSafeInteger(integer) || integer < 0)
|
||||
return undefined;
|
||||
return strings[integer];
|
||||
};
|
||||
|
||||
const numberArray = (value: unknown): readonly (number | undefined)[] =>
|
||||
Array.isArray(value)
|
||||
? value.map((item) => {
|
||||
@@ -86,7 +81,7 @@ const xmlDocument = (nodes: DomSnapshotNodes): boolean | undefined => {
|
||||
for (let index = 0; index < nodes.nodeTypes.length; index += 1) {
|
||||
if (nodes.nodeTypes[index] !== 1) continue;
|
||||
if (nodes.parents[index] !== documentIndex) continue;
|
||||
const name = stringAt(nodes.strings, nodes.nodeNames[index]);
|
||||
const name = cdpSnapshotString(nodes.strings, nodes.nodeNames[index]);
|
||||
if (name === undefined || !validSvgSourceNode(nodes, index))
|
||||
return undefined;
|
||||
if (name === "HTML") return false;
|
||||
@@ -96,7 +91,7 @@ const xmlDocument = (nodes: DomSnapshotNodes): boolean | undefined => {
|
||||
const attributes = numberArray(nodes.attributes[index]);
|
||||
for (let offset = 0; offset + 1 < attributes.length; offset += 2) {
|
||||
const value = attributes[offset];
|
||||
const attributeName = stringAt(nodes.strings, value);
|
||||
const attributeName = cdpSnapshotString(nodes.strings, value);
|
||||
if (attributeName === undefined) return undefined;
|
||||
if (xmlnsPrefix(attributeName) !== undefined) return true;
|
||||
}
|
||||
@@ -195,7 +190,7 @@ const resolveXmlElements = (
|
||||
const svgElement =
|
||||
nodes.nodeTypes[node] === 1 &&
|
||||
elementNamespace(
|
||||
stringAt(nodes.strings, nodes.nodeNames[node]),
|
||||
cdpSnapshotString(nodes.strings, nodes.nodeNames[node]),
|
||||
bindings,
|
||||
) === SVG_NAMESPACE;
|
||||
resolved.set(node, {
|
||||
@@ -223,8 +218,8 @@ const applyDeclarations = (
|
||||
const nameIndex = attributes[index];
|
||||
const valueIndex = attributes[index + 1];
|
||||
if (nameIndex === undefined || valueIndex === undefined) continue;
|
||||
const name = stringAt(strings, nameIndex);
|
||||
const value = stringAt(strings, valueIndex);
|
||||
const name = cdpSnapshotString(strings, nameIndex);
|
||||
const value = cdpSnapshotValueString(strings, valueIndex);
|
||||
if (name === undefined || value === undefined) continue;
|
||||
const prefix = xmlnsPrefix(name);
|
||||
if (prefix === undefined || seen.has(prefix)) continue;
|
||||
@@ -277,7 +272,7 @@ const xlinkHrefValue = (
|
||||
const nameIndex = attributes[index];
|
||||
const valueIndex = attributes[index + 1];
|
||||
if (nameIndex === undefined || valueIndex === undefined) continue;
|
||||
const name = stringAt(strings, nameIndex);
|
||||
const name = cdpSnapshotString(strings, nameIndex);
|
||||
if (name === undefined) continue;
|
||||
if (seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
@@ -286,7 +281,7 @@ const xlinkHrefValue = (
|
||||
const prefix = name.slice(0, colon);
|
||||
const local = name.slice(colon + 1);
|
||||
if (local !== "href" || bindings.get(prefix) !== XLINK_NAMESPACE) continue;
|
||||
return stringAt(strings, valueIndex);
|
||||
return cdpSnapshotValueString(strings, valueIndex);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
@@ -297,19 +292,14 @@ const validSvgSourceNode = (
|
||||
): boolean => {
|
||||
if (
|
||||
nodes.nodeTypes[index] === undefined ||
|
||||
stringAt(nodes.strings, nodes.nodeNames[index]) === undefined
|
||||
cdpSnapshotString(nodes.strings, nodes.nodeNames[index]) === undefined
|
||||
)
|
||||
return false;
|
||||
const rawAttributes = nodes.attributes[index];
|
||||
if (!Array.isArray(rawAttributes) || rawAttributes.length % 2 !== 0)
|
||||
return false;
|
||||
if (!Array.isArray(rawAttributes)) return false;
|
||||
const attributes = numberArray(rawAttributes);
|
||||
return (
|
||||
attributes.length === rawAttributes.length &&
|
||||
attributes.every(
|
||||
(attribute) =>
|
||||
attribute !== undefined &&
|
||||
stringAt(nodes.strings, attribute) !== undefined,
|
||||
)
|
||||
cdpSnapshotAttributesResolve(nodes.strings, attributes)
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { numberValue } from "./CdpCaptureValues.js";
|
||||
|
||||
// DOMSnapshot interns an empty or null string as index -1 instead of a table
|
||||
// entry. Chrome uses it for element node values and empty attribute values.
|
||||
const CDP_EMPTY_STRING_INDEX = -1;
|
||||
|
||||
/** Resolve a DOMSnapshot name index, which must reference a string entry. */
|
||||
export const cdpSnapshotString = (
|
||||
strings: readonly (string | undefined)[],
|
||||
index: unknown,
|
||||
): string | undefined => {
|
||||
const integer = numberValue(index);
|
||||
if (integer === undefined || !Number.isSafeInteger(integer) || integer < 0)
|
||||
return undefined;
|
||||
return strings[integer];
|
||||
};
|
||||
|
||||
/** Resolve a DOMSnapshot value index, where -1 is the protocol's empty string. */
|
||||
export const cdpSnapshotValueString = (
|
||||
strings: readonly (string | undefined)[],
|
||||
index: unknown,
|
||||
): string | undefined =>
|
||||
numberValue(index) === CDP_EMPTY_STRING_INDEX
|
||||
? ""
|
||||
: cdpSnapshotString(strings, index);
|
||||
|
||||
/** Whether flattened name/value attribute indexes all resolve. */
|
||||
export const cdpSnapshotAttributesResolve = (
|
||||
strings: readonly (string | undefined)[],
|
||||
attributes: readonly (number | undefined)[],
|
||||
): boolean =>
|
||||
attributes.length % 2 === 0 &&
|
||||
attributes.every(
|
||||
(attribute, offset) =>
|
||||
attribute !== undefined &&
|
||||
(offset % 2 === 0
|
||||
? cdpSnapshotString(strings, attribute)
|
||||
: cdpSnapshotValueString(strings, attribute)) !== undefined,
|
||||
);
|
||||
@@ -147,7 +147,9 @@ describe("wrapped contextBridge exposure methods", () => {
|
||||
`const worldId = selectWorld(); electron.contextBridge.exposeInIsolatedWorld(worldId, "isolatedApi", {ping: true});`,
|
||||
),
|
||||
);
|
||||
expect(result.electron.context_bridge_apis).toContainEqual(
|
||||
// The proven Webpack bootstrap resolves `load(2)` directly, so the complete
|
||||
// direct observation is retained instead of a wrapper substitution.
|
||||
expect(result.electron.context_bridge_apis).toEqual([
|
||||
expect.objectContaining({
|
||||
world: "isolated",
|
||||
world_id: {
|
||||
@@ -156,9 +158,15 @@ describe("wrapped contextBridge exposure methods", () => {
|
||||
expression: "worldId",
|
||||
},
|
||||
api_key: "isolatedApi",
|
||||
api_status: "object-literal",
|
||||
members: ["ping"],
|
||||
resolution_basis: "wrapper-substitution",
|
||||
unknown_members: 0,
|
||||
module_key: "9",
|
||||
module_runtime: "webpack-bootstrap@0",
|
||||
}),
|
||||
]);
|
||||
expect(result.electron.context_bridge_apis[0]).not.toHaveProperty(
|
||||
"resolution_basis",
|
||||
);
|
||||
expect(result.parse_status).toBe("partial");
|
||||
});
|
||||
|
||||
@@ -56,9 +56,13 @@ const createAmbientPathResolver = (
|
||||
const prior = possibleWrites ? undefined : cached.get(node);
|
||||
if (prior !== undefined) return prior;
|
||||
const result = new Map<string, AmbientPath>();
|
||||
const pending: { node: t.Node; suffix: AmbientProjection }[] = [
|
||||
{ node, suffix: [] },
|
||||
];
|
||||
// The caller's write site selects initializers for the written target.
|
||||
// Reads reached through an initializer occur at their own position.
|
||||
const pending: {
|
||||
node: t.Node;
|
||||
suffix: AmbientProjection;
|
||||
readsAtSelf?: true;
|
||||
}[] = [{ node, suffix: [] }];
|
||||
// Join projections at their producing expression, rather than enumerating
|
||||
// alias histories. Each key can only widen to a wildcard, and conflicting
|
||||
// depths widen to unknown, so diamonds and cycles have finite state.
|
||||
@@ -105,7 +109,11 @@ const createAmbientPathResolver = (
|
||||
// A widened projection is a possible write, never proof of an intrinsic.
|
||||
if (suffix === undefined && !possibleWrites) continue;
|
||||
const continueWith = (node: t.Node, path: AmbientProjection = suffix) =>
|
||||
pending.push({ node, suffix: path });
|
||||
pending.push({
|
||||
node,
|
||||
suffix: path,
|
||||
...(current.readsAtSelf && { readsAtSelf: true }),
|
||||
});
|
||||
if (
|
||||
t.isMetaProperty(root) &&
|
||||
root.meta.name === "import" &&
|
||||
@@ -126,20 +134,19 @@ const createAmbientPathResolver = (
|
||||
const binding = resolveSemanticBindingState(state, root, root.name);
|
||||
if (binding === undefined) continue;
|
||||
const stable = semanticStableInitializer(binding, state);
|
||||
const readAt = current.readsAtSelf ? root : capture;
|
||||
const initializers = possibleWrites
|
||||
? capture === undefined
|
||||
? readAt === undefined
|
||||
? binding.initializers
|
||||
: semanticMutationInitializers(
|
||||
binding,
|
||||
capture,
|
||||
state.parentsByNode,
|
||||
).initializers
|
||||
: semanticMutationInitializers(binding, readAt, state.parentsByNode)
|
||||
.initializers
|
||||
: stable === undefined
|
||||
? []
|
||||
: [stable];
|
||||
for (const initializer of initializers)
|
||||
pending.push({
|
||||
node: initializer.node,
|
||||
readsAtSelf: true,
|
||||
suffix:
|
||||
suffix === undefined
|
||||
? undefined
|
||||
|
||||
@@ -206,3 +206,43 @@ describe("JavaScript semantic analysis: child-process signals", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("JavaScript semantic analysis: configuration value escapes", () => {
|
||||
const configurationReads = (body: string) =>
|
||||
analyzeJavaScriptSemantics(`function run() { ${body} }`)
|
||||
.configurationOperations.filter(({ kind }) => kind !== "default")
|
||||
.map(({ kind, key }) => [kind, key]);
|
||||
|
||||
it.each([
|
||||
[
|
||||
"an environment value",
|
||||
"return Number(process.env.PORT);",
|
||||
"environment",
|
||||
"PORT",
|
||||
],
|
||||
["an argv entry", "return Number(process.argv[2]);", "argv", "2"],
|
||||
])("keeps a read after passing %s to a call", (_name, body, kind, key) => {
|
||||
expect(configurationReads(body)).toEqual([[kind, key]]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"a direct argv entry",
|
||||
"process.argv[2] = {}; return Number(process.argv[2]);",
|
||||
],
|
||||
[
|
||||
"an aliased argv entry",
|
||||
"const argv = process.argv; argv[2] = {}; return Number(process.argv[2]);",
|
||||
],
|
||||
[
|
||||
"the argv container",
|
||||
"mutate(process.argv); return Number(process.argv[2]);",
|
||||
],
|
||||
[
|
||||
"the env container",
|
||||
"mutate(process.env); return Number(process.env.PORT);",
|
||||
],
|
||||
])("leaves a read unknown after replacing %s", (_name, body) => {
|
||||
expect(configurationReads(body)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -69,6 +69,16 @@ export function* collectSemanticMemberMutationsSteps(
|
||||
node: t.Node | undefined,
|
||||
): void => {
|
||||
const identity = semanticRequireExportsIdentity(specifier, path);
|
||||
// Node stores process.env values and process.argv entries as strings, so
|
||||
// passing one to a call exposes no object that the callee could mutate.
|
||||
// Replacing an entry records its own write or receiver escape.
|
||||
if (
|
||||
effect === "escape" &&
|
||||
identity.specifier === "process" &&
|
||||
identity.path.length === 2 &&
|
||||
(identity.path[0] === "env" || identity.path[0] === "argv")
|
||||
)
|
||||
return;
|
||||
const key = JSON.stringify([
|
||||
"required",
|
||||
identity.specifier,
|
||||
|
||||
@@ -263,7 +263,6 @@ const causeLaunchOutcome = (
|
||||
redact: (value: string, incomplete?: boolean) => string,
|
||||
): Record<string, JsonValue> => {
|
||||
if (!(cause instanceof GhidraLaunchError)) return {};
|
||||
if (cause.cause instanceof GhidraLaunchSupportError) return {};
|
||||
const launchSupport =
|
||||
cause.launchSupport.length === 0
|
||||
? {}
|
||||
@@ -273,6 +272,9 @@ const causeLaunchOutcome = (
|
||||
stderr: redact(output.stderr),
|
||||
})),
|
||||
};
|
||||
// A failed LaunchSupport helper reports its own command outcome in the
|
||||
// nested cause; earlier helper warnings still belong to this launch.
|
||||
if (cause.cause instanceof GhidraLaunchSupportError) return launchSupport;
|
||||
const output = cause.capturedOutput;
|
||||
if (output === undefined) return launchSupport;
|
||||
const stdout = boundedUtf8Text(
|
||||
|
||||
@@ -779,18 +779,18 @@ describe("native IDA attached operation admission", () => {
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("does not admit the headless-only containment and document operations", async () => {
|
||||
const { client, producer } = await fixture();
|
||||
for (const [operation, parameters] of [
|
||||
["resolve_containing_procedure", { address: "0x1000" }],
|
||||
["list_documents", {}],
|
||||
] as const) {
|
||||
const result = await client.execute(operation, parameters);
|
||||
it("reports the bound document but not headless-only address resolution", async () => {
|
||||
const { client, producer, target } = await fixture();
|
||||
const containing = await client.execute("resolve_containing_procedure", {
|
||||
address: "0x1000",
|
||||
});
|
||||
expect(containing.ok).toBe(false);
|
||||
if (!containing.ok)
|
||||
expect(containing.error._tag).toBe("AnalysisCapabilityUnavailableError");
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok)
|
||||
expect(result.error._tag).toBe("AnalysisCapabilityUnavailableError");
|
||||
}
|
||||
const documents = await client.execute("list_documents", {});
|
||||
if (!documents.ok) throw documents.error;
|
||||
expect(documents.value.result).toEqual([target.path]);
|
||||
expect(
|
||||
producer.calls.some(({ name }) => name === "lookup_funcs"),
|
||||
"nothing is dispatched upstream",
|
||||
|
||||
@@ -328,6 +328,7 @@ export const javaScriptWorkerStaticAnalysisSchema = z
|
||||
kind: z.enum(["route", "network"]),
|
||||
value: z.string(),
|
||||
mechanism: z.string(),
|
||||
interpretation: z.literal("candidate").exactOptional(),
|
||||
}),
|
||||
),
|
||||
storage: z.array(
|
||||
|
||||
@@ -485,6 +485,7 @@ it("preserves complete line-comment source-map URLs through CLI and MCP", async
|
||||
declared_url: declaredUrl,
|
||||
declared_url_sha256: digest,
|
||||
resolved_path: "app.js.map",
|
||||
resolution_status: "resolved",
|
||||
available: true,
|
||||
},
|
||||
evidence: expect.objectContaining({
|
||||
@@ -507,6 +508,7 @@ it("preserves complete line-comment source-map URLs through CLI and MCP", async
|
||||
properties: {
|
||||
declared_url_sha256: digest,
|
||||
resolved_path: "app.js.map",
|
||||
resolution_status: "resolved",
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -565,6 +567,7 @@ it("resolves source-map URL paths once without changing their declarations", asy
|
||||
declared_url: declaredUrl,
|
||||
declared_url_sha256: digest,
|
||||
resolved_path: path,
|
||||
resolution_status: "resolved",
|
||||
available: true,
|
||||
},
|
||||
evidence: expect.objectContaining({
|
||||
@@ -587,7 +590,11 @@ it("resolves source-map URL paths once without changing their declarations", asy
|
||||
expect.objectContaining({
|
||||
relation: "maps_to",
|
||||
target_node_id: map?.node_id,
|
||||
properties: { declared_url_sha256: digest, resolved_path: path },
|
||||
properties: {
|
||||
declared_url_sha256: digest,
|
||||
resolved_path: path,
|
||||
resolution_status: "resolved",
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1576,8 +1583,11 @@ it("retains bundled isolated-world contextBridge APIs through dedicated and gene
|
||||
world: "isolated",
|
||||
world_id: { status: "literal", value: 1001, expression: null },
|
||||
api_key: "isolatedApi",
|
||||
api_status: "object-literal",
|
||||
members: ["ping"],
|
||||
resolution_basis: "wrapper-substitution",
|
||||
unknown_members: 0,
|
||||
// The proven Webpack bootstrap resolves `load(2)` directly.
|
||||
resolution_basis: "direct-syntax",
|
||||
}),
|
||||
evidence: expect.objectContaining({
|
||||
location: expect.objectContaining({
|
||||
|
||||
@@ -73,6 +73,7 @@ cliTest.for(["CLI", "stdio MCP"] as const)(
|
||||
declared_url: url,
|
||||
declared_url_sha256: digest,
|
||||
resolved_path: url,
|
||||
resolution_status: "resolved",
|
||||
available: true,
|
||||
},
|
||||
evidence: expect.objectContaining({
|
||||
@@ -92,7 +93,11 @@ cliTest.for(["CLI", "stdio MCP"] as const)(
|
||||
expect.objectContaining({
|
||||
relation: "maps_to",
|
||||
target_node_id: map?.node_id,
|
||||
properties: { declared_url_sha256: digest, resolved_path: url },
|
||||
properties: {
|
||||
declared_url_sha256: digest,
|
||||
resolved_path: url,
|
||||
resolution_status: "resolved",
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -108,7 +108,9 @@ cliTest(
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(repeatedNode.observations).toHaveLength(2);
|
||||
expect(repeatedNode.identity).toMatchObject({ key: "/ok" });
|
||||
expect(repeatedNode.identity).toMatchObject({
|
||||
key: JSON.stringify(["literal", "/ok"]),
|
||||
});
|
||||
expect(
|
||||
repeatedNode.observations.every(({ label }) => label === "/ok"),
|
||||
).toBe(true);
|
||||
|
||||
@@ -10,7 +10,10 @@ import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { expect, it as runtimeTest } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { webNetworkCaptureSchema } from "../../../src/domain/webNetworkCapture.js";
|
||||
import {
|
||||
WEB_NETWORK_CAPTURE_LIMITS,
|
||||
webNetworkCaptureSchema,
|
||||
} from "../../../src/domain/webNetworkCapture.js";
|
||||
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
@@ -478,12 +481,19 @@ it.each([
|
||||
expect(JSON.parse(result.stdout).category).toBe(
|
||||
["encrypted", "unknown-member", "dtd"].includes(variant)
|
||||
? "unsupported_provider"
|
||||
: variant === "expanded-limit"
|
||||
? "execution_failure"
|
||||
: "invalid_input",
|
||||
: "invalid_input",
|
||||
);
|
||||
// Expanded size is a caller-correctable input bound, not a decoder failure.
|
||||
if (variant === "expanded-limit")
|
||||
expect(JSON.parse(result.stdout).code).toBe("unreadable_output");
|
||||
expect(JSON.parse(result.stdout).details.issues).toEqual([
|
||||
expect.objectContaining({
|
||||
path: ["capture_path", ""],
|
||||
reason: "out_of_range",
|
||||
expected: {
|
||||
maximum_expanded_capture_bytes: WEB_NETWORK_CAPTURE_LIMITS.inputBytes,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
expect(JSON.stringify(result)).not.toContain("malformed-private-input");
|
||||
});
|
||||
|
||||
|
||||
@@ -13,12 +13,18 @@ import { describe, expect, it } from "vitest";
|
||||
import { execFileOutput } from "../../../src/process/ExecFileOutput.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const run = (root: string, command: string, arguments_: readonly string[]) =>
|
||||
const run = (
|
||||
root: string,
|
||||
command: string,
|
||||
arguments_: readonly string[],
|
||||
env?: NodeJS.ProcessEnv,
|
||||
) =>
|
||||
execFileOutput(command, arguments_, {
|
||||
cwd: root,
|
||||
stopSignal: "SIGTERM",
|
||||
timeout: 30_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
...(env === undefined ? {} : { env }),
|
||||
});
|
||||
|
||||
const git = (root: string, ...arguments_: string[]) =>
|
||||
@@ -72,11 +78,15 @@ const fixture = async () => {
|
||||
return root;
|
||||
};
|
||||
|
||||
// Assertions read the child's plain-text summary. An inherited color setting
|
||||
// styled the counts in the CI lane; NO_COLOR takes precedence over FORCE_COLOR.
|
||||
const testCommand = (root: string, ...arguments_: string[]) =>
|
||||
run(root, process.execPath, [
|
||||
"scripts/run-development-tests.mjs",
|
||||
...arguments_,
|
||||
]);
|
||||
run(
|
||||
root,
|
||||
process.execPath,
|
||||
["scripts/run-development-tests.mjs", ...arguments_],
|
||||
{ ...process.env, NO_COLOR: "1" },
|
||||
);
|
||||
|
||||
describe("development test runner exact selection", () => {
|
||||
it("matches selected path casing to the host filesystem", async () => {
|
||||
|
||||
@@ -55,8 +55,13 @@ describe("DirectoryArtifactReader resource ownership", () => {
|
||||
const movedRoot = `${root}-original`;
|
||||
try {
|
||||
const entries = reader.entries()[Symbol.asyncIterator]();
|
||||
expect((await entries.next()).value?.path).toBe("sub");
|
||||
expect((await entries.next()).value?.path).toBe("sample.txt");
|
||||
// Root entries follow the filesystem's directory order; descendants are
|
||||
// read only after the root is exhausted.
|
||||
const rootEntries = [
|
||||
(await entries.next()).value?.path,
|
||||
(await entries.next()).value?.path,
|
||||
];
|
||||
expect(rootEntries.sort()).toEqual(["sample.txt", "sub"]);
|
||||
|
||||
await rename(root, movedRoot);
|
||||
await mkdir(root);
|
||||
|
||||
+9
-5
@@ -79,6 +79,10 @@ it("retains empty literal evidence across the application boundary surfaces", as
|
||||
);
|
||||
});
|
||||
|
||||
/** Endpoint identity keys tag literal values apart from candidates. */
|
||||
const literalEndpointKey = (value: string): string =>
|
||||
JSON.stringify(["literal", value]);
|
||||
|
||||
it("keeps bridge entry paths stable through worker validation and API seed matching", async () => {
|
||||
const output = await analyzeSource(`
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
@@ -144,7 +148,7 @@ it("analyzes a plain long-line loader without package metadata", async () => {
|
||||
for (const node of nodes) {
|
||||
expect(node.identity).toMatchObject({
|
||||
strategy: "artifact-local-key",
|
||||
key: "",
|
||||
key: node.kind === "endpoint" ? literalEndpointKey("") : "",
|
||||
});
|
||||
expect(node.observations.map(({ label }) => label)).toEqual([null]);
|
||||
}
|
||||
@@ -165,9 +169,9 @@ it("keeps exact values for empty literals without synthetic application labels",
|
||||
// a literal two-quote endpoint; both keep exact identities.
|
||||
expect(endpoints).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ key: "", labels: [null], values: [""] },
|
||||
{ key: '""', labels: ['""'], values: ['""'] },
|
||||
{ key: "/api", labels: ["/api"], values: ["/api"] },
|
||||
{ key: literalEndpointKey(""), labels: [null], values: [""] },
|
||||
{ key: literalEndpointKey('""'), labels: ['""'], values: ['""'] },
|
||||
{ key: literalEndpointKey("/api"), labels: ["/api"], values: ["/api"] },
|
||||
]),
|
||||
);
|
||||
expect(endpoints).toHaveLength(3);
|
||||
@@ -252,7 +256,7 @@ it("does not match an empty endpoint with a literal two-quote seed", async () =>
|
||||
case_sensitive: true,
|
||||
}).filter(({ node_id: nodeId }) => endpoints.has(nodeId));
|
||||
expect(matches.map(({ node_id: nodeId }) => endpoints.get(nodeId))).toEqual([
|
||||
'""',
|
||||
literalEndpointKey('""'),
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -134,7 +134,9 @@ it("associates each source-map URL with its edge when aliases share one physical
|
||||
);
|
||||
expect(mapEdges).toHaveLength(2);
|
||||
expect(mapEdges[0]?.target_node_id).toBe(mapEdges[1]?.target_node_id);
|
||||
expect(
|
||||
// Edge order follows content-derived edge IDs, so pair each edge with the
|
||||
// script whose comment declared it.
|
||||
const declaredBySource = new Map(
|
||||
mapEdges.map((edge) => {
|
||||
const target = nodes.get(edge.target_node_id);
|
||||
const observation = target?.observations.find(
|
||||
@@ -142,15 +144,28 @@ it("associates each source-map URL with its edge when aliases share one physical
|
||||
properties.declared_url_sha256 ===
|
||||
edge.properties.declared_url_sha256,
|
||||
);
|
||||
return {
|
||||
declared_url: observation?.properties.declared_url,
|
||||
resolved_path: edge.properties.resolved_path,
|
||||
};
|
||||
const location = edge.evidence.location;
|
||||
return [
|
||||
location.available && location.value.kind === "source-range"
|
||||
? location.value.source
|
||||
: null,
|
||||
{
|
||||
declared_url: observation?.properties.declared_url,
|
||||
resolved_path: edge.properties.resolved_path,
|
||||
},
|
||||
];
|
||||
}),
|
||||
).toEqual([
|
||||
{ declared_url: "app.js.map", resolved_path: "dist/app.js.map" },
|
||||
{ declared_url: "./app.js.map", resolved_path: "dist/app.js.map" },
|
||||
]);
|
||||
);
|
||||
expect(Object.fromEntries(declaredBySource)).toEqual({
|
||||
"dist/one.js": {
|
||||
declared_url: "app.js.map",
|
||||
resolved_path: "dist/app.js.map",
|
||||
},
|
||||
"dist/two.js": {
|
||||
declared_url: "./app.js.map",
|
||||
resolved_path: "dist/app.js.map",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves a bare source-map file name and worker URL next to the script", async () => {
|
||||
@@ -191,6 +206,7 @@ it("resolves a bare source-map file name and worker URL next to the script", asy
|
||||
.update("app.js.map")
|
||||
.digest("hex"),
|
||||
resolved_path: "dist/app.js.map",
|
||||
resolution_status: "resolved",
|
||||
});
|
||||
expect(
|
||||
nodes
|
||||
|
||||
@@ -57,17 +57,18 @@ const README_LINK_DRIFT_MUTATIONS = [
|
||||
"(../installation.md#supported-agents)",
|
||||
"(../installation.md#missing-agents)",
|
||||
],
|
||||
["(../native-investigation.md)", "(../missing-investigation.md)"],
|
||||
[
|
||||
"https://rea.tools/zh-CN/guides/native/",
|
||||
"https://rea.tools/zh-CN/showcase/dx-ball/",
|
||||
"https://rea.tools/zh-CN/missing/",
|
||||
],
|
||||
[
|
||||
"https://rea.tools/zh-CN/guides/native/",
|
||||
"https://rea.tools/zh-TW/guides/native/",
|
||||
"https://rea.tools/zh-CN/showcase/dx-ball/",
|
||||
"https://rea.tools/zh-TW/showcase/dx-ball/",
|
||||
],
|
||||
[
|
||||
"https://rea.tools/zh-CN/guides/native/",
|
||||
"https://rea.tools/zh-CN/guides/native/?other=1",
|
||||
"https://rea.tools/zh-CN/showcase/dx-ball/",
|
||||
"https://rea.tools/zh-CN/showcase/dx-ball/?other=1",
|
||||
],
|
||||
[
|
||||
'src="../assets/rea-hopper-analysis.png"',
|
||||
|
||||
@@ -110,15 +110,6 @@ describe("browser comparison input boundary", () => {
|
||||
},
|
||||
{ before_scenario: {}, after_scenario: {} },
|
||||
{ before_scenario: scenario.before_scenario },
|
||||
{ ...passive, normalization: { rules: [] } },
|
||||
{
|
||||
before_scenario: scenario.before_scenario,
|
||||
normalization: { rules: [] },
|
||||
},
|
||||
{
|
||||
after_scenario: scenario.after_scenario,
|
||||
normalization: { rules: [] },
|
||||
},
|
||||
{ ...passive, unexpected: true },
|
||||
{
|
||||
...scenario,
|
||||
@@ -133,8 +124,24 @@ describe("browser comparison input boundary", () => {
|
||||
},
|
||||
},
|
||||
];
|
||||
// The projection leaves group exclusion to the canonical runtime parser.
|
||||
const mixedGroups = [{ ...passive, ...scenario }];
|
||||
// Strict clients reject combinators and dependentRequired, so the
|
||||
// projection states the groups in its description and leaves group
|
||||
// completeness and exclusion to the canonical runtime parser.
|
||||
expect(advertised.inputSchema.description).toContain(
|
||||
"before + after; before_scenario + after_scenario + [normalization]",
|
||||
);
|
||||
const mixedGroups = [
|
||||
{ ...passive, ...scenario },
|
||||
{ ...passive, normalization: { rules: [] } },
|
||||
{
|
||||
before_scenario: scenario.before_scenario,
|
||||
normalization: { rules: [] },
|
||||
},
|
||||
{
|
||||
after_scenario: scenario.after_scenario,
|
||||
normalization: { rules: [] },
|
||||
},
|
||||
];
|
||||
for (const { input, advertisedValid, runtimeValid } of [
|
||||
...[passive, scenario, withoutNormalization].map((input) => ({
|
||||
input,
|
||||
|
||||
@@ -91,7 +91,9 @@ describe("enhanced MCP tools", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("enhanced MCP graph caveats", () => {
|
||||
it("keeps generic provider caveats visible without truncating complete relationships", async () => {
|
||||
const caveat = "Decompiler output may omit indirect calls.";
|
||||
const client = await connect({
|
||||
@@ -113,21 +115,33 @@ describe("enhanced MCP tools", () => {
|
||||
arguments: { address: "0x1", direction: "forward" },
|
||||
});
|
||||
expect(graph.structuredContent).toMatchObject({
|
||||
limitations: [
|
||||
"Artifact identity is unavailable for this observation.",
|
||||
"Derived by an REA composed workflow.",
|
||||
caveat,
|
||||
],
|
||||
normalized_result: {
|
||||
graph: {
|
||||
"0": [
|
||||
{ status: "ok", relationship_coverage: { status: "complete" } },
|
||||
{
|
||||
address: "0x1",
|
||||
calls: ["0x2"],
|
||||
status: "ok",
|
||||
relationship_coverage: { status: "complete" },
|
||||
},
|
||||
],
|
||||
"1": [
|
||||
{ status: "ok", relationship_coverage: { status: "complete" } },
|
||||
{
|
||||
address: "0x2",
|
||||
calls: [],
|
||||
status: "ok",
|
||||
relationship_coverage: { status: "complete" },
|
||||
},
|
||||
],
|
||||
},
|
||||
limitations: [caveat],
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(graph.structuredContent)?.split(caveat)).toHaveLength(
|
||||
2,
|
||||
);
|
||||
|
||||
const trace = await client.callTool({
|
||||
name: "trace_call_path",
|
||||
|
||||
@@ -188,9 +188,13 @@ describe.skipIf(process.platform === "win32")(
|
||||
const cause = failure.diagnostics.failure_cause;
|
||||
if (mode === "env-failure") {
|
||||
expect(cause).toMatchObject({
|
||||
launch_outcome: {
|
||||
exit_code: 3,
|
||||
stderr: expect.stringContaining(`-envvars: ${warning}`),
|
||||
cause: {
|
||||
name: "GhidraLaunchSupportError",
|
||||
command_outcome: {
|
||||
killed: false,
|
||||
stderr: expect.stringContaining(`-envvars: ${warning}`),
|
||||
},
|
||||
cause: { code: 3 },
|
||||
},
|
||||
});
|
||||
} else {
|
||||
@@ -205,9 +209,13 @@ describe.skipIf(process.platform === "win32")(
|
||||
}
|
||||
if (mode === "vm-failure")
|
||||
expect(cause).toMatchObject({
|
||||
launch_outcome: {
|
||||
exit_code: 3,
|
||||
stderr: expect.stringContaining(`-vmargs: ${warning}`),
|
||||
cause: {
|
||||
name: "GhidraLaunchSupportError",
|
||||
command_outcome: {
|
||||
killed: false,
|
||||
stderr: expect.stringContaining(`-vmargs: ${warning}`),
|
||||
},
|
||||
cause: { code: 3 },
|
||||
},
|
||||
});
|
||||
if (mode === "vm-malformed")
|
||||
|
||||
@@ -41,6 +41,7 @@ class CancelThenFixtureLauncher implements BridgeLauncher {
|
||||
readonly firstLaunch = new Promise<ChildProcess>((resolve) => {
|
||||
this.#firstLaunchReady = resolve;
|
||||
});
|
||||
retriedProcess: ChildProcess | undefined;
|
||||
|
||||
async launch(session: BridgeSession) {
|
||||
this.#launches += 1;
|
||||
@@ -49,6 +50,7 @@ class CancelThenFixtureLauncher implements BridgeLauncher {
|
||||
: new FixtureLauncher().launch(session));
|
||||
if (this.#launches === 1 && launched.ok)
|
||||
this.#firstLaunchReady?.(launched.value.process);
|
||||
else if (launched.ok) this.retriedProcess = launched.value.process;
|
||||
return launched;
|
||||
}
|
||||
}
|
||||
@@ -256,9 +258,17 @@ describe("HopperClient startup failures", () => {
|
||||
expect(
|
||||
firstProcess.exitCode !== null || firstProcess.signalCode !== null,
|
||||
).toBe(true);
|
||||
await expect(client.start()).resolves.toEqual({
|
||||
const restarted = await client.start();
|
||||
const retriedProcess = launcher.retriedProcess;
|
||||
expect(retriedProcess).toBeDefined();
|
||||
expect(retriedProcess).not.toBe(firstProcess);
|
||||
expect(restarted).toEqual({
|
||||
ok: true,
|
||||
value: { name: "REA Hopper bridge", version: "1.0.0" },
|
||||
value: {
|
||||
name: "REA Hopper bridge",
|
||||
version: "1.0.0",
|
||||
processId: retriedProcess?.pid,
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
client.callTool("echo", { value: "retried" }),
|
||||
@@ -282,11 +292,16 @@ describe("HopperClient startup failures", () => {
|
||||
error: { _tag: "HopperCancelledError" },
|
||||
});
|
||||
expect(launcher.processes).toHaveLength(0);
|
||||
await expect(client.start()).resolves.toEqual({
|
||||
ok: true,
|
||||
value: { name: "REA Hopper bridge", version: "1.0.0" },
|
||||
});
|
||||
const started = await client.start();
|
||||
expect(launcher.processes).toHaveLength(1);
|
||||
expect(started).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
name: "REA Hopper bridge",
|
||||
version: "1.0.0",
|
||||
processId: launcher.processes[0]?.pid,
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
client.callTool("echo", { value: "retried" }),
|
||||
).resolves.toEqual({
|
||||
|
||||
@@ -74,15 +74,12 @@ it
|
||||
const { stdout } = await parent;
|
||||
const { pid } = JSON.parse(stdout) as { pid: number };
|
||||
orphanPid = pid;
|
||||
if (alternateMode) {
|
||||
const processEntry = join(procRoot, String(pid));
|
||||
await mkdir(processEntry);
|
||||
for (const name of ["cmdline", "comm", "exe"])
|
||||
await symlink(
|
||||
`/proc/${String(pid)}/${name}`,
|
||||
join(processEntry, name),
|
||||
);
|
||||
}
|
||||
// The runtime check reads only the supplied proc root, so expose the
|
||||
// fixture there in both modes.
|
||||
const processEntry = join(procRoot, String(pid));
|
||||
await mkdir(processEntry);
|
||||
for (const name of ["cmdline", "comm", "exe"])
|
||||
await symlink(`/proc/${String(pid)}/${name}`, join(processEntry, name));
|
||||
const { stdout: coordinates } = await execFileAsync("ps", [
|
||||
"-p",
|
||||
String(pid),
|
||||
|
||||
Reference in new issue
Block a user