Files
rea/src/browser/CdpPageCaptureScripts.ts
T
Itoje Dollar e4ac7bbfc2 fix: order remaining outputs independently of host locale (#1867)
* fix: order remaining outputs independently of host locale

String.prototype.localeCompare without a pinned collation follows the
host's ICU default locale, so the same input could be ordered differently
on different machines. 614fb768 moved canonical domain projections to
compareUnicodeCodePoints; provider, application, catalog, and setup
orderings still used locale collation.

Order every remaining source site by Unicode code point, including Mach-O
symbols, firmware extraction paths, ASAR entries (previously pinned to
"en" collation), evidence ID sets, browser and Electron target, script,
and resource inventories, reconstruction obligations, capability and
provider listings, and setup environment details.

The current tool and prompt catalog names order identically under both
comparators, so the catalog identity digest is unchanged.

* fix(artifacts): order ASAR entries by their portable path

@electron/asar lists host-spelled paths, so code-point order over the raw
spelling still differed by host: "a\b" sorts after "a0" on Windows while
"a/b" sorts before it on POSIX. Pair each listing with its portable
archive path once, order by that path, and keep the host spelling for
@electron/asar calls and adapter keys.
2026-10-12 01:10:01 +08:00

171 lines
5.1 KiB
TypeScript

import type { WebPageInspection } from "../domain/browserObservationSchemas.js";
import {
reconcileCapturedWebScript,
stableWebScriptKey,
} from "../domain/webInventory.js";
import type { CapturedResource } from "./CdpCaptureDocuments.js";
import type { CdpCaptureEvents } from "./CdpCaptureEvents.js";
import type { CapturedScript } from "./CdpCaptureEventTypes.js";
import {
requiredScriptSource,
sourceExcluded,
sourceResult,
} from "./CdpCaptureValues.js";
import type { CaptureContext } from "./CdpPageCapture.js";
import type { WebSourceMapRequest } from "./WebSourceMapFetcher.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
export interface CapturedScripts {
readonly inventory: WebPageInspection["scripts"];
readonly sourceMapRequests: readonly WebSourceMapRequest[];
}
export interface CaptureScriptsOptions {
readonly context: CaptureContext;
readonly events: CdpCaptureEvents;
readonly rawResources: readonly CapturedResource[];
readonly resources: WebPageInspection["resources"];
readonly frameIds: ReadonlySet<string>;
}
interface ScriptDraftState {
readonly context: CaptureContext;
readonly events: CdpCaptureEvents;
readonly frameIds: ReadonlySet<string>;
readonly rawResources: readonly CapturedResource[];
readonly resources: WebPageInspection["resources"];
}
type ScriptDraftItem = Omit<
WebPageInspection["scripts"]["items"][number],
"script_key"
>;
export const captureScripts = async (
options: CaptureScriptsOptions,
): Promise<CapturedScripts> => {
const { context, events, rawResources, resources, frameIds } = options;
const state: ScriptDraftState = {
context,
events,
frameIds,
rawResources,
resources,
};
const drafts: {
readonly item: ScriptDraftItem;
readonly sourceMapRawUrl: string | null;
}[] = [];
for (const script of events.scripts.values())
drafts.push(await buildScriptDraft(script, state));
if (!context.input.include_script_sources)
events.completeness.exclude(
"script_sources",
"not_approved",
events.scripts.size,
);
return keyAndBuildInventory(drafts, events.scripts.size);
};
const buildScriptDraft = async (
script: CapturedScript,
state: ScriptDraftState,
): Promise<{
readonly item: ScriptDraftItem;
readonly sourceMapRawUrl: string | null;
}> => {
let source: WebPageInspection["scripts"]["items"][number]["source"] = state
.context.input.include_script_sources
? await captureScriptSource(script, state)
: sourceExcluded("source capture was not selected");
const inventoryScript = {
frame_id: state.events.frameForScript(script, state.frameIds),
url: script.url,
cdp_hash: script.hash,
length: script.length,
is_module: script.isModule,
language: script.language,
source_map_url: script.sourceMapUrl,
};
return {
item: {
...inventoryScript,
origin: script.origin,
resource_reconciliation: reconcileCapturedWebScript(
{ ...inventoryScript, rawUrl: script.rawUrl },
state.rawResources,
state.resources,
),
source,
},
sourceMapRawUrl: script.sourceMapRawUrl,
};
};
const captureScriptSource = async (
script: CapturedScript,
state: ScriptDraftState,
): Promise<WebPageInspection["scripts"]["items"][number]["source"]> => {
const { context } = state;
const content = requiredScriptSource(
await context.connection.send(
"Debugger.getScriptSource",
{ scriptId: script.scriptId },
context.sessionId,
context.signal,
),
context.operation,
);
return sourceResult(content);
};
const keyAndBuildInventory = (
drafts: readonly {
readonly item: ScriptDraftItem;
readonly sourceMapRawUrl: string | null;
}[],
scriptCount: number,
): CapturedScripts => {
const keyed = drafts.map((draft) => ({
...draft,
base: stableWebScriptKey(draft.item),
}));
keyed.sort(
(left, right) =>
compareUnicodeCodePoints(left.base, right.base) ||
compareUnicodeCodePoints(
sourceDigest(left.item.source),
sourceDigest(right.item.source),
) ||
compareUnicodeCodePoints(
left.item.frame_id ?? "",
right.item.frame_id ?? "",
),
);
const totals = new Map<string, number>();
for (const { base } of keyed) totals.set(base, (totals.get(base) ?? 0) + 1);
const seen = new Map<string, number>();
const sourceMapRequests: WebSourceMapRequest[] = [];
const items = keyed.map(({ base, item, sourceMapRawUrl }) => {
const occurrence = (seen.get(base) ?? 0) + 1;
seen.set(base, occurrence);
const scriptKey =
totals.get(base) === 1 ? base : `${base}_${String(occurrence)}`;
if (item.source_map_url !== null && sourceMapRawUrl !== null)
sourceMapRequests.push({
scriptKey,
declaredUrl: item.source_map_url,
fetchUrl: sourceMapRawUrl,
});
return { script_key: scriptKey, ...item };
});
return {
inventory: { total: scriptCount, items },
sourceMapRequests,
};
};
const sourceDigest = (
source: WebPageInspection["scripts"]["items"][number]["source"],
): string => (source.included ? source.artifact.sha256 : source.reason);