feat: verify explored edges by replay

This commit is contained in:
赵义仑
2026-06-16 20:45:04 +08:00
parent 347d68626e
commit 1ea4c52ad1
5 changed files with 337 additions and 122 deletions

View File

@@ -33,6 +33,24 @@ function hasEdgeForAccessibleName(
});
}
function edgeForAccessibleName(
graph: {
edges: Array<{
edgeId: string;
elementId: string;
toStateId: string | null;
effects: Array<{ type: string; before?: string; after?: string; description: string }>;
}>;
elements: Array<{ elementId: string; accessibleName: string | null }>;
},
accessibleName: string,
) {
return graph.edges.find((edge) => {
const element = graph.elements.find((item) => item.elementId === edge.elementId);
return element?.accessibleName === accessibleName;
});
}
test("scan captures a state-local inventory without clicking high-risk actions", async () => {
const graph = await withTempOutput("scan", (out) => scanUrl({ url: fixturePath, out }));
assert.equal(graph.states.length, 1);
@@ -198,8 +216,110 @@ test("explore records low-risk edges and keeps high-risk submit actions out of d
assert.equal(graph.states.length >= 2, true);
});
test("explore records edge effects and opened-by state relationships", async () => {
const graph = await withTempOutput("explore-effects", (out) => exploreUrl({ url: fixturePath, maxActions: 12, out }));
const entryState = graph.states[0];
const openSettingsEdge = edgeForAccessibleName(graph, "Open settings");
assert.ok(entryState);
assert.ok(openSettingsEdge);
assert.ok(openSettingsEdge.toStateId);
const openedState = graph.states.find((state) => state.stateId === openSettingsEdge.toStateId);
assert.ok(openedState);
assert.equal(entryState.openedByEdgeId, null);
assert.equal(openedState.openedByEdgeId, openSettingsEdge.edgeId);
const visibleTextEffect = openSettingsEdge.effects.find((effect) => effect.type === "visibleText");
assert.ok(visibleTextEffect);
assert.equal(visibleTextEffect.before, entryState.visibleTextHash);
assert.equal(visibleTextEffect.after, openedState.visibleTextHash);
assert.equal(openSettingsEdge.effects.some((effect) => effect.type === "actionableInventory"), true);
assert.equal(openSettingsEdge.effects.some((effect) => effect.description === "Actionable inventory changed after action" && effect.type === "dom"), false);
});
test("explore replays each v0.2.1 edge from the entry state", async () => {
const graph = await withTempOutput("explore-entry-replay", (out) => exploreUrl({ url: fixturePath, maxActions: 8, out }));
const entryState = graph.states[0];
assert.ok(entryState);
assert.equal(graph.edges.length > 0, true);
assert.equal(graph.edges.every((edge) => edge.fromStateId === entryState.stateId), true);
});
test("explore isolates replay contexts for storage-writing actions", async () => {
const graph = await withTempOutput("explore-storage-isolation", (out) => exploreUrl({ url: fixturePath, maxActions: 8, out }));
const entryState = graph.states[0];
const rememberFilterEdge = edgeForAccessibleName(graph, "Remember filter");
const leakText = "Replay storage leaked";
assert.ok(entryState);
assert.ok(rememberFilterEdge);
assert.equal(graph.edges.every((edge) => edge.fromStateId === entryState.stateId), true);
assert.equal(
graph.states.some(
(state) =>
state.title.includes(leakText) ||
state.url.includes(leakText) ||
state.route.includes(leakText) ||
state.evidence.some((item) => item.description.includes(leakText)),
),
false,
);
assert.equal(
graph.elements.some(
(element) =>
element.accessibleName === leakText ||
element.text?.includes(leakText) ||
element.evidence.some((item) => item.description.includes(leakText)),
),
false,
);
assert.equal(
graph.edges.some((edge) =>
edge.effects.some(
(effect) =>
effect.description.includes(leakText) || effect.before?.includes(leakText) || effect.after?.includes(leakText),
),
),
false,
);
});
test("explore records locator failures instead of edges or fake effects for unsafe low-risk candidates", async () => {
const graph = await withTempOutput("explore-locator-failures", (out) => exploreUrl({ url: fixturePath, maxActions: 10, out }));
const entryState = graph.states[0];
assert.ok(entryState);
const duplicates = graph.elements.filter((element) => element.stateId === entryState.stateId && element.accessibleName === "Duplicate");
const namelessButton = graph.elements.find((element) => element.stateId === entryState.stateId && element.tag === "button" && element.accessibleName === null);
assert.equal(duplicates.length, 2);
assert.equal(hasEdgeForAccessibleName(graph, "Duplicate"), false);
assert.ok(namelessButton);
assert.equal(graph.edges.some((edge) => edge.elementId === namelessButton.elementId), false);
for (const duplicate of duplicates) {
assert.equal(
graph.failedObservations.some(
(failure) => failure.elementId === duplicate.elementId && failure.reason === "locator_not_unique",
),
true,
);
}
assert.equal(
graph.failedObservations.some(
(failure) => failure.elementId === namelessButton.elementId && failure.reason === "locator_not_found",
),
true,
);
assert.equal(
graph.edges.some((edge) => edge.effects.some((effect) => effect.description.includes("Action failed:"))),
false,
);
});
test("explore records state-local skipped high-risk actions with evidence", async () => {
const graph = await withTempOutput("explore-skips", (out) => exploreUrl({ url: fixturePath, maxActions: 8, out }));
const graph = await withTempOutput("explore-skips", (out) => exploreUrl({ url: fixturePath, maxActions: 12, out }));
assert.equal(graph.skippedActions.some((skip) => skip.reason.includes("form_submit")), true);
const deleteElement = graph.elements.find((element) => element.accessibleName === "Delete account");