mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(browser): preserve malformed capture gaps and protocol session identity
This commit is contained in:
1 parent
e572c38dcf
commit
bffdd91053
22 files changed
+966
-210
No files matched your search
@@ -2,13 +2,258 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
captureAccessibility,
|
||||
captureDom,
|
||||
captureFrames,
|
||||
captureResources,
|
||||
} from "./CdpCaptureDocuments.js";
|
||||
import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
|
||||
import { inspectWebPageInputSchema } from "../domain/browserObservation.js";
|
||||
|
||||
const origin = "https://app.example.test";
|
||||
|
||||
describe("CDP document capture", () => {
|
||||
describe("CDP DOM capture", () => {
|
||||
it("preserves node names and parents when aligned arrays are valid", () => {
|
||||
const generator = captureDom(
|
||||
{
|
||||
strings: [origin + "/app", "#document", "DIV", "SPAN", ""],
|
||||
documents: [
|
||||
{
|
||||
documentURL: 0,
|
||||
baseURL: 0,
|
||||
nodes: {
|
||||
nodeType: [9, 1, 1],
|
||||
nodeName: [1, 2, 3],
|
||||
nodeValue: [4, 4, 4],
|
||||
parentIndex: [-1, 0, 1],
|
||||
attributes: [[], [], []],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
new Set([origin]),
|
||||
inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: "http://127.0.0.1:9222",
|
||||
allowed_origins: [origin],
|
||||
target_id: "page-1",
|
||||
}),
|
||||
);
|
||||
let captured = generator.next([]);
|
||||
while (!captured.done) captured = generator.next([]);
|
||||
expect(captured.value.nodes.map((node) => node.node_name)).toEqual([
|
||||
"#document",
|
||||
"DIV",
|
||||
"SPAN",
|
||||
]);
|
||||
expect(captured.value.nodes.map((node) => node.parent_index)).toEqual([
|
||||
-1, 0, 1,
|
||||
]);
|
||||
expect(captured.value.nodes.map((node) => node.node_value_length)).toEqual([
|
||||
0, 0, 0,
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["nodeName", "parentIndex", "nodeValue", "attributes"] as const)(
|
||||
"preserves DOM fact positions and reports malformed %s entries",
|
||||
(field) => {
|
||||
const strings = [
|
||||
origin + "/app",
|
||||
"#document",
|
||||
"DIV",
|
||||
"SPAN",
|
||||
"href",
|
||||
"title",
|
||||
"/page",
|
||||
];
|
||||
const nodeArrays: Record<string, unknown> = {
|
||||
nodeType: [9, 1, 1],
|
||||
nodeName: [1, 2, 3],
|
||||
nodeValue: [0, 0, 0],
|
||||
parentIndex: [-1, 0, 1],
|
||||
attributes: [[], [], []],
|
||||
};
|
||||
if (field === "nodeName") nodeArrays.nodeName = [1, "malformed", 3];
|
||||
if (field === "parentIndex")
|
||||
nodeArrays.parentIndex = [-1, "malformed", 1];
|
||||
if (field === "nodeValue") nodeArrays.nodeValue = [0, 0, "malformed"];
|
||||
if (field === "attributes")
|
||||
nodeArrays.attributes = [[], [4, "malformed", 5, 6], []];
|
||||
const completeness = new CdpCaptureCompleteness();
|
||||
const generator = captureDom(
|
||||
{
|
||||
strings,
|
||||
documents: [
|
||||
{
|
||||
documentURL: 0,
|
||||
baseURL: 0,
|
||||
nodes: nodeArrays,
|
||||
},
|
||||
],
|
||||
},
|
||||
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.total).toBe(3);
|
||||
expect(captured.value.nodes.map((node) => node.node_name)).toEqual(
|
||||
field === "nodeValue" ? ["#document", "DIV"] : ["#document", "SPAN"],
|
||||
);
|
||||
expect(captured.value.nodes.map((node) => node.index)).toEqual(
|
||||
field === "nodeValue" ? [0, 1] : [0, 2],
|
||||
);
|
||||
expect(captured.value.nodes.map((node) => node.parent_index)).toEqual(
|
||||
field === "nodeValue" ? [-1, 0] : [-1, 1],
|
||||
);
|
||||
if (field === "attributes") {
|
||||
expect(
|
||||
captured.value.nodes.map((node) => node.attribute_names),
|
||||
).toEqual([[], []]);
|
||||
expect(captured.value.urls).toEqual([]);
|
||||
}
|
||||
expect(completeness.snapshot()).toMatchObject({
|
||||
unavailable_sections: expect.arrayContaining(["dom"]),
|
||||
excluded: expect.arrayContaining([
|
||||
{ section: "dom", reason: "invalid_protocol_value", count: 1 },
|
||||
]),
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("CDP DOM string and namespace validity", () => {
|
||||
it.each([
|
||||
{ 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)(
|
||||
"omits a node when its $field string index is invalid ($index)",
|
||||
({ field, index, malformedString }) => {
|
||||
const strings: unknown[] = [
|
||||
origin + "/app",
|
||||
"#document",
|
||||
"DIV",
|
||||
"SPAN",
|
||||
"href",
|
||||
"/good",
|
||||
];
|
||||
const nodeArrays: Record<string, unknown> = {
|
||||
nodeType: [9, 1, 1],
|
||||
nodeName: [1, 2, 3],
|
||||
nodeValue: [0, 0, 0],
|
||||
parentIndex: [-1, 0, 0],
|
||||
attributes: [[], [], []],
|
||||
};
|
||||
if (field === "nodeName") nodeArrays.nodeName = [1, index, 3];
|
||||
if (field === "nodeValue") nodeArrays.nodeValue = [0, index, 0];
|
||||
if (field === "attributeName")
|
||||
nodeArrays.attributes = [[], [index, 5], []];
|
||||
if (field === "attributeValue")
|
||||
nodeArrays.attributes = [[], [4, index], []];
|
||||
if (malformedString === true) strings[index === 2 ? 2 : 5] = 42;
|
||||
const completeness = new CdpCaptureCompleteness();
|
||||
const generator = captureDom(
|
||||
{
|
||||
strings,
|
||||
documents: [
|
||||
{
|
||||
documentURL: 0,
|
||||
baseURL: 0,
|
||||
nodes: nodeArrays,
|
||||
},
|
||||
],
|
||||
},
|
||||
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.total).toBe(3);
|
||||
expect(captured.value.nodes.map((node) => node.index)).toEqual([0, 2]);
|
||||
expect(captured.value.nodes.map((node) => node.node_name)).toEqual([
|
||||
"#document",
|
||||
"SPAN",
|
||||
]);
|
||||
expect(completeness.snapshot().excluded).toContainEqual({
|
||||
section: "dom",
|
||||
reason: "invalid_protocol_value",
|
||||
count: 1,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("does not infer SVG ancestry through an element with malformed namespace data", () => {
|
||||
const strings: unknown[] = [
|
||||
origin + "/app",
|
||||
"#document",
|
||||
"svg",
|
||||
"a",
|
||||
"xmlns:xlink",
|
||||
"http://www.w3.org/1999/xlink",
|
||||
"xlink:href",
|
||||
"/svg-link",
|
||||
];
|
||||
const completeness = new CdpCaptureCompleteness();
|
||||
const generator = captureDom(
|
||||
{
|
||||
strings,
|
||||
documents: [
|
||||
{
|
||||
documentURL: 0,
|
||||
baseURL: 0,
|
||||
nodes: {
|
||||
nodeType: [9, 1, 1],
|
||||
nodeName: [1, 2, 3],
|
||||
nodeValue: [0, 0, 0],
|
||||
parentIndex: [-1, 0, 1],
|
||||
attributes: [[], [4, 99], [6, 7]],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
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) => node.index)).toEqual([0, 2]);
|
||||
expect(captured.value.nodes[1]?.attribute_names).toEqual(["xlink:href"]);
|
||||
expect(captured.value.urls).toEqual([]);
|
||||
expect(
|
||||
completeness
|
||||
.snapshot()
|
||||
.excluded.some(
|
||||
({ section, reason }) =>
|
||||
section === "metadata" && reason === "invalid_protocol_value",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CDP frame capture", () => {
|
||||
it("walks deeply nested frame trees and returns every frame and resource", () => {
|
||||
let frameTree: Record<string, unknown> = {
|
||||
frame: { id: "frame-0", url: `${origin}/0` },
|
||||
@@ -30,7 +275,9 @@ describe("CDP document capture", () => {
|
||||
expect(frames.items).toHaveLength(2_000);
|
||||
expect(resources.items).toHaveLength(2_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CDP accessibility capture", () => {
|
||||
it("retains full approved accessibility text", () => {
|
||||
const capture = captureAccessibility(
|
||||
[
|
||||
|
||||
@@ -174,7 +174,7 @@ export function* captureDom(
|
||||
> {
|
||||
const root = requiredRecord(result);
|
||||
const strings = Array.isArray(root.strings)
|
||||
? root.strings.map((value) => cdpStringValue(value) ?? "")
|
||||
? root.strings.map(cdpStringValue)
|
||||
: [];
|
||||
const nodes: WebPageInspection["dom"]["nodes"] = [];
|
||||
const urls: WebPageInspection["metadata"]["dom_urls"] = [];
|
||||
@@ -184,44 +184,100 @@ export function* captureDom(
|
||||
let unresolvedSvgNodes = 0;
|
||||
for (const document of recordsValue(root.documents)) {
|
||||
const documentUrl = indexedString(strings, document.documentURL);
|
||||
if (documentUrl === undefined) {
|
||||
completeness?.exclude("dom", "invalid_protocol_value");
|
||||
continue;
|
||||
}
|
||||
if (allowedSanitizedUrl(documentUrl, allowedOrigins) === undefined) {
|
||||
completeness?.exclude("dom", exclusionReasonForUrl(documentUrl));
|
||||
continue;
|
||||
}
|
||||
const baseUrl = indexedString(strings, document.baseURL) || documentUrl;
|
||||
const baseUrlValue =
|
||||
document.baseURL === undefined
|
||||
? documentUrl
|
||||
: indexedString(strings, document.baseURL);
|
||||
if (baseUrlValue === undefined) {
|
||||
completeness?.exclude("dom", "invalid_protocol_value");
|
||||
continue;
|
||||
}
|
||||
const baseUrl = baseUrlValue || documentUrl;
|
||||
const documentNodes = recordValue(document.nodes);
|
||||
if (documentNodes === undefined) continue;
|
||||
const nodeTypes = numberArray(documentNodes.nodeType);
|
||||
const nodeNames = numberArray(documentNodes.nodeName);
|
||||
const nodeValues = numberArray(documentNodes.nodeValue);
|
||||
const parents = numberArray(documentNodes.parentIndex);
|
||||
const attributes = Array.isArray(documentNodes.attributes)
|
||||
const rawNodeTypes = numberArray(documentNodes.nodeType);
|
||||
const rawNodeNames = numberArray(documentNodes.nodeName);
|
||||
const rawNodeValues = numberArray(documentNodes.nodeValue);
|
||||
const rawParents = numberArray(documentNodes.parentIndex);
|
||||
const rawAttributes = Array.isArray(documentNodes.attributes)
|
||||
? documentNodes.attributes
|
||||
: [];
|
||||
: undefined;
|
||||
if (
|
||||
rawNodeTypes === undefined ||
|
||||
rawNodeNames === undefined ||
|
||||
rawNodeValues === undefined ||
|
||||
rawParents === undefined ||
|
||||
rawAttributes === undefined ||
|
||||
rawNodeNames.length !== rawNodeTypes.length ||
|
||||
rawNodeValues.length !== rawNodeTypes.length ||
|
||||
rawParents.length !== rawNodeTypes.length
|
||||
) {
|
||||
completeness?.exclude("dom", "invalid_protocol_value");
|
||||
continue;
|
||||
}
|
||||
if (rawAttributes.length > rawNodeTypes.length)
|
||||
completeness?.exclude(
|
||||
"dom",
|
||||
"invalid_protocol_value",
|
||||
rawAttributes.length - rawNodeTypes.length,
|
||||
);
|
||||
const backendNodeIds = Array.isArray(documentNodes.backendNodeId)
|
||||
? documentNodes.backendNodeId
|
||||
: [];
|
||||
const baseIndex = nodes.length;
|
||||
const baseIndex = total;
|
||||
total += rawNodeTypes.length;
|
||||
const ancestry = createDocumentSvgAncestry({
|
||||
nodeTypes,
|
||||
nodeNames,
|
||||
parents,
|
||||
nodeTypes: rawNodeTypes,
|
||||
nodeNames: rawNodeNames,
|
||||
parents: rawParents,
|
||||
strings,
|
||||
attributes,
|
||||
attributes: rawAttributes,
|
||||
});
|
||||
total += nodeTypes.length;
|
||||
const pendingMetadata: PendingDomMetadata[] = [];
|
||||
for (let index = 0; index < nodeTypes.length; index += 1) {
|
||||
const attributeIndexes = numberArray(attributes[index]);
|
||||
const parent = Math.trunc(parents[index] ?? -1);
|
||||
const nodeIndex = nodes.length;
|
||||
const nodeName = indexedString(strings, nodeNames[index]);
|
||||
const attributeNames = uniqueAttributeNames(attributeIndexes, strings);
|
||||
for (let index = 0; index < rawNodeTypes.length; index += 1) {
|
||||
const nodeType = rawNodeTypes[index];
|
||||
const nodeNameIndex = rawNodeNames[index];
|
||||
const nodeValueIndex = rawNodeValues[index];
|
||||
const parent = rawParents[index];
|
||||
const attributeIndexes = numberArray(rawAttributes[index]);
|
||||
const nodeName = indexedString(strings, nodeNameIndex);
|
||||
const nodeValue = indexedString(strings, nodeValueIndex);
|
||||
if (
|
||||
nodeType === undefined ||
|
||||
nodeName === undefined ||
|
||||
nodeValue === undefined ||
|
||||
parent === undefined ||
|
||||
parent < -1 ||
|
||||
parent === index ||
|
||||
parent >= rawNodeTypes.length ||
|
||||
attributeIndexes === undefined ||
|
||||
attributeIndexes.length % 2 !== 0 ||
|
||||
attributeIndexes.some(
|
||||
(value) =>
|
||||
value === undefined || indexedString(strings, value) === undefined,
|
||||
)
|
||||
) {
|
||||
completeness?.exclude("dom", "invalid_protocol_value");
|
||||
continue;
|
||||
}
|
||||
const attributes = attributeIndexes as readonly number[];
|
||||
const nodeIndex = baseIndex + index;
|
||||
const attributeNames = uniqueAttributeNames(attributes, strings);
|
||||
const svgLink = domSvgLink(ancestry, index);
|
||||
if (svgLink.xmlDocument === undefined)
|
||||
completeness?.exclude("metadata", "invalid_protocol_value");
|
||||
let svgQuery: DomSvgNodeQuery | null | undefined;
|
||||
if (
|
||||
!svgLink.xmlDocument &&
|
||||
nodeTypes[index] === 1 &&
|
||||
svgLink.xmlDocument === false &&
|
||||
nodeType === 1 &&
|
||||
attributeNames.includes("xlink:href") &&
|
||||
!attributeNames.includes("href")
|
||||
) {
|
||||
@@ -236,16 +292,16 @@ export function* captureDom(
|
||||
nodes.push({
|
||||
index: nodeIndex,
|
||||
parent_index: parent < 0 ? -1 : baseIndex + parent,
|
||||
node_type: Math.max(0, Math.trunc(nodeTypes[index] ?? 0)),
|
||||
node_type: nodeType,
|
||||
node_name: nodeName,
|
||||
node_value_length: indexedString(strings, nodeValues[index]).length,
|
||||
node_value_length: nodeValue.length,
|
||||
attribute_names: attributeNames,
|
||||
});
|
||||
pendingMetadata.push({
|
||||
svgQuery,
|
||||
options: {
|
||||
strings,
|
||||
attributes: attributeIndexes,
|
||||
attributes,
|
||||
// The BASE element itself resolves against the document fallback URL.
|
||||
baseUrl: nodeName.toLowerCase() === "base" ? documentUrl : baseUrl,
|
||||
documentUrl,
|
||||
@@ -257,7 +313,7 @@ export function* captureDom(
|
||||
});
|
||||
if (
|
||||
pendingMetadata.length === DOM_METADATA_BATCH_SIZE ||
|
||||
index === nodeTypes.length - 1
|
||||
index === rawNodeTypes.length - 1
|
||||
) {
|
||||
const metadata = yield* captureDomMetadata(
|
||||
pendingMetadata,
|
||||
@@ -270,6 +326,14 @@ export function* captureDom(
|
||||
pendingMetadata.length = 0;
|
||||
}
|
||||
}
|
||||
if (pendingMetadata.length > 0) {
|
||||
const metadata = yield* captureDomMetadata(pendingMetadata, completeness);
|
||||
urls.push(...metadata.urls);
|
||||
agentHints.push(...metadata.agentHints);
|
||||
excludedUrls += metadata.excludedUrls;
|
||||
unresolvedSvgNodes += metadata.unresolvedSvgNodes;
|
||||
pendingMetadata.length = 0;
|
||||
}
|
||||
}
|
||||
return {
|
||||
total,
|
||||
@@ -450,19 +514,25 @@ const accessibilityStates = (
|
||||
const axText = (value: unknown): string | null =>
|
||||
cdpStringValue(recordValue(value)?.value) ?? null;
|
||||
|
||||
const indexedString = (strings: readonly string[], index: unknown): string => {
|
||||
const indexedString = (
|
||||
strings: readonly (string | undefined)[],
|
||||
index: unknown,
|
||||
): string | undefined => {
|
||||
const integer = numberValue(index);
|
||||
return integer === undefined ? "" : (strings[Math.trunc(integer)] ?? "");
|
||||
if (integer === undefined || !Number.isSafeInteger(integer) || integer < 0)
|
||||
return undefined;
|
||||
return strings[integer];
|
||||
};
|
||||
|
||||
const uniqueAttributeNames = (
|
||||
attributeIndexes: readonly number[],
|
||||
strings: readonly string[],
|
||||
strings: readonly (string | undefined)[],
|
||||
): string[] => {
|
||||
const names: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (let index = 0; index < attributeIndexes.length; index += 2) {
|
||||
const name = indexedString(strings, attributeIndexes[index]);
|
||||
if (name === undefined) continue;
|
||||
if (seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
names.push(name);
|
||||
@@ -470,16 +540,20 @@ const uniqueAttributeNames = (
|
||||
return names;
|
||||
};
|
||||
|
||||
const numberArray = (value: unknown): readonly number[] =>
|
||||
Array.isArray(value)
|
||||
? value.flatMap((item) => {
|
||||
const number = numberValue(item);
|
||||
return number === undefined ? [] : [number];
|
||||
})
|
||||
: [];
|
||||
const numberArray = (
|
||||
value: unknown,
|
||||
): readonly (number | undefined)[] | undefined => {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
return value.map((item) => {
|
||||
const number = numberValue(item);
|
||||
return number !== undefined && Number.isSafeInteger(number)
|
||||
? number
|
||||
: undefined;
|
||||
});
|
||||
};
|
||||
|
||||
interface DomMetadataOptions {
|
||||
readonly strings: readonly string[];
|
||||
readonly strings: readonly (string | undefined)[];
|
||||
readonly attributes: readonly number[];
|
||||
readonly baseUrl: string;
|
||||
readonly documentUrl: string;
|
||||
@@ -525,6 +599,7 @@ const domMetadata = (
|
||||
// Chrome already folds HTML names; SVG/XML names remain case-sensitive.
|
||||
const name = indexedString(strings, attributes[index]);
|
||||
const value = indexedString(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);
|
||||
}
|
||||
|
||||
+102
-42
@@ -4,10 +4,10 @@ const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
|
||||
const XLINK_NAMESPACE = "http://www.w3.org/1999/xlink";
|
||||
|
||||
interface DomSnapshotNodes {
|
||||
readonly nodeTypes: readonly number[];
|
||||
readonly nodeNames: readonly number[];
|
||||
readonly parents: readonly number[];
|
||||
readonly strings: readonly string[];
|
||||
readonly nodeTypes: readonly (number | undefined)[];
|
||||
readonly nodeNames: readonly (number | undefined)[];
|
||||
readonly parents: readonly (number | undefined)[];
|
||||
readonly strings: readonly (string | undefined)[];
|
||||
readonly attributes: readonly unknown[];
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ interface XmlSvgLink {
|
||||
}
|
||||
|
||||
interface DocumentSvgAncestry {
|
||||
readonly xml: boolean;
|
||||
readonly xml: boolean | undefined;
|
||||
readonly xmlLinks: Map<number, XmlSvgLink>;
|
||||
}
|
||||
|
||||
@@ -48,50 +48,59 @@ export const capturedSvgNodeKind = (
|
||||
|
||||
interface DomSvgLink {
|
||||
readonly svgElement: boolean;
|
||||
readonly xmlDocument: boolean;
|
||||
readonly xmlDocument: boolean | undefined;
|
||||
readonly xlinkHref: string | undefined;
|
||||
}
|
||||
|
||||
const UNRESOLVED: XmlSvgLink = {
|
||||
svgElement: false,
|
||||
xlinkHref: undefined,
|
||||
};
|
||||
|
||||
const stringAt = (strings: readonly string[], index: unknown): string => {
|
||||
const stringAt = (
|
||||
strings: readonly (string | undefined)[],
|
||||
index: unknown,
|
||||
): string | undefined => {
|
||||
const integer = numberValue(index);
|
||||
return integer === undefined ? "" : (strings[Math.trunc(integer)] ?? "");
|
||||
if (integer === undefined || !Number.isSafeInteger(integer) || integer < 0)
|
||||
return undefined;
|
||||
return strings[integer];
|
||||
};
|
||||
|
||||
const numberArray = (value: unknown): readonly number[] =>
|
||||
const numberArray = (value: unknown): readonly (number | undefined)[] =>
|
||||
Array.isArray(value)
|
||||
? value.flatMap((item) => {
|
||||
? value.map((item) => {
|
||||
const number = numberValue(item);
|
||||
return number === undefined ? [] : [number];
|
||||
return number !== undefined && Number.isSafeInteger(number)
|
||||
? number
|
||||
: undefined;
|
||||
})
|
||||
: [];
|
||||
|
||||
// DOMSnapshot has no namespace URI. HTML documents use uppercase `HTML` and
|
||||
// the parser's local names. XML documents preserve prefixed names and xmlns
|
||||
// declarations, including those on standalone SVG roots.
|
||||
const xmlDocument = (nodes: DomSnapshotNodes): boolean => {
|
||||
const documentIndex = nodes.nodeTypes.findIndex(
|
||||
(nodeType) => Math.trunc(nodeType) === 9,
|
||||
);
|
||||
const xmlDocument = (nodes: DomSnapshotNodes): boolean | undefined => {
|
||||
const documentIndex = nodes.nodeTypes.findIndex((nodeType) => nodeType === 9);
|
||||
if (documentIndex < 0) return false;
|
||||
if (
|
||||
!validSvgSourceNode(nodes, documentIndex) ||
|
||||
nodes.parents[documentIndex] !== -1
|
||||
)
|
||||
return undefined;
|
||||
for (let index = 0; index < nodes.nodeTypes.length; index += 1) {
|
||||
if (Math.trunc(nodes.nodeTypes[index] ?? 0) !== 1) continue;
|
||||
if (Math.trunc(nodes.parents[index] ?? -1) !== documentIndex) continue;
|
||||
if (nodes.nodeTypes[index] !== 1) continue;
|
||||
if (nodes.parents[index] !== documentIndex) continue;
|
||||
const name = stringAt(nodes.strings, nodes.nodeNames[index]);
|
||||
if (name === undefined || !validSvgSourceNode(nodes, index))
|
||||
return undefined;
|
||||
if (name === "HTML") return false;
|
||||
if (name === "html" || name.includes(":")) return true;
|
||||
// Explicit namespaces also identify standalone SVG and other XML roots.
|
||||
// Namespace-free synthetic snapshots retain the legacy HTML behavior.
|
||||
const attributes = numberArray(nodes.attributes[index]);
|
||||
return attributes.some(
|
||||
(value, offset) =>
|
||||
offset % 2 === 0 &&
|
||||
xmlnsPrefix(stringAt(nodes.strings, value)) !== undefined,
|
||||
);
|
||||
for (let offset = 0; offset + 1 < attributes.length; offset += 2) {
|
||||
const value = attributes[offset];
|
||||
const attributeName = stringAt(nodes.strings, value);
|
||||
if (attributeName === undefined) return undefined;
|
||||
if (xmlnsPrefix(attributeName) !== undefined) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
@@ -102,7 +111,7 @@ export const createDocumentSvgAncestry = (
|
||||
const xml = xmlDocument(nodes);
|
||||
return {
|
||||
xml,
|
||||
xmlLinks: xml ? resolveXmlElements(nodes) : new Map(),
|
||||
xmlLinks: xml === true ? resolveXmlElements(nodes) : new Map(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -110,14 +119,28 @@ export const domSvgLink = (
|
||||
ancestry: DocumentSvgAncestry,
|
||||
index: number,
|
||||
): DomSvgLink => {
|
||||
if (!ancestry.xml) {
|
||||
if (ancestry.xml === false) {
|
||||
return {
|
||||
svgElement: false,
|
||||
xmlDocument: false,
|
||||
xlinkHref: undefined,
|
||||
};
|
||||
}
|
||||
const resolved = ancestry.xmlLinks.get(index) ?? UNRESOLVED;
|
||||
if (ancestry.xml === undefined) {
|
||||
return {
|
||||
svgElement: false,
|
||||
xmlDocument: undefined,
|
||||
xlinkHref: undefined,
|
||||
};
|
||||
}
|
||||
const resolved = ancestry.xmlLinks.get(index);
|
||||
if (resolved === undefined) {
|
||||
return {
|
||||
svgElement: false,
|
||||
xmlDocument: undefined,
|
||||
xlinkHref: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
svgElement: resolved.svgElement,
|
||||
xmlDocument: true,
|
||||
@@ -143,7 +166,9 @@ const resolveXmlElements = (
|
||||
const children = new Map<number, number[]>();
|
||||
const pending: XmlWalkStep[] = [];
|
||||
for (let node = 0; node < nodes.nodeNames.length; node += 1) {
|
||||
const parent = Math.trunc(nodes.parents[node] ?? -1);
|
||||
const parent = nodes.parents[node];
|
||||
if (parent === undefined || parent < -1 || parent >= nodes.nodeNames.length)
|
||||
continue;
|
||||
if (parent < 0) pending.push({ node });
|
||||
else if (parent < nodes.nodeNames.length && parent !== node) {
|
||||
const siblings = children.get(parent);
|
||||
@@ -164,10 +189,11 @@ const resolveXmlElements = (
|
||||
continue;
|
||||
}
|
||||
const { node } = step;
|
||||
if (!validSvgSourceNode(nodes, node)) continue;
|
||||
const attributes = numberArray(nodes.attributes[node]);
|
||||
const restore = applyDeclarations(bindings, attributes, nodes.strings);
|
||||
const svgElement =
|
||||
Math.trunc(nodes.nodeTypes[node] ?? 0) === 1 &&
|
||||
nodes.nodeTypes[node] === 1 &&
|
||||
elementNamespace(
|
||||
stringAt(nodes.strings, nodes.nodeNames[node]),
|
||||
bindings,
|
||||
@@ -188,24 +214,30 @@ const resolveXmlElements = (
|
||||
|
||||
const applyDeclarations = (
|
||||
bindings: Map<string, string>,
|
||||
attributes: readonly number[],
|
||||
strings: readonly string[],
|
||||
attributes: readonly (number | undefined)[],
|
||||
strings: readonly (string | undefined)[],
|
||||
): readonly NamespaceRestore[] => {
|
||||
const restore: NamespaceRestore[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (let index = 0; index + 1 < attributes.length; index += 2) {
|
||||
const prefix = xmlnsPrefix(stringAt(strings, attributes[index]));
|
||||
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);
|
||||
if (name === undefined || value === undefined) continue;
|
||||
const prefix = xmlnsPrefix(name);
|
||||
if (prefix === undefined || seen.has(prefix)) continue;
|
||||
seen.add(prefix);
|
||||
restore.push({ prefix, previous: bindings.get(prefix) });
|
||||
const value = stringAt(strings, attributes[index + 1]);
|
||||
if (value.length === 0) bindings.delete(prefix);
|
||||
else bindings.set(prefix, value);
|
||||
}
|
||||
return restore;
|
||||
};
|
||||
|
||||
const xmlnsPrefix = (name: string): string | undefined => {
|
||||
const xmlnsPrefix = (name: string | undefined): string | undefined => {
|
||||
if (name === undefined) return undefined;
|
||||
if (name === "xmlns") return "";
|
||||
if (!name.startsWith("xmlns:")) return undefined;
|
||||
const prefix = name.slice("xmlns:".length);
|
||||
@@ -215,9 +247,10 @@ const xmlnsPrefix = (name: string): string | undefined => {
|
||||
};
|
||||
|
||||
const elementNamespace = (
|
||||
nodeName: string,
|
||||
nodeName: string | undefined,
|
||||
bindings: ReadonlyMap<string, string>,
|
||||
): string => {
|
||||
if (nodeName === undefined) return "";
|
||||
const colon = nodeName.indexOf(":");
|
||||
if (colon < 0) return bindings.get("") ?? "";
|
||||
const prefix = nodeName.slice(0, colon);
|
||||
@@ -235,13 +268,17 @@ const elementNamespace = (
|
||||
// First prefixed href bound to the XLink namespace. Duplicate names keep the
|
||||
// earlier value. Plain href is applied by the caller and wins even when empty.
|
||||
const xlinkHrefValue = (
|
||||
attributes: readonly number[],
|
||||
strings: readonly string[],
|
||||
attributes: readonly (number | undefined)[],
|
||||
strings: readonly (string | undefined)[],
|
||||
bindings: ReadonlyMap<string, string>,
|
||||
): string | undefined => {
|
||||
const seen = new Set<string>();
|
||||
for (let index = 0; index + 1 < attributes.length; index += 2) {
|
||||
const name = stringAt(strings, attributes[index]);
|
||||
const nameIndex = attributes[index];
|
||||
const valueIndex = attributes[index + 1];
|
||||
if (nameIndex === undefined || valueIndex === undefined) continue;
|
||||
const name = stringAt(strings, nameIndex);
|
||||
if (name === undefined) continue;
|
||||
if (seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
const colon = name.indexOf(":");
|
||||
@@ -249,7 +286,30 @@ 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, attributes[index + 1]);
|
||||
return stringAt(strings, valueIndex);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const validSvgSourceNode = (
|
||||
nodes: DomSnapshotNodes,
|
||||
index: number,
|
||||
): boolean => {
|
||||
if (
|
||||
nodes.nodeTypes[index] === undefined ||
|
||||
stringAt(nodes.strings, nodes.nodeNames[index]) === undefined
|
||||
)
|
||||
return false;
|
||||
const rawAttributes = nodes.attributes[index];
|
||||
if (!Array.isArray(rawAttributes) || rawAttributes.length % 2 !== 0)
|
||||
return false;
|
||||
const attributes = numberArray(rawAttributes);
|
||||
return (
|
||||
attributes.length === rawAttributes.length &&
|
||||
attributes.every(
|
||||
(attribute) =>
|
||||
attribute !== undefined &&
|
||||
stringAt(nodes.strings, attribute) !== undefined,
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -82,3 +82,61 @@ it.each([false, true])(
|
||||
expect(value.fingerprints_complete).toBe(!mixed);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps an omitted IndexedDB remote value unknown", async () => {
|
||||
const replies: Record<string, unknown> = {
|
||||
"Storage.getUsageAndQuota": { usage: 0, quota: 1000 },
|
||||
"DOMStorage.getDOMStorageItems": { entries: [] },
|
||||
"IndexedDB.requestDatabaseNames": { databaseNames: ["db"] },
|
||||
"CacheStorage.requestCacheNames": { caches: [] },
|
||||
"Network.getCookies": { cookies: [] },
|
||||
"IndexedDB.requestDatabase": {
|
||||
databaseWithObjectStores: {
|
||||
name: "db",
|
||||
objectStores: [
|
||||
{
|
||||
name: "records",
|
||||
keyPath: { type: "string", string: "id" },
|
||||
autoIncrement: false,
|
||||
indexes: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"IndexedDB.requestData": {
|
||||
objectStoreDataEntries: [
|
||||
{
|
||||
key: { type: "number", value: 1 },
|
||||
primaryKey: { type: "number", value: 1 },
|
||||
},
|
||||
],
|
||||
hasMore: false,
|
||||
},
|
||||
};
|
||||
const connection = {
|
||||
async send(method: string): Promise<unknown> {
|
||||
if (!(method in replies)) throw new Error(method);
|
||||
return replies[method];
|
||||
},
|
||||
} as unknown as CdpConnection;
|
||||
const { value } = await captureStorage(
|
||||
{
|
||||
connection,
|
||||
sessionId: undefined,
|
||||
input: inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: "http://127.0.0.1:9222",
|
||||
allowed_origins: ["https://example.test"],
|
||||
target_id: "page-1",
|
||||
include_storage_keys: true,
|
||||
include_storage_fingerprints: true,
|
||||
}),
|
||||
},
|
||||
"https://example.test/app",
|
||||
[],
|
||||
);
|
||||
const record = value.content_fingerprints.find(
|
||||
(item) => item.scope === "indexed_db_record",
|
||||
);
|
||||
expect(record).toMatchObject({ value_sha256: null, complete: false });
|
||||
expect(value.fingerprints_complete).toBe(false);
|
||||
});
|
||||
@@ -301,7 +301,10 @@ const addIndexedDbRecords = async (
|
||||
for (const entry of entries) {
|
||||
const key = stableValue(entry.key);
|
||||
const primaryKey = stableValue(entry.primaryKey);
|
||||
const value = stableValue(entry.value);
|
||||
// A missing remote object is an omission, not the JavaScript value
|
||||
// "undefined". Keep the unavailable digest null and coverage partial.
|
||||
const value =
|
||||
entry.value === undefined ? undefined : stableValue(entry.value);
|
||||
addFingerprint(run.state, {
|
||||
scope: "indexed_db_record",
|
||||
identity: {
|
||||
|
||||
@@ -243,9 +243,9 @@ export class CdpConnection {
|
||||
const hasResult = "result" in message;
|
||||
const hasError = "error" in message;
|
||||
if (
|
||||
("sessionId" in message &&
|
||||
(typeof message.sessionId !== "string" ||
|
||||
message.sessionId !== pending.sessionId)) ||
|
||||
(pending.sessionId === undefined
|
||||
? "sessionId" in message
|
||||
: message.sessionId !== pending.sessionId) ||
|
||||
hasResult === hasError
|
||||
) {
|
||||
this.#failPending("protocol_error");
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js";
|
||||
import { captureFrames } from "./CdpElectronInspection.js";
|
||||
|
||||
const temporary: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporary
|
||||
.splice(0)
|
||||
.map((path) => rm(path, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe("Electron frame capture", () => {
|
||||
it.each([7, undefined])(
|
||||
"classifies a malformed child frame ID as unavailable: %s",
|
||||
async (childId) => {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-electron-frame-"));
|
||||
temporary.push(root);
|
||||
const main = join(root, "index.html");
|
||||
const child = join(root, "child.html");
|
||||
await Promise.all([writeFile(main, "main"), writeFile(child, "child")]);
|
||||
const mainUrl = pathToFileURL(main).href;
|
||||
const childUrl = pathToFileURL(child).href;
|
||||
const completeness = new CdpCaptureCompleteness();
|
||||
|
||||
const frames = await captureFrames(
|
||||
{
|
||||
frameTree: {
|
||||
frame: { id: "frame-main", url: mainUrl },
|
||||
childFrames: [
|
||||
{
|
||||
frame: {
|
||||
...(childId === undefined ? {} : { id: childId }),
|
||||
url: childUrl,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
completeness,
|
||||
);
|
||||
|
||||
expect(frames.map((frame) => frame.frame_id)).toEqual(["frame-main"]);
|
||||
expect(completeness.snapshot()).toMatchObject({
|
||||
unavailable_sections: ["frames"],
|
||||
excluded: [
|
||||
{ section: "frames", reason: "invalid_protocol_value", count: 1 },
|
||||
],
|
||||
});
|
||||
expect(completeness.snapshot().policy_filtered_sections).not.toContain(
|
||||
"frames",
|
||||
);
|
||||
expect(completeness.snapshot().conditions).toContain("attach_limited");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -232,7 +232,7 @@ const navigatedFrameId = (event: CdpEvent): string | undefined => {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const captureFrames = async (
|
||||
export const captureFrames = async (
|
||||
result: unknown,
|
||||
completeness: CdpCaptureCompleteness,
|
||||
): Promise<ElectronPageInspection["frames"]> => {
|
||||
@@ -245,8 +245,12 @@ const captureFrames = async (
|
||||
)) {
|
||||
const frame = recordValue(tree.frame);
|
||||
const frameId = cdpStringValue(frame?.id);
|
||||
const path = await authorizedElectronFile(cdpStringValue(frame?.url) ?? "");
|
||||
if (frameId === undefined || path === undefined) {
|
||||
if (frame === undefined || frameId === undefined) {
|
||||
completeness.exclude("frames", "invalid_protocol_value");
|
||||
continue;
|
||||
}
|
||||
const path = await authorizedElectronFile(cdpStringValue(frame.url) ?? "");
|
||||
if (path === undefined) {
|
||||
completeness.exclude("frames", "out_of_target_scope");
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ export const captureCdpScreenshot = async (
|
||||
const bytes =
|
||||
encoded === undefined ? undefined : decodeCanonicalBase64(encoded);
|
||||
if (bytes === undefined)
|
||||
throw new BrowserObservationError("inspect_web_page", "payload_limit");
|
||||
throw new BrowserObservationError("inspect_web_page", "protocol_error");
|
||||
const dimensions = readPngHeaderDimensions(bytes);
|
||||
if (dimensions === undefined)
|
||||
throw new BrowserObservationError("inspect_web_page", "protocol_error");
|
||||
|
||||
@@ -40,12 +40,15 @@ it("projects hook retention and dropped event families through the result schema
|
||||
estimated_retained_bytes: 1_024,
|
||||
event_serialized_byte_upper_bound: 512,
|
||||
retained: 1,
|
||||
dropped: 1,
|
||||
dropped: 2,
|
||||
dropped_ipc: 0,
|
||||
dropped_runtime: 1,
|
||||
dropped_event_families: [{ family: "renderer-ipc", count: 1 }],
|
||||
dropped_runtime: 2,
|
||||
dropped_event_families: [
|
||||
{ family: "renderer-ipc", count: 1 },
|
||||
{ family: "preload-configuration", count: 1 },
|
||||
],
|
||||
dropped_event_roles: [{ role: "renderer", count: 1 }],
|
||||
observed: 2,
|
||||
observed: 3,
|
||||
observed_ipc: 1,
|
||||
observed_runtime: 1,
|
||||
hook_error: false,
|
||||
@@ -58,18 +61,29 @@ it("projects hook retention and dropped event families through the result schema
|
||||
estimated_retained_bytes: 1_024,
|
||||
event_serialized_byte_upper_bound: 512,
|
||||
retained: 1,
|
||||
dropped: 1,
|
||||
dropped_event_families: [{ family: "renderer-ipc", count: 1 }],
|
||||
dropped: 2,
|
||||
dropped_event_families: [
|
||||
{ family: "renderer-ipc", count: 1 },
|
||||
{ family: "preload-configuration", count: 1 },
|
||||
],
|
||||
dropped_event_roles: [{ role: "renderer", count: 1 }],
|
||||
});
|
||||
expect(result.ipc).toMatchObject({ observed: 1, dropped: 0 });
|
||||
expect(result.timeline).toMatchObject({ observed: 2, dropped: 1 });
|
||||
expect(result.timeline).toMatchObject({ observed: 3, dropped: 2 });
|
||||
expect(result.coverage.observed_event_families).toContain("renderer-ipc");
|
||||
expect(result.coverage.unavailable_event_families).not.toContain(
|
||||
"renderer-ipc",
|
||||
);
|
||||
expect(result.coverage.observed_roles).toContain("renderer");
|
||||
expect(result.coverage.unavailable_event_families).toContain("preload");
|
||||
expect(result.coverage.observed_event_families).toContain(
|
||||
"preload-configuration",
|
||||
);
|
||||
expect(result.coverage.unavailable_event_families).not.toContain(
|
||||
"preload-configuration",
|
||||
);
|
||||
expect(result.limitations).not.toContain(
|
||||
"Preload configuration was not exposed by the observed Electron window metadata. Preload paths and execution remain unavailable.",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps renderer coverage unavailable when only the main process was observed", () => {
|
||||
|
||||
@@ -113,6 +113,18 @@ const createCoverage = (hookSnapshot: ElectronHookSnapshot) => {
|
||||
} as const;
|
||||
};
|
||||
|
||||
const hasObservedPreloadConfiguration = (
|
||||
hookSnapshot: ElectronHookSnapshot,
|
||||
): boolean =>
|
||||
hookSnapshot.events.some(
|
||||
({ kind, process_type }) =>
|
||||
kind === "preload" &&
|
||||
(process_type === "main" || process_type === "browser"),
|
||||
) ||
|
||||
hookSnapshot.dropped_event_families.some(
|
||||
({ family, count }) => family === "preload-configuration" && count > 0,
|
||||
);
|
||||
|
||||
/** Project one provider hook snapshot into the canonical public result. */
|
||||
export const projectElectronActiveCapture = (
|
||||
paths: ElectronCapturePaths,
|
||||
@@ -166,7 +178,7 @@ export const projectElectronActiveCapture = (
|
||||
"IPC direction and sender/receiver identifiers are observed only where Electron exposes them at the hooked boundary.",
|
||||
"The runtime timeline records lifecycle, navigation, shell, permission, popup, download, protocol, preload, native-addon, process, and IPC events; activity before hook installation is unavailable.",
|
||||
"The preload and renderer process contexts are not instrumented by the main-process -r hook; preload configuration and contextBridge API-shape events are main-boundary observations, not proof of renderer-side execution.",
|
||||
...(!hookSnapshot.events.some(({ kind }) => kind === "preload")
|
||||
...(!hasObservedPreloadConfiguration(hookSnapshot)
|
||||
? [
|
||||
"Preload configuration was not exposed by the observed Electron window metadata. Preload paths and execution remain unavailable.",
|
||||
]
|
||||
|
||||
@@ -73,6 +73,23 @@ const ownership = {
|
||||
expectedParentPid: 1,
|
||||
} satisfies OwnedProcessGroup;
|
||||
|
||||
const emptyHookSnapshot = () => ({
|
||||
events: [],
|
||||
retention_budget_bytes: 0,
|
||||
estimated_retained_bytes: 0,
|
||||
event_serialized_byte_upper_bound: 0,
|
||||
retained: 0,
|
||||
dropped: 0,
|
||||
dropped_ipc: 0,
|
||||
dropped_runtime: 0,
|
||||
dropped_event_families: [],
|
||||
dropped_event_roles: [],
|
||||
observed: 0,
|
||||
observed_ipc: 0,
|
||||
observed_runtime: 0,
|
||||
hook_error: false,
|
||||
});
|
||||
|
||||
it("does not signal a Windows Electron PID when lineage is unavailable", async () => {
|
||||
const terminateTree = vi.fn(async () => ({
|
||||
cleaned: true as const,
|
||||
@@ -138,27 +155,11 @@ it("retains the partial capture without taskkill when Windows lineage is unavail
|
||||
const terminateTree = vi
|
||||
.spyOn(processOwnership, "cleanupWindowsProcessTree")
|
||||
.mockResolvedValue({ cleaned: true, signaled: true });
|
||||
const hookSnapshot = {
|
||||
events: [],
|
||||
retention_budget_bytes: 0,
|
||||
estimated_retained_bytes: 0,
|
||||
event_serialized_byte_upper_bound: 0,
|
||||
retained: 0,
|
||||
dropped: 0,
|
||||
dropped_ipc: 0,
|
||||
dropped_runtime: 0,
|
||||
dropped_event_families: [],
|
||||
dropped_event_roles: [],
|
||||
observed: 0,
|
||||
observed_ipc: 0,
|
||||
observed_runtime: 0,
|
||||
hook_error: false,
|
||||
};
|
||||
vi.spyOn(electronActions, "readApplicationState").mockResolvedValue({
|
||||
windows: [],
|
||||
metrics: [],
|
||||
electronVersion: "44.0.0",
|
||||
hookSnapshot,
|
||||
hookSnapshot: emptyHookSnapshot(),
|
||||
});
|
||||
const application = {
|
||||
process: () => ({ pid: 424242 }),
|
||||
@@ -206,3 +207,49 @@ it("retains the partial capture without taskkill when Windows lineage is unavail
|
||||
Object.defineProperty(process, "platform", originalPlatform);
|
||||
}
|
||||
});
|
||||
|
||||
it("retains the initial snapshot when the final state read fails", async () => {
|
||||
const initialState = {
|
||||
windows: [],
|
||||
metrics: [],
|
||||
electronVersion: "44.0.0",
|
||||
hookSnapshot: emptyHookSnapshot(),
|
||||
};
|
||||
vi.spyOn(electronActions, "readApplicationState")
|
||||
.mockResolvedValueOnce(initialState)
|
||||
.mockRejectedValueOnce(new Error("final renderer state unavailable"));
|
||||
const application = {
|
||||
process: () => ({ pid: 424242 }),
|
||||
close: vi.fn(async () => undefined),
|
||||
windows: () => [],
|
||||
};
|
||||
const provider = new PlaywrightElectronActiveProvider(
|
||||
{},
|
||||
async () =>
|
||||
application as unknown as Awaited<
|
||||
ReturnType<typeof import("playwright-core")._electron.launch>
|
||||
>,
|
||||
);
|
||||
|
||||
const result = await provider.capture({
|
||||
...electronActiveObservationInputSchema.parse({
|
||||
executable_path: process.execPath,
|
||||
application_path: process.execPath,
|
||||
actions: [],
|
||||
}),
|
||||
application_root: dirname(process.execPath),
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected final state capture to fail");
|
||||
const partialObservation = result.error.partialObservation;
|
||||
expect(partialObservation).toMatchObject({
|
||||
kind: "electron-active-observation",
|
||||
capture: {
|
||||
application: { electron_version: "44.0.0" },
|
||||
limitations: expect.arrayContaining([
|
||||
expect.stringContaining("initial pre-action snapshot"),
|
||||
]),
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -145,11 +145,7 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
|
||||
)
|
||||
: ok(capture);
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
options.signal?.aborted &&
|
||||
paths !== undefined &&
|
||||
initialState !== undefined
|
||||
) {
|
||||
if (paths !== undefined && initialState !== undefined) {
|
||||
const retainedCapture = projectElectronActiveCapture(
|
||||
paths,
|
||||
actions,
|
||||
@@ -159,25 +155,33 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
|
||||
...retainedCapture,
|
||||
limitations: [
|
||||
...retainedCapture.limitations,
|
||||
`Final Electron state was unavailable after cancellation: ${cause instanceof Error ? cause.message : String(cause)}. Windows, metrics, IPC, and timeline are the snapshot collected before actions and may omit their effects.`,
|
||||
`Capture stopped after the initial pre-action snapshot: ${cause instanceof Error ? cause.message : String(cause)}. Final Windows, metrics, IPC, and timeline state is unknown; the retained snapshot may omit action effects.`,
|
||||
],
|
||||
};
|
||||
}
|
||||
outcome = err(
|
||||
options.signal?.aborted === true
|
||||
? new BrowserObservationError(OPERATION, "cancelled", {
|
||||
cause,
|
||||
...(capture === undefined
|
||||
? {}
|
||||
: {
|
||||
partialObservation: {
|
||||
kind: "electron-active-observation" as const,
|
||||
capture,
|
||||
},
|
||||
}),
|
||||
})
|
||||
: providerError(cause),
|
||||
);
|
||||
if (options.signal?.aborted === true)
|
||||
outcome = err(
|
||||
new BrowserObservationError(OPERATION, "cancelled", {
|
||||
cause,
|
||||
...(capture === undefined
|
||||
? {}
|
||||
: {
|
||||
partialObservation: {
|
||||
kind: "electron-active-observation" as const,
|
||||
capture,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
else {
|
||||
const error = providerError(cause);
|
||||
if (capture !== undefined)
|
||||
error.retainPartialObservation({
|
||||
kind: "electron-active-observation",
|
||||
capture,
|
||||
});
|
||||
outcome = err(error);
|
||||
}
|
||||
}
|
||||
if (application !== undefined) {
|
||||
if (ownership !== undefined) {
|
||||
|
||||
@@ -11,6 +11,10 @@ import { isUrlLikeModuleSpecifier } from "../domain/webBundleAnalyzerAst.js";
|
||||
import { analyzeParsedJavaScriptReferences } from "../domain/javascript/javascriptSemanticAnalysis.js";
|
||||
import { traverseJavaScriptAst } from "../domain/javascript/javascriptSemanticTraversal.js";
|
||||
import { parseJavaScriptSource } from "../domain/javascript/javascriptSourceParser.js";
|
||||
import {
|
||||
isTypeOnlyExportDeclaration,
|
||||
isTypeOnlyImportDeclaration,
|
||||
} from "../domain/javascript/javascriptModuleDeclaration.js";
|
||||
import { stringValue } from "../domain/javascript/javascriptStaticAnalysisHelpers.js";
|
||||
import type {
|
||||
AnalyzeWebBundleInput,
|
||||
@@ -1061,7 +1065,11 @@ const originalDependency = (
|
||||
t.isExportAllDeclaration(node) ||
|
||||
t.isExportNamedDeclaration(node)
|
||||
)
|
||||
return node.source === null || node.source === undefined
|
||||
return node.source === null ||
|
||||
node.source === undefined ||
|
||||
(t.isImportDeclaration(node)
|
||||
? isTypeOnlyImportDeclaration(node)
|
||||
: isTypeOnlyExportDeclaration(node))
|
||||
? null
|
||||
: { kind: "static_import", specifier: node.source.value };
|
||||
if (t.isImportExpression(node)) {
|
||||
|
||||
@@ -333,6 +333,13 @@ const networkDimension = (
|
||||
);
|
||||
const beforeWebSockets = webSocketGroups(before);
|
||||
const afterWebSockets = webSocketGroups(after);
|
||||
const inventoryComplete = [before, after].every((inspection) =>
|
||||
sectionsComplete(inspection, [
|
||||
"network_requests",
|
||||
"websocket_connections",
|
||||
"websocket_frames",
|
||||
]),
|
||||
);
|
||||
const [beforeWebSocketPayload, afterWebSocketPayload] = webSocketPayloadMaps(
|
||||
beforeWebSockets,
|
||||
afterWebSockets,
|
||||
@@ -349,17 +356,12 @@ const networkDimension = (
|
||||
...webSocketMetadataMap(afterWebSockets),
|
||||
...afterWebSocketPayload,
|
||||
]),
|
||||
[before, after].every((inspection) =>
|
||||
sectionsComplete(inspection, [
|
||||
"network_requests",
|
||||
"websocket_connections",
|
||||
"websocket_frames",
|
||||
]),
|
||||
) &&
|
||||
inventoryComplete &&
|
||||
shapesComplete &&
|
||||
webSocketPayloadBytesComplete &&
|
||||
webSocketShapesComplete,
|
||||
"Network capture or selected HTTP/WebSocket payload-shape coverage is attach-window limited, unavailable, or incomplete.",
|
||||
inventoryComplete,
|
||||
);
|
||||
return compared.status === "unchanged" && !responseFieldsComplete
|
||||
? {
|
||||
|
||||
@@ -28,6 +28,7 @@ const INVALID_REPLIES = [
|
||||
}),
|
||||
],
|
||||
["invalid result", (id: number) => ({ id, result: [] })],
|
||||
["missing session id", (id: number) => ({ id, result: {} })],
|
||||
["invalid session id", (id: number) => ({ id, result: {}, sessionId: 7 })],
|
||||
[
|
||||
"foreign session id",
|
||||
|
||||
@@ -254,6 +254,7 @@ describe("CDP SVG query concurrency", () => {
|
||||
browser.emitRawMessage(
|
||||
JSON.stringify({
|
||||
id: command.id,
|
||||
sessionId: command.sessionId,
|
||||
result: {
|
||||
node: {
|
||||
backendNodeId: id,
|
||||
@@ -295,6 +296,7 @@ describe("CDP SVG query concurrency", () => {
|
||||
browser.emitRawMessage(
|
||||
JSON.stringify({
|
||||
id: batch.at(-1)?.id,
|
||||
sessionId: batch.at(-1)?.sessionId,
|
||||
error: { code: -32_602, message: "Invalid parameters" },
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -258,7 +258,62 @@ describe("CdpElectronProvider packaged pages", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("CdpElectronProvider capture", () => {
|
||||
describe("CdpElectronProvider frame capture", () => {
|
||||
it.each([7, undefined])(
|
||||
"reports a malformed child frame ID as unavailable protocol evidence: %s",
|
||||
async (childId) => {
|
||||
const root = await electronFixture();
|
||||
const main = pathToFileURL(join(root, "index.html")).href;
|
||||
const child = pathToFileURL(join(root, "child.html")).href;
|
||||
const browser = await startFakeCdpBrowser({
|
||||
electronFileUrl: main,
|
||||
commandResult: (command) =>
|
||||
command.method === "Page.getFrameTree"
|
||||
? {
|
||||
frameTree: {
|
||||
frame: { id: "frame-main", url: main },
|
||||
childFrames: [
|
||||
{
|
||||
frame: {
|
||||
...(childId === undefined ? {} : { id: childId }),
|
||||
url: child,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
browsers.push(browser);
|
||||
const result = await new CdpElectronProvider().inspectPage(
|
||||
inspectElectronPageInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
target_id: "electron-page",
|
||||
observation_ms: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.frames.map((frame) => frame.frame_id)).toEqual([
|
||||
"frame-main",
|
||||
]);
|
||||
expect(result.value.completeness).toMatchObject({
|
||||
unavailable_sections: ["frames"],
|
||||
});
|
||||
expect(result.value.completeness.excluded).toContainEqual({
|
||||
section: "frames",
|
||||
reason: "invalid_protocol_value",
|
||||
count: 1,
|
||||
});
|
||||
expect(result.value.completeness.policy_filtered_sections).not.toContain(
|
||||
"frames",
|
||||
);
|
||||
expect(result.value.completeness.conditions).toContain("attach_limited");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("CdpElectronProvider malformed source replies", () => {
|
||||
it.each([{}, { scriptSource: null }, { scriptSource: 123 }])(
|
||||
"rejects malformed Electron source replies instead of inventing source: %j",
|
||||
async (reply) => {
|
||||
@@ -291,7 +346,9 @@ describe("CdpElectronProvider capture", () => {
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("CdpElectronProvider script source capture", () => {
|
||||
it("retains an explicitly empty Electron producer script", async () => {
|
||||
const root = await electronFixture();
|
||||
const browser = await startFakeCdpBrowser({
|
||||
|
||||
@@ -3,6 +3,30 @@ import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js";
|
||||
import { captureWebScreenshotInputSchema } from "../../../src/domain/webScreenshot.js";
|
||||
import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js";
|
||||
describe("screenshot committed document identity", () => {
|
||||
it.each([
|
||||
["missing screenshot data", {}],
|
||||
["invalid base64 screenshot data", { data: "not base64%%%" }],
|
||||
])("reports %s as a protocol error", async (_label, result) => {
|
||||
const browser = await startFakeCdpBrowser({
|
||||
commandResult: ({ method }) =>
|
||||
method === "Page.captureScreenshot" ? result : undefined,
|
||||
});
|
||||
onTestFinished(async () => {
|
||||
await browser.close();
|
||||
});
|
||||
const observation = await new CdpBrowserProvider().captureScreenshot(
|
||||
captureWebScreenshotInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
}),
|
||||
);
|
||||
expect(observation).toMatchObject({
|
||||
ok: false,
|
||||
error: { reason: "protocol_error" },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ offset: 8, value: 14 },
|
||||
{ offset: 16, value: 0x8000_0000 },
|
||||
|
||||
@@ -684,6 +684,44 @@ describe("web capture WebSocket payload byte coverage", () => {
|
||||
});
|
||||
|
||||
describe("web capture diff", () => {
|
||||
it("reports request membership changes when selected body shapes are unavailable", async () => {
|
||||
const browser = await startFakeCdpBrowser();
|
||||
browsers.push(browser);
|
||||
const captured = await new CdpBrowserProvider().inspectPage(
|
||||
inspectWebPageInputSchema.parse({
|
||||
cdp_endpoint: browser.endpoint,
|
||||
allowed_origins: [browser.allowedOrigin],
|
||||
target_id: "allowed-page",
|
||||
observation_ms: 0,
|
||||
}),
|
||||
);
|
||||
if (!captured.ok) throw captured.error;
|
||||
const before = structuredClone(captured.value);
|
||||
const after = structuredClone(captured.value);
|
||||
const original = before.network.requests[0];
|
||||
if (original === undefined) throw new Error("Missing request fixture");
|
||||
after.network.requests.push({
|
||||
...structuredClone(original),
|
||||
request_id: `${original.request_id}-added`,
|
||||
url: `${browser.allowedOrigin}/added`,
|
||||
});
|
||||
for (const inspection of [before, after]) {
|
||||
markSectionsComplete(inspection, [
|
||||
"network_requests",
|
||||
"websocket_connections",
|
||||
"websocket_frames",
|
||||
]);
|
||||
for (const request of inspection.network.requests)
|
||||
request.body_shapes.status = "unavailable";
|
||||
}
|
||||
|
||||
expect(compareNetwork(before, after)).toMatchObject({
|
||||
status: "changed",
|
||||
total_changes: 1,
|
||||
changes: [expect.objectContaining({ change: "added" })],
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["status", "mime_type"] as const)(
|
||||
"keeps an independently unavailable HTTP %s unknown",
|
||||
async (field) => {
|
||||
|
||||
@@ -1102,6 +1102,21 @@ describe("source-map dependency coverage of hard-to-parse sources", () => {
|
||||
'import lib = require("./lib.js");',
|
||||
["./lib.js"],
|
||||
],
|
||||
[
|
||||
"whole-statement type-only import",
|
||||
'import type { Value } from "./types.js";',
|
||||
[],
|
||||
],
|
||||
[
|
||||
"whole-statement type-only export",
|
||||
'export type { Value } from "./types.js";',
|
||||
[],
|
||||
],
|
||||
[
|
||||
"inline type import retains module side effects",
|
||||
'import { type Value } from "./types.js";',
|
||||
["./types.js"],
|
||||
],
|
||||
[
|
||||
"a local import alias",
|
||||
'import lib = localAlias;\nimport "./a.js";',
|
||||
|
||||
Vendored
+87
-66
@@ -31,44 +31,9 @@ export const startFakeCdpBrowser = async (
|
||||
let openedConnections = 0;
|
||||
let frameTreeReads = 0;
|
||||
let port = 0;
|
||||
const http = createServer((request, response) => {
|
||||
httpRequests.push({
|
||||
url: request.url ?? "",
|
||||
authorization: request.headers.authorization,
|
||||
cookie: request.headers.cookie,
|
||||
referer: request.headers.referer,
|
||||
});
|
||||
response.setHeader("content-type", "application/json");
|
||||
if (serveSourceMap(request, response, options)) return;
|
||||
if (request.url === "/json/version") {
|
||||
response.end(
|
||||
options.oversizedDiscovery === true
|
||||
? " ".repeat(65 * 1_024)
|
||||
: options.malformedDiscovery === true
|
||||
? "{not-json"
|
||||
: JSON.stringify({
|
||||
Browser: "FakeChrome/1.0",
|
||||
"Protocol-Version": "1.3",
|
||||
"User-Agent": "FakeChrome",
|
||||
"V8-Version": "13.0",
|
||||
"WebKit-Version": "fake-revision",
|
||||
webSocketDebuggerUrl:
|
||||
options.invalidBrowserWebSocket === true
|
||||
? `ws://localhost:${String(port)}/devtools/invalid/fake`
|
||||
: options.pageScopedVersionWebSocket === true
|
||||
? `ws://localhost:${String(port)}/devtools/page/${versionTargetId(options)}`
|
||||
: `ws://localhost:${String(port)}/devtools/browser/fake`,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (request.url === "/json/list") {
|
||||
response.end(JSON.stringify(targets(port, options)));
|
||||
return;
|
||||
}
|
||||
response.statusCode = 404;
|
||||
response.end("{}");
|
||||
});
|
||||
const http = createServer((request, response) =>
|
||||
respondToHttpRequest(request, response, httpRequests, options, () => port),
|
||||
);
|
||||
const webSockets = new WebSocketServer({ noServer: true });
|
||||
http.on("upgrade", (request, socket, head) => {
|
||||
if (
|
||||
@@ -94,7 +59,7 @@ export const startFakeCdpBrowser = async (
|
||||
commands.push(command);
|
||||
const commandError = options.commandError?.(command);
|
||||
if (commandError !== undefined) {
|
||||
socket.send(JSON.stringify({ id: command.id, error: commandError }));
|
||||
sendCommandReply(socket, command, { error: commandError });
|
||||
return;
|
||||
}
|
||||
if (options.closeOnMethod === command.method) {
|
||||
@@ -107,12 +72,9 @@ export const startFakeCdpBrowser = async (
|
||||
return;
|
||||
}
|
||||
if (options.unsupportedMethods?.includes(command.method) === true) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
id: command.id,
|
||||
error: { code: -32_601, message: "Method not found" },
|
||||
}),
|
||||
);
|
||||
sendCommandReply(socket, command, {
|
||||
error: { code: -32_601, message: "Method not found" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
@@ -120,12 +82,9 @@ export const startFakeCdpBrowser = async (
|
||||
(command.method === "Target.attachToTarget" ||
|
||||
command.sessionId !== undefined)
|
||||
) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
id: command.id,
|
||||
error: { code: -32_600, message: "Direct page socket expected" },
|
||||
}),
|
||||
);
|
||||
sendCommandReply(socket, command, {
|
||||
error: { code: -32_600, message: "Direct page socket expected" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (command.method === "Page.getFrameTree") frameTreeReads += 1;
|
||||
@@ -164,6 +123,57 @@ export const startFakeCdpBrowser = async (
|
||||
};
|
||||
};
|
||||
|
||||
const respondToHttpRequest = (
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
requests: {
|
||||
url: string;
|
||||
authorization: string | undefined;
|
||||
cookie: string | undefined;
|
||||
referer: string | undefined;
|
||||
}[],
|
||||
options: FakeOptions,
|
||||
port: () => number,
|
||||
): void => {
|
||||
requests.push({
|
||||
url: request.url ?? "",
|
||||
authorization: request.headers.authorization,
|
||||
cookie: request.headers.cookie,
|
||||
referer: request.headers.referer,
|
||||
});
|
||||
response.setHeader("content-type", "application/json");
|
||||
const portNumber = port();
|
||||
if (serveSourceMap(request, response, options)) return;
|
||||
if (request.url === "/json/version") {
|
||||
response.end(
|
||||
options.oversizedDiscovery === true
|
||||
? " ".repeat(65 * 1_024)
|
||||
: options.malformedDiscovery === true
|
||||
? "{not-json"
|
||||
: JSON.stringify({
|
||||
Browser: "FakeChrome/1.0",
|
||||
"Protocol-Version": "1.3",
|
||||
"User-Agent": "FakeChrome",
|
||||
"V8-Version": "13.0",
|
||||
"WebKit-Version": "fake-revision",
|
||||
webSocketDebuggerUrl:
|
||||
options.invalidBrowserWebSocket === true
|
||||
? `ws://localhost:${String(portNumber)}/devtools/invalid/fake`
|
||||
: options.pageScopedVersionWebSocket === true
|
||||
? `ws://localhost:${String(portNumber)}/devtools/page/${versionTargetId(options)}`
|
||||
: `ws://localhost:${String(portNumber)}/devtools/browser/fake`,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (request.url === "/json/list") {
|
||||
response.end(JSON.stringify(targets(portNumber, options)));
|
||||
return;
|
||||
}
|
||||
response.statusCode = 404;
|
||||
response.end("{}");
|
||||
};
|
||||
|
||||
const boundPort = (
|
||||
address: string | null | { readonly port: number },
|
||||
): number => {
|
||||
@@ -172,6 +182,25 @@ const boundPort = (
|
||||
return address.port;
|
||||
};
|
||||
|
||||
/** Every ordinary reply uses the request's flattened target session. */
|
||||
const sendCommandReply = (
|
||||
socket: WebSocket,
|
||||
command: FakeCdpCommand,
|
||||
payload:
|
||||
| { readonly result: unknown }
|
||||
| { readonly error: { readonly code: number; readonly message: string } },
|
||||
): void => {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
id: command.id,
|
||||
...(command.sessionId === undefined
|
||||
? {}
|
||||
: { sessionId: command.sessionId }),
|
||||
...payload,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
/** Emit a configured producer reply and its associated events. */
|
||||
const replyToCommand = (
|
||||
socket: WebSocket,
|
||||
@@ -187,24 +216,16 @@ const replyToCommand = (
|
||||
frameTreeReads,
|
||||
) ?? resultFor(command, port, options, frameTreeReads);
|
||||
if (result === undefined) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
id: command.id,
|
||||
error: {
|
||||
code: -32_601,
|
||||
message: `Method not found: ${command.method}`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
sendCommandReply(socket, command, {
|
||||
error: {
|
||||
code: -32_601,
|
||||
message: `Method not found: ${command.method}`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
const reply = () => {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
id: command.id,
|
||||
result,
|
||||
}),
|
||||
);
|
||||
sendCommandReply(socket, command, { result });
|
||||
if (options.malformedEventOnMethod === command.method)
|
||||
socket.send("{not-json");
|
||||
if (options.malformedEventShapeOnMethod === command.method)
|
||||
|
||||
Reference in new issue
Block a user