* perf(hopper): resolve string search records once per query search_strings called _string_inventory(document) once per match, and each call sorts every string address and rebuilds the record dict, so a query cost O(matches x strings log strings). Resolve the inventory once per search. Warm search, 50% matches, fake document, best of 7: 1000 strings 186 -> 0.5 ms, 4000 strings 3161 -> 2.1 ms, 8000 strings 13121 -> 4.9 ms; output byte-identical. * perf(hopper): skip record resolution for empty string searches Resolving the full string inventory for a query with no matches added a sort of every string address that the previous code never performed. Return early when nothing matches. Zero-match search on a warm document, best of 7: 64000 strings 35.9 ms before this change, 4.0 ms after (main: 3.9 ms).
81 lines
2.4 KiB
TypeScript
81 lines
2.4 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
import { z } from "zod";
|
|
|
|
const execute = promisify(execFile);
|
|
const bridge = fileURLToPath(
|
|
new URL("../../../../bridge/hopper_bridge.py", import.meta.url),
|
|
);
|
|
const probe = fileURLToPath(
|
|
new URL("../../../fixtures/hopperStringSearchProbe.py", import.meta.url),
|
|
);
|
|
const resultSchema = z.array(
|
|
z.object({
|
|
pattern: z.string(),
|
|
case_sensitive: z.boolean(),
|
|
actual: z.array(z.object({ address: z.string() }).passthrough()),
|
|
expected: z.array(z.object({ address: z.string() }).passthrough()),
|
|
}),
|
|
);
|
|
|
|
async function runProbe() {
|
|
const { stdout } = await execute("python3", [probe, bridge], {
|
|
encoding: "utf8",
|
|
timeout: 5_000,
|
|
maxBuffer: 1_024 * 1_024,
|
|
});
|
|
return resultSchema.parse(JSON.parse(stdout));
|
|
}
|
|
|
|
describe("Hopper string search", () => {
|
|
it("returns the independently expected records for every query, including repeats", async () => {
|
|
const results = await runProbe();
|
|
expect(results).toHaveLength(14);
|
|
for (const result of results) {
|
|
expect(result.actual).toEqual(result.expected);
|
|
}
|
|
});
|
|
|
|
it("matches case-insensitively and case-sensitively across segments in ascending address order", async () => {
|
|
const results = await runProbe();
|
|
const byQuery = (pattern: string, caseSensitive: boolean) =>
|
|
results
|
|
.find(
|
|
(item) =>
|
|
item.pattern === pattern && item.case_sensitive === caseSensitive,
|
|
)
|
|
?.actual.map((item) => item.address);
|
|
expect(byQuery("needle", false)).toEqual([
|
|
"0x1010",
|
|
"0x1030",
|
|
"0x1050",
|
|
"0x2000",
|
|
"0x3020",
|
|
"0x3040",
|
|
]);
|
|
expect(byQuery("needle", true)).toEqual(["0x1010", "0x2000", "0x3040"]);
|
|
expect(byQuery("zzz-absent", false)).toEqual([]);
|
|
});
|
|
|
|
it("preserves the decoding-unavailable record for an unreadable string", async () => {
|
|
const results = await runProbe();
|
|
const search = results.find(
|
|
(item) => item.pattern === "Broken" && item.case_sensitive,
|
|
);
|
|
expect(search?.actual).toEqual([
|
|
{
|
|
address: "0x1030",
|
|
value: "Broken Needle",
|
|
provider_value: "Broken Needle",
|
|
decoding: {
|
|
available: false,
|
|
reason: "Hopper reported an invalid string object extent at 0x1030",
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
});
|