@skyramp/mcp 0.1.8 → 0.2.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/playwright/registerPlaywrightTools.js +12 -0
- package/build/playwright/traceRecordingPrompt.js +15 -0
- package/build/prompts/test-recommendation/diffExecutionPlan.js +31 -0
- package/build/prompts/test-recommendation/recommendationSections.js +1 -2
- package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +94 -0
- package/build/prompts/testbot/testbot-prompts.js +115 -11
- package/build/prompts/testbot/testbot-prompts.test.js +79 -0
- package/build/resources/testbotResource.js +1 -1
- package/build/services/ScenarioGenerationService.integration.test.js +158 -0
- package/build/services/ScenarioGenerationService.js +36 -3
- package/build/services/ScenarioGenerationService.test.js +158 -22
- package/build/tools/generate-tests/generateBatchScenarioRestTool.js +16 -4
- package/build/tools/generate-tests/generateIntegrationRestTool.js +2 -0
- package/build/tools/generate-tests/generateUIRestTool.js +2 -0
- package/build/tools/test-management/analyzeChangesTool.js +7 -1
- package/build/utils/routeParsers.js +12 -0
- package/node_modules/playwright/ThirdPartyNotices.txt +6 -6
- package/node_modules/playwright/lib/dom-analyzer/analyze.js +111 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprint.js +1161 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprint.test.js +396 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintCache.js +57 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintCache.test.js +57 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintDiff.js +250 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintDiff.test.js +298 -0
- package/node_modules/playwright/lib/dom-analyzer/crawler.js +384 -0
- package/node_modules/playwright/lib/dom-analyzer/curatedWidgets.js +73 -0
- package/node_modules/playwright/lib/dom-analyzer/dynamicId.js +43 -0
- package/node_modules/playwright/lib/dom-analyzer/dynamicId.test.js +85 -0
- package/node_modules/playwright/lib/dom-analyzer/fingerprint.js +90 -0
- package/node_modules/playwright/lib/dom-analyzer/fingerprint.test.js +231 -0
- package/node_modules/playwright/lib/dom-analyzer/fingerprintAblation.fixtures.js +145 -0
- package/node_modules/playwright/lib/dom-analyzer/fingerprintAblation.test.js +41 -0
- package/node_modules/playwright/lib/dom-analyzer/graph.js +36 -0
- package/node_modules/playwright/lib/dom-analyzer/liveFingerprints.js +43 -0
- package/node_modules/playwright/lib/dom-analyzer/logicalNameResolver.js +72 -0
- package/node_modules/playwright/lib/dom-analyzer/logicalNameResolver.test.js +182 -0
- package/node_modules/playwright/lib/dom-analyzer/sectionGrouper.js +169 -0
- package/node_modules/playwright/lib/dom-analyzer/sectionGrouper.test.js +269 -0
- package/node_modules/playwright/lib/dom-analyzer/serialization.js +75 -0
- package/node_modules/playwright/lib/dom-analyzer/slug.js +30 -0
- package/node_modules/playwright/lib/dom-analyzer/slug.test.js +84 -0
- package/node_modules/playwright/lib/dom-analyzer/widgetContract.js +127 -0
- package/node_modules/playwright/lib/dom-analyzer/widgetContract.test.js +212 -0
- package/node_modules/playwright/lib/mcp/browser/browserContextFactory.js +3 -1
- package/node_modules/playwright/lib/mcp/browser/config.js +1 -1
- package/node_modules/playwright/lib/mcp/browser/context.js +17 -1
- package/node_modules/playwright/lib/mcp/browser/tab.js +38 -0
- package/node_modules/playwright/lib/mcp/browser/tools/domAnalyzer.js +261 -0
- package/node_modules/playwright/lib/mcp/browser/tools/keyboard.js +3 -3
- package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.js +129 -0
- package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.test.js +137 -0
- package/node_modules/playwright/lib/mcp/browser/tools/sitemap.js +226 -0
- package/node_modules/playwright/lib/mcp/browser/tools/snapshot.js +2 -2
- package/node_modules/playwright/lib/mcp/browser/tools/widgetContract.js +168 -0
- package/node_modules/playwright/lib/mcp/browser/tools.js +6 -0
- package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +52 -12
- package/node_modules/playwright/lib/mcp/test/skyRampExport.js +64 -13
- package/node_modules/playwright/package.json +1 -1
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.3.tgz +0 -0
- package/package.json +2 -2
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var import_logicalNameResolver = require("./logicalNameResolver");
|
|
3
|
+
const cases = [];
|
|
4
|
+
function test(name, run) {
|
|
5
|
+
cases.push({ name, run });
|
|
6
|
+
}
|
|
7
|
+
function assertEqual(actual, expected, msg) {
|
|
8
|
+
const a = JSON.stringify(actual);
|
|
9
|
+
const e = JSON.stringify(expected);
|
|
10
|
+
if (a !== e) throw new Error(`${msg ?? "assertEqual"} \u2014 expected ${e}, got ${a}`);
|
|
11
|
+
}
|
|
12
|
+
function input(overrides = {}) {
|
|
13
|
+
return {
|
|
14
|
+
initialName: "save_btn",
|
|
15
|
+
sectionName: "main",
|
|
16
|
+
testId: null,
|
|
17
|
+
stableId: null,
|
|
18
|
+
nearestAncestorId: null,
|
|
19
|
+
...overrides
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
test("resolveCollisions: single element no collision, pass through unchanged", () => {
|
|
23
|
+
const out = (0, import_logicalNameResolver.resolveCollisions)([input({ initialName: "save_btn" })]);
|
|
24
|
+
assertEqual(out, ["save_btn"]);
|
|
25
|
+
});
|
|
26
|
+
test("resolveCollisions: testId wins when element has one (slugged)", () => {
|
|
27
|
+
const out = (0, import_logicalNameResolver.resolveCollisions)([
|
|
28
|
+
input({ initialName: "save_btn", testId: "save-action" }),
|
|
29
|
+
input({ initialName: "save_btn", testId: "confirm-action" })
|
|
30
|
+
]);
|
|
31
|
+
assertEqual(out, ["save_action_save_btn", "confirm_action_save_btn"]);
|
|
32
|
+
});
|
|
33
|
+
test("resolveCollisions: stableId used when testId absent (slugged)", () => {
|
|
34
|
+
const out = (0, import_logicalNameResolver.resolveCollisions)([
|
|
35
|
+
input({ initialName: "save_btn", stableId: "top-save" }),
|
|
36
|
+
input({ initialName: "save_btn", stableId: "bottom-save" })
|
|
37
|
+
]);
|
|
38
|
+
assertEqual(out, ["top_save_save_btn", "bottom_save_save_btn"]);
|
|
39
|
+
});
|
|
40
|
+
test("resolveCollisions: nearestAncestorId used when self has no id (slugged)", () => {
|
|
41
|
+
const out = (0, import_logicalNameResolver.resolveCollisions)([
|
|
42
|
+
input({ initialName: "save_btn", nearestAncestorId: "form-a" }),
|
|
43
|
+
input({ initialName: "save_btn", nearestAncestorId: "form-b" })
|
|
44
|
+
]);
|
|
45
|
+
assertEqual(out, ["form_a_save_btn", "form_b_save_btn"]);
|
|
46
|
+
});
|
|
47
|
+
test("resolveCollisions: position-hinted fallback when nothing disambiguates", () => {
|
|
48
|
+
const out = (0, import_logicalNameResolver.resolveCollisions)([
|
|
49
|
+
input({ initialName: "save_btn", sectionName: "main" }),
|
|
50
|
+
input({ initialName: "save_btn", sectionName: "main" }),
|
|
51
|
+
input({ initialName: "save_btn", sectionName: "main" })
|
|
52
|
+
]);
|
|
53
|
+
assertEqual(out, ["main_save_btn_1", "main_save_btn_2", "main_save_btn_3"]);
|
|
54
|
+
});
|
|
55
|
+
test("resolveCollisions: duplicate testIds fall through to position-hinted fallback", () => {
|
|
56
|
+
const out = (0, import_logicalNameResolver.resolveCollisions)([
|
|
57
|
+
input({ initialName: "save_btn", testId: "save", sectionName: "main" }),
|
|
58
|
+
input({ initialName: "save_btn", testId: "save", sectionName: "main" })
|
|
59
|
+
]);
|
|
60
|
+
assertEqual(out, ["main_save_btn_1", "main_save_btn_2"]);
|
|
61
|
+
});
|
|
62
|
+
test("resolveCollisions: ancestor id with spaces and caps is slugged", () => {
|
|
63
|
+
const out = (0, import_logicalNameResolver.resolveCollisions)([
|
|
64
|
+
input({
|
|
65
|
+
initialName: "view_details_btn",
|
|
66
|
+
sectionName: "page",
|
|
67
|
+
nearestAncestorId: "order-detail-product-Bose QuietComfort 35 II"
|
|
68
|
+
}),
|
|
69
|
+
input({
|
|
70
|
+
initialName: "view_details_btn",
|
|
71
|
+
sectionName: "page",
|
|
72
|
+
nearestAncestorId: "order-detail-product-Apple iWatch"
|
|
73
|
+
})
|
|
74
|
+
]);
|
|
75
|
+
assertEqual(out, [
|
|
76
|
+
"order_detail_product_bose_quietcomfort_35_ii_view_details_btn",
|
|
77
|
+
"order_detail_product_apple_iwatch_view_details_btn"
|
|
78
|
+
]);
|
|
79
|
+
});
|
|
80
|
+
test("resolveCollisions: testId with capitals is slugged", () => {
|
|
81
|
+
const out = (0, import_logicalNameResolver.resolveCollisions)([
|
|
82
|
+
input({ initialName: "save_btn", testId: "MainSaveBtn" }),
|
|
83
|
+
input({ initialName: "save_btn", testId: "FooterSaveBtn" })
|
|
84
|
+
]);
|
|
85
|
+
assertEqual(out, ["mainsavebtn_save_btn", "footersavebtn_save_btn"]);
|
|
86
|
+
});
|
|
87
|
+
test("resolveCollisions: prefix that slugs to empty falls through tier", () => {
|
|
88
|
+
const out = (0, import_logicalNameResolver.resolveCollisions)([
|
|
89
|
+
input({ initialName: "save_btn", sectionName: "main", nearestAncestorId: "!!!" }),
|
|
90
|
+
input({ initialName: "save_btn", sectionName: "main", nearestAncestorId: "---" })
|
|
91
|
+
]);
|
|
92
|
+
assertEqual(out, ["main_save_btn_1", "main_save_btn_2"]);
|
|
93
|
+
});
|
|
94
|
+
test("resolveCollisions: ladder falls through when testId slugs to empty", () => {
|
|
95
|
+
const out = (0, import_logicalNameResolver.resolveCollisions)([
|
|
96
|
+
input({ initialName: "save_btn", testId: "!!!", stableId: "top-save" }),
|
|
97
|
+
input({ initialName: "save_btn", testId: "???", stableId: "bottom-save" })
|
|
98
|
+
]);
|
|
99
|
+
assertEqual(out, ["top_save_save_btn", "bottom_save_save_btn"]);
|
|
100
|
+
});
|
|
101
|
+
test("resolveCollisions: CJK ancestor id preserved after slugging", () => {
|
|
102
|
+
const out = (0, import_logicalNameResolver.resolveCollisions)([
|
|
103
|
+
input({ initialName: "edit_btn", nearestAncestorId: "\u8CC7\u7523\u7BA1\u7406_\u30D1\u30CD\u30EB" }),
|
|
104
|
+
input({ initialName: "edit_btn", nearestAncestorId: "\u5C65\u6B74\u60C5\u5831_\u30D1\u30CD\u30EB" })
|
|
105
|
+
]);
|
|
106
|
+
assertEqual(out, ["\u8CC7\u7523\u7BA1\u7406_\u30D1\u30CD\u30EB_edit_btn", "\u5C65\u6B74\u60C5\u5831_\u30D1\u30CD\u30EB_edit_btn"]);
|
|
107
|
+
});
|
|
108
|
+
test("resolveCollisions: no cross-section collision \u2014 same initialName in different sections", () => {
|
|
109
|
+
const out = (0, import_logicalNameResolver.resolveCollisions)([
|
|
110
|
+
input({ initialName: "save_btn", sectionName: "header" }),
|
|
111
|
+
input({ initialName: "save_btn", sectionName: "footer" })
|
|
112
|
+
]);
|
|
113
|
+
assertEqual(out, ["save_btn", "save_btn"]);
|
|
114
|
+
});
|
|
115
|
+
test("resolveCollisions: mixed group \u2014 one with testId, one without", () => {
|
|
116
|
+
const out = (0, import_logicalNameResolver.resolveCollisions)([
|
|
117
|
+
input({ initialName: "save_btn", testId: "save-top" }),
|
|
118
|
+
input({ initialName: "save_btn" })
|
|
119
|
+
]);
|
|
120
|
+
assertEqual(out, ["main_save_btn_1", "main_save_btn_2"]);
|
|
121
|
+
});
|
|
122
|
+
test("resolveCollisions: preserves document order in position hints", () => {
|
|
123
|
+
const out = (0, import_logicalNameResolver.resolveCollisions)([
|
|
124
|
+
input({ initialName: "item", sectionName: "list" }),
|
|
125
|
+
input({ initialName: "item", sectionName: "list" }),
|
|
126
|
+
input({ initialName: "item", sectionName: "list" })
|
|
127
|
+
]);
|
|
128
|
+
assertEqual(out, ["list_item_1", "list_item_2", "list_item_3"]);
|
|
129
|
+
});
|
|
130
|
+
test("resolveCollisions: multi-signal precedence \u2014 testId beats stableId beats nearestAncestorId", () => {
|
|
131
|
+
const out = (0, import_logicalNameResolver.resolveCollisions)([
|
|
132
|
+
input({ initialName: "save_btn", testId: "save-all", stableId: "id-a", nearestAncestorId: "anc-a" }),
|
|
133
|
+
input({ initialName: "save_btn", testId: null, stableId: "id-b", nearestAncestorId: "anc-b" })
|
|
134
|
+
]);
|
|
135
|
+
assertEqual(out, ["save_all_save_btn", "id_b_save_btn"]);
|
|
136
|
+
});
|
|
137
|
+
test("resolveCollisions: multiple simultaneous collision groups in one input", () => {
|
|
138
|
+
const out = (0, import_logicalNameResolver.resolveCollisions)([
|
|
139
|
+
input({ initialName: "save_btn", testId: "save-a", sectionName: "main" }),
|
|
140
|
+
input({ initialName: "cancel_btn", testId: "cancel-a", sectionName: "main" }),
|
|
141
|
+
input({ initialName: "save_btn", testId: "save-b", sectionName: "main" }),
|
|
142
|
+
input({ initialName: "cancel_btn", testId: "cancel-b", sectionName: "main" })
|
|
143
|
+
]);
|
|
144
|
+
assertEqual(out, [
|
|
145
|
+
"save_a_save_btn",
|
|
146
|
+
"cancel_a_cancel_btn",
|
|
147
|
+
"save_b_save_btn",
|
|
148
|
+
"cancel_b_cancel_btn"
|
|
149
|
+
]);
|
|
150
|
+
});
|
|
151
|
+
test("resolveCollisions: mixed collision and non-collision elements in one input", () => {
|
|
152
|
+
const out = (0, import_logicalNameResolver.resolveCollisions)([
|
|
153
|
+
input({ initialName: "unique_btn", testId: "u", sectionName: "main" }),
|
|
154
|
+
input({ initialName: "save_btn", testId: "a", sectionName: "main" }),
|
|
155
|
+
input({ initialName: "save_btn", testId: "b", sectionName: "main" }),
|
|
156
|
+
input({ initialName: "another_btn", testId: "x", sectionName: "main" })
|
|
157
|
+
]);
|
|
158
|
+
assertEqual(out, [
|
|
159
|
+
"unique_btn",
|
|
160
|
+
"a_save_btn",
|
|
161
|
+
"b_save_btn",
|
|
162
|
+
"another_btn"
|
|
163
|
+
]);
|
|
164
|
+
});
|
|
165
|
+
let failed = 0;
|
|
166
|
+
for (const { name, run } of cases) {
|
|
167
|
+
try {
|
|
168
|
+
run();
|
|
169
|
+
console.log(" \u2713", name);
|
|
170
|
+
} catch (e) {
|
|
171
|
+
failed++;
|
|
172
|
+
console.log(" \u2717", name);
|
|
173
|
+
console.log(" ", e.message);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (failed > 0) {
|
|
177
|
+
console.log(`
|
|
178
|
+
${failed}/${cases.length} failed`);
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
console.log(`
|
|
182
|
+
${cases.length} passed`);
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
var sectionGrouper_exports = {};
|
|
20
|
+
__export(sectionGrouper_exports, {
|
|
21
|
+
JUNK_SECTION_NAME_PATTERN: () => JUNK_SECTION_NAME_PATTERN,
|
|
22
|
+
resolveSections: () => resolveSections
|
|
23
|
+
});
|
|
24
|
+
module.exports = __toCommonJS(sectionGrouper_exports);
|
|
25
|
+
var import_slug = require("./slug");
|
|
26
|
+
const JUNK_SECTION_NAME_PATTERN = /^(content|section|untitled|main|overview|details)$/i;
|
|
27
|
+
function isJunk(candidateSlug) {
|
|
28
|
+
return !candidateSlug || JUNK_SECTION_NAME_PATTERN.test(candidateSlug);
|
|
29
|
+
}
|
|
30
|
+
function resolveSections(inputs) {
|
|
31
|
+
const assignments = {};
|
|
32
|
+
const candidatesByAnchorKey = /* @__PURE__ */ new Map();
|
|
33
|
+
const elementsByAnchorKey = /* @__PURE__ */ new Map();
|
|
34
|
+
const headingAnchorChainByElement = /* @__PURE__ */ new Map();
|
|
35
|
+
for (const inp of inputs) {
|
|
36
|
+
const { landmarkAncestor, sectioningAncestor, headingAncestry } = inp.ancestry;
|
|
37
|
+
if (landmarkAncestor) {
|
|
38
|
+
const name = landmarkAncestor.nameHint;
|
|
39
|
+
assignments[inp.id] = name;
|
|
40
|
+
candidatesByAnchorKey.set(landmarkAncestor.anchorKey, {
|
|
41
|
+
name,
|
|
42
|
+
landmark: landmarkAncestor.landmark,
|
|
43
|
+
anchorKey: landmarkAncestor.anchorKey
|
|
44
|
+
});
|
|
45
|
+
const arr2 = elementsByAnchorKey.get(landmarkAncestor.anchorKey) ?? [];
|
|
46
|
+
arr2.push(inp.id);
|
|
47
|
+
elementsByAnchorKey.set(landmarkAncestor.anchorKey, arr2);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (sectioningAncestor) {
|
|
51
|
+
const rawName = sectioningAncestor.accessibleName;
|
|
52
|
+
if (rawName) {
|
|
53
|
+
const candidateSlug = (0, import_slug.slug)(rawName);
|
|
54
|
+
if (!isJunk(candidateSlug)) {
|
|
55
|
+
assignments[inp.id] = candidateSlug;
|
|
56
|
+
candidatesByAnchorKey.set(sectioningAncestor.anchorKey, {
|
|
57
|
+
name: candidateSlug,
|
|
58
|
+
landmark: "other",
|
|
59
|
+
landmarkOther: sectioningAncestor.tagName,
|
|
60
|
+
anchorKey: sectioningAncestor.anchorKey
|
|
61
|
+
});
|
|
62
|
+
const arr2 = elementsByAnchorKey.get(sectioningAncestor.anchorKey) ?? [];
|
|
63
|
+
arr2.push(inp.id);
|
|
64
|
+
elementsByAnchorKey.set(sectioningAncestor.anchorKey, arr2);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
let assignedFromHeading = false;
|
|
70
|
+
for (let i = headingAncestry.length - 1; i >= 0; i--) {
|
|
71
|
+
const h = headingAncestry[i];
|
|
72
|
+
const candidateSlug = (0, import_slug.slug)(h.text);
|
|
73
|
+
if (!isJunk(candidateSlug)) {
|
|
74
|
+
let finalName = candidateSlug;
|
|
75
|
+
let suffix = 2;
|
|
76
|
+
while ([...candidatesByAnchorKey.values()].some((c) => c.name === finalName && c.anchorKey !== h.anchorKey)) {
|
|
77
|
+
finalName = `${candidateSlug}_${suffix++}`;
|
|
78
|
+
}
|
|
79
|
+
assignments[inp.id] = finalName;
|
|
80
|
+
candidatesByAnchorKey.set(h.anchorKey, {
|
|
81
|
+
name: finalName,
|
|
82
|
+
landmark: "other",
|
|
83
|
+
landmarkOther: "heading",
|
|
84
|
+
anchorKey: h.anchorKey
|
|
85
|
+
});
|
|
86
|
+
const arr2 = elementsByAnchorKey.get(h.anchorKey) ?? [];
|
|
87
|
+
arr2.push(inp.id);
|
|
88
|
+
elementsByAnchorKey.set(h.anchorKey, arr2);
|
|
89
|
+
headingAnchorChainByElement.set(
|
|
90
|
+
inp.id,
|
|
91
|
+
headingAncestry.map((hh) => hh.anchorKey)
|
|
92
|
+
);
|
|
93
|
+
assignedFromHeading = true;
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (assignedFromHeading) continue;
|
|
98
|
+
assignments[inp.id] = "page";
|
|
99
|
+
const pageAnchorKey = "page-fallback";
|
|
100
|
+
candidatesByAnchorKey.set(pageAnchorKey, { name: "page", landmark: "region", anchorKey: pageAnchorKey });
|
|
101
|
+
const arr = elementsByAnchorKey.get(pageAnchorKey) ?? [];
|
|
102
|
+
arr.push(inp.id);
|
|
103
|
+
elementsByAnchorKey.set(pageAnchorKey, arr);
|
|
104
|
+
}
|
|
105
|
+
const candidateAnchorKeysWithElements = new Set(
|
|
106
|
+
[...elementsByAnchorKey.entries()].filter(([, ids]) => ids.length > 0).map(([k]) => k)
|
|
107
|
+
);
|
|
108
|
+
const toDrop = /* @__PURE__ */ new Set();
|
|
109
|
+
for (const [anchorKey, info] of candidatesByAnchorKey.entries()) {
|
|
110
|
+
if (info.landmarkOther !== "heading") continue;
|
|
111
|
+
let isAncestor = false;
|
|
112
|
+
for (const [, chain] of headingAnchorChainByElement.entries()) {
|
|
113
|
+
const idx = chain.indexOf(anchorKey);
|
|
114
|
+
if (idx === -1) continue;
|
|
115
|
+
for (let j = idx + 1; j < chain.length; j++) {
|
|
116
|
+
const deeperKey = chain[j];
|
|
117
|
+
if (candidateAnchorKeysWithElements.has(deeperKey) && deeperKey !== anchorKey) {
|
|
118
|
+
isAncestor = true;
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (isAncestor) break;
|
|
123
|
+
}
|
|
124
|
+
if (isAncestor) toDrop.add(anchorKey);
|
|
125
|
+
}
|
|
126
|
+
for (const droppedKey of toDrop) {
|
|
127
|
+
const affectedIds = elementsByAnchorKey.get(droppedKey) ?? [];
|
|
128
|
+
for (const id of affectedIds) {
|
|
129
|
+
const chain = headingAnchorChainByElement.get(id) ?? [];
|
|
130
|
+
let reassigned = null;
|
|
131
|
+
for (let i = chain.length - 1; i >= 0; i--) {
|
|
132
|
+
const k = chain[i];
|
|
133
|
+
if (k === droppedKey) continue;
|
|
134
|
+
if (toDrop.has(k)) continue;
|
|
135
|
+
const cand = candidatesByAnchorKey.get(k);
|
|
136
|
+
if (cand && cand.landmarkOther === "heading") {
|
|
137
|
+
reassigned = cand.name;
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
assignments[id] = reassigned ?? "page";
|
|
142
|
+
}
|
|
143
|
+
candidatesByAnchorKey.delete(droppedKey);
|
|
144
|
+
elementsByAnchorKey.delete(droppedKey);
|
|
145
|
+
}
|
|
146
|
+
const sections = [];
|
|
147
|
+
const seenNames = /* @__PURE__ */ new Set();
|
|
148
|
+
const assignmentOrder = [];
|
|
149
|
+
for (const inp of inputs) {
|
|
150
|
+
const name = assignments[inp.id];
|
|
151
|
+
if (name && !assignmentOrder.includes(name)) assignmentOrder.push(name);
|
|
152
|
+
}
|
|
153
|
+
for (const name of assignmentOrder) {
|
|
154
|
+
if (seenNames.has(name)) continue;
|
|
155
|
+
seenNames.add(name);
|
|
156
|
+
const info = [...candidatesByAnchorKey.values()].find((c) => c.name === name);
|
|
157
|
+
if (info) {
|
|
158
|
+
sections.push({ name: info.name, landmark: info.landmark, landmarkOther: info.landmarkOther });
|
|
159
|
+
} else if (name === "page") {
|
|
160
|
+
sections.push({ name: "page", landmark: "region" });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return { assignments, sections };
|
|
164
|
+
}
|
|
165
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
166
|
+
0 && (module.exports = {
|
|
167
|
+
JUNK_SECTION_NAME_PATTERN,
|
|
168
|
+
resolveSections
|
|
169
|
+
});
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var import_sectionGrouper = require("./sectionGrouper");
|
|
3
|
+
const cases = [];
|
|
4
|
+
function test(name, run) {
|
|
5
|
+
cases.push({ name, run });
|
|
6
|
+
}
|
|
7
|
+
function assertEqual(actual, expected, msg) {
|
|
8
|
+
const a = JSON.stringify(actual);
|
|
9
|
+
const e = JSON.stringify(expected);
|
|
10
|
+
if (a !== e) throw new Error(`${msg ?? "assertEqual"} \u2014 expected ${e}, got ${a}`);
|
|
11
|
+
}
|
|
12
|
+
function el(overrides) {
|
|
13
|
+
return {
|
|
14
|
+
id: "test-el-" + Math.random(),
|
|
15
|
+
ancestry: { landmarkAncestor: null, sectioningAncestor: null, headingAncestry: [] },
|
|
16
|
+
...overrides
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
test("resolveSections: landmark beats sectioning beats heading", () => {
|
|
20
|
+
const inputs = [
|
|
21
|
+
el({
|
|
22
|
+
id: "a",
|
|
23
|
+
ancestry: {
|
|
24
|
+
landmarkAncestor: { landmark: "main", nameHint: "main", anchorKey: "ak1" },
|
|
25
|
+
sectioningAncestor: { tagName: "section", accessibleName: "Inner", anchorKey: "ak2" },
|
|
26
|
+
headingAncestry: [{ level: 2, text: "Deepest", anchorKey: "ak3" }]
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
];
|
|
30
|
+
const out = (0, import_sectionGrouper.resolveSections)(inputs);
|
|
31
|
+
assertEqual(out.assignments, { a: "main" });
|
|
32
|
+
});
|
|
33
|
+
test("resolveSections: sectioning without name falls through to implicit heading", () => {
|
|
34
|
+
const inputs = [
|
|
35
|
+
el({
|
|
36
|
+
id: "a",
|
|
37
|
+
ancestry: {
|
|
38
|
+
landmarkAncestor: null,
|
|
39
|
+
sectioningAncestor: { tagName: "section", accessibleName: null, anchorKey: "ak1" },
|
|
40
|
+
headingAncestry: [{ level: 2, text: "Orders", anchorKey: "ak2" }]
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
];
|
|
44
|
+
const out = (0, import_sectionGrouper.resolveSections)(inputs);
|
|
45
|
+
assertEqual(out.assignments, { a: "orders" });
|
|
46
|
+
});
|
|
47
|
+
test("resolveSections: G1 drops empty-interactive implicit heading", () => {
|
|
48
|
+
const inputs = [
|
|
49
|
+
el({
|
|
50
|
+
id: "a",
|
|
51
|
+
ancestry: {
|
|
52
|
+
landmarkAncestor: { landmark: "main", nameHint: "main", anchorKey: "main-ak" },
|
|
53
|
+
sectioningAncestor: null,
|
|
54
|
+
headingAncestry: [{ level: 2, text: "Decorative", anchorKey: "ak1" }]
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
];
|
|
58
|
+
const out = (0, import_sectionGrouper.resolveSections)(inputs);
|
|
59
|
+
assertEqual(out.assignments, { a: "main" });
|
|
60
|
+
assertEqual(out.sections.find((s) => s.name === "decorative"), void 0);
|
|
61
|
+
});
|
|
62
|
+
test("resolveSections: G2 (leaf-only) drops ancestor heading when descendant has elements", () => {
|
|
63
|
+
const inputs = [
|
|
64
|
+
el({
|
|
65
|
+
id: "a",
|
|
66
|
+
ancestry: {
|
|
67
|
+
landmarkAncestor: null,
|
|
68
|
+
sectioningAncestor: null,
|
|
69
|
+
headingAncestry: [
|
|
70
|
+
{ level: 1, text: "Dashboard", anchorKey: "dash-ak" },
|
|
71
|
+
{ level: 2, text: "Orders", anchorKey: "orders-ak" }
|
|
72
|
+
]
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
];
|
|
76
|
+
const out = (0, import_sectionGrouper.resolveSections)(inputs);
|
|
77
|
+
assertEqual(out.assignments, { a: "orders" });
|
|
78
|
+
});
|
|
79
|
+
test("resolveSections: G2 keeps two sibling headings at same level", () => {
|
|
80
|
+
const inputs = [
|
|
81
|
+
el({
|
|
82
|
+
id: "a",
|
|
83
|
+
ancestry: {
|
|
84
|
+
landmarkAncestor: null,
|
|
85
|
+
sectioningAncestor: null,
|
|
86
|
+
headingAncestry: [{ level: 2, text: "Orders", anchorKey: "orders-ak" }]
|
|
87
|
+
}
|
|
88
|
+
}),
|
|
89
|
+
el({
|
|
90
|
+
id: "b",
|
|
91
|
+
ancestry: {
|
|
92
|
+
landmarkAncestor: null,
|
|
93
|
+
sectioningAncestor: null,
|
|
94
|
+
headingAncestry: [{ level: 2, text: "Products", anchorKey: "products-ak" }]
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
];
|
|
98
|
+
const out = (0, import_sectionGrouper.resolveSections)(inputs);
|
|
99
|
+
assertEqual(out.assignments, { a: "orders", b: "products" });
|
|
100
|
+
});
|
|
101
|
+
test("resolveSections: G3 rejects junk slug \u2014 fall through to implicit heading", () => {
|
|
102
|
+
const inputs = [
|
|
103
|
+
el({
|
|
104
|
+
id: "a",
|
|
105
|
+
ancestry: {
|
|
106
|
+
landmarkAncestor: null,
|
|
107
|
+
sectioningAncestor: { tagName: "section", accessibleName: "Untitled", anchorKey: "sec-ak" },
|
|
108
|
+
headingAncestry: [{ level: 2, text: "Orders", anchorKey: "orders-ak" }]
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
];
|
|
112
|
+
const out = (0, import_sectionGrouper.resolveSections)(inputs);
|
|
113
|
+
assertEqual(out.assignments, { a: "orders" });
|
|
114
|
+
});
|
|
115
|
+
test("resolveSections: G3 rejects all signals \u2192 page fallback", () => {
|
|
116
|
+
const inputs = [
|
|
117
|
+
el({
|
|
118
|
+
id: "a",
|
|
119
|
+
ancestry: {
|
|
120
|
+
landmarkAncestor: null,
|
|
121
|
+
sectioningAncestor: { tagName: "section", accessibleName: "Content", anchorKey: "sec-ak" },
|
|
122
|
+
headingAncestry: [{ level: 2, text: "Overview", anchorKey: "ov-ak" }]
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
];
|
|
126
|
+
const out = (0, import_sectionGrouper.resolveSections)(inputs);
|
|
127
|
+
assertEqual(out.assignments, { a: "page" });
|
|
128
|
+
});
|
|
129
|
+
test("resolveSections: sectioning null-name and junk-name behave identically (fall through)", () => {
|
|
130
|
+
const inputsNull = [
|
|
131
|
+
el({
|
|
132
|
+
id: "null-a",
|
|
133
|
+
ancestry: {
|
|
134
|
+
landmarkAncestor: null,
|
|
135
|
+
sectioningAncestor: { tagName: "section", accessibleName: null, anchorKey: "s1" },
|
|
136
|
+
headingAncestry: [{ level: 2, text: "Orders", anchorKey: "o1" }]
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
];
|
|
140
|
+
const inputsJunk = [
|
|
141
|
+
el({
|
|
142
|
+
id: "junk-a",
|
|
143
|
+
ancestry: {
|
|
144
|
+
landmarkAncestor: null,
|
|
145
|
+
sectioningAncestor: { tagName: "section", accessibleName: "Untitled", anchorKey: "s2" },
|
|
146
|
+
headingAncestry: [{ level: 2, text: "Orders", anchorKey: "o2" }]
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
];
|
|
150
|
+
const outNull = (0, import_sectionGrouper.resolveSections)(inputsNull);
|
|
151
|
+
const outJunk = (0, import_sectionGrouper.resolveSections)(inputsJunk);
|
|
152
|
+
assertEqual(outNull.assignments["null-a"], "orders");
|
|
153
|
+
assertEqual(outJunk.assignments["junk-a"], "orders");
|
|
154
|
+
});
|
|
155
|
+
test("resolveSections: two headings same text different anchorKeys \u2192 separate sections", () => {
|
|
156
|
+
const inputs = [
|
|
157
|
+
el({
|
|
158
|
+
id: "a",
|
|
159
|
+
ancestry: {
|
|
160
|
+
landmarkAncestor: null,
|
|
161
|
+
sectioningAncestor: null,
|
|
162
|
+
headingAncestry: [{ level: 2, text: "Orders", anchorKey: "orders-1" }]
|
|
163
|
+
}
|
|
164
|
+
}),
|
|
165
|
+
el({
|
|
166
|
+
id: "b",
|
|
167
|
+
ancestry: {
|
|
168
|
+
landmarkAncestor: null,
|
|
169
|
+
sectioningAncestor: null,
|
|
170
|
+
headingAncestry: [{ level: 2, text: "Orders", anchorKey: "orders-2" }]
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
];
|
|
174
|
+
const out = (0, import_sectionGrouper.resolveSections)(inputs);
|
|
175
|
+
assertEqual(out.assignments, { a: "orders", b: "orders_2" });
|
|
176
|
+
});
|
|
177
|
+
test("resolveSections: element in landmark with inner heading \u2014 landmark wins", () => {
|
|
178
|
+
const inputs = [
|
|
179
|
+
el({
|
|
180
|
+
id: "a",
|
|
181
|
+
ancestry: {
|
|
182
|
+
landmarkAncestor: { landmark: "main", nameHint: "main", anchorKey: "main-ak" },
|
|
183
|
+
sectioningAncestor: null,
|
|
184
|
+
headingAncestry: [{ level: 3, text: "Filters", anchorKey: "h3-ak" }]
|
|
185
|
+
}
|
|
186
|
+
})
|
|
187
|
+
];
|
|
188
|
+
const out = (0, import_sectionGrouper.resolveSections)(inputs);
|
|
189
|
+
assertEqual(out.assignments, { a: "main" });
|
|
190
|
+
});
|
|
191
|
+
test("resolveSections: element in landmark+sectioning+heading \u2014 landmark wins (strongest)", () => {
|
|
192
|
+
const inputs = [
|
|
193
|
+
el({
|
|
194
|
+
id: "a",
|
|
195
|
+
ancestry: {
|
|
196
|
+
landmarkAncestor: { landmark: "main", nameHint: "main", anchorKey: "main-ak" },
|
|
197
|
+
sectioningAncestor: { tagName: "section", accessibleName: "Orders", anchorKey: "ord-ak" },
|
|
198
|
+
headingAncestry: [{ level: 3, text: "Filters", anchorKey: "h3-ak" }]
|
|
199
|
+
}
|
|
200
|
+
})
|
|
201
|
+
];
|
|
202
|
+
const out = (0, import_sectionGrouper.resolveSections)(inputs);
|
|
203
|
+
assertEqual(out.assignments, { a: "main" });
|
|
204
|
+
});
|
|
205
|
+
test("resolveSections: G2 nested drop cascade \u2014 A > B > C, only C has elements", () => {
|
|
206
|
+
const inputs = [
|
|
207
|
+
el({
|
|
208
|
+
id: "a",
|
|
209
|
+
ancestry: {
|
|
210
|
+
landmarkAncestor: null,
|
|
211
|
+
sectioningAncestor: null,
|
|
212
|
+
headingAncestry: [
|
|
213
|
+
{ level: 1, text: "Dashboard", anchorKey: "dash-ak" },
|
|
214
|
+
{ level: 2, text: "Orders", anchorKey: "orders-ak" },
|
|
215
|
+
{ level: 3, text: "Recent", anchorKey: "recent-ak" }
|
|
216
|
+
]
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
];
|
|
220
|
+
const out = (0, import_sectionGrouper.resolveSections)(inputs);
|
|
221
|
+
assertEqual(out.assignments, { a: "recent" });
|
|
222
|
+
assertEqual(out.sections.find((s) => s.name === "dashboard"), void 0);
|
|
223
|
+
assertEqual(out.sections.find((s) => s.name === "orders"), void 0);
|
|
224
|
+
});
|
|
225
|
+
test("resolveSections: G2 keeps ancestor heading that has its own elements", () => {
|
|
226
|
+
const inputs = [
|
|
227
|
+
el({
|
|
228
|
+
id: "a",
|
|
229
|
+
ancestry: {
|
|
230
|
+
landmarkAncestor: null,
|
|
231
|
+
sectioningAncestor: null,
|
|
232
|
+
headingAncestry: [
|
|
233
|
+
{ level: 1, text: "Dashboard", anchorKey: "dash-ak" }
|
|
234
|
+
]
|
|
235
|
+
}
|
|
236
|
+
}),
|
|
237
|
+
el({
|
|
238
|
+
id: "b",
|
|
239
|
+
ancestry: {
|
|
240
|
+
landmarkAncestor: null,
|
|
241
|
+
sectioningAncestor: null,
|
|
242
|
+
headingAncestry: [
|
|
243
|
+
{ level: 1, text: "Dashboard", anchorKey: "dash-ak" },
|
|
244
|
+
{ level: 2, text: "Orders", anchorKey: "orders-ak" }
|
|
245
|
+
]
|
|
246
|
+
}
|
|
247
|
+
})
|
|
248
|
+
];
|
|
249
|
+
const out = (0, import_sectionGrouper.resolveSections)(inputs);
|
|
250
|
+
assertEqual(out.assignments, { a: "page", b: "orders" });
|
|
251
|
+
});
|
|
252
|
+
let failed = 0;
|
|
253
|
+
for (const { name, run } of cases) {
|
|
254
|
+
try {
|
|
255
|
+
run();
|
|
256
|
+
console.log(" \u2713", name);
|
|
257
|
+
} catch (e) {
|
|
258
|
+
failed++;
|
|
259
|
+
console.log(" \u2717", name);
|
|
260
|
+
console.log(" ", e.message);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (failed > 0) {
|
|
264
|
+
console.log(`
|
|
265
|
+
${failed}/${cases.length} failed`);
|
|
266
|
+
process.exit(1);
|
|
267
|
+
}
|
|
268
|
+
console.log(`
|
|
269
|
+
${cases.length} passed`);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
var serialization_exports = {};
|
|
20
|
+
__export(serialization_exports, {
|
|
21
|
+
CRAWL_TTL_MS: () => CRAWL_TTL_MS,
|
|
22
|
+
SCHEMA_VERSION: () => SCHEMA_VERSION,
|
|
23
|
+
assertSchemaVersion: () => assertSchemaVersion,
|
|
24
|
+
normalizeUrl: () => normalizeUrl,
|
|
25
|
+
nowTimestamp: () => nowTimestamp,
|
|
26
|
+
validateTimestamp: () => validateTimestamp
|
|
27
|
+
});
|
|
28
|
+
module.exports = __toCommonJS(serialization_exports);
|
|
29
|
+
const SCHEMA_VERSION = 1;
|
|
30
|
+
const CRAWL_TTL_MS = 30 * 60 * 1e3;
|
|
31
|
+
function normalizeUrl(raw) {
|
|
32
|
+
const url = new URL(raw);
|
|
33
|
+
url.hash = "";
|
|
34
|
+
if (url.protocol === "http:" && url.port === "80" || url.protocol === "https:" && url.port === "443")
|
|
35
|
+
url.port = "";
|
|
36
|
+
if (url.search) {
|
|
37
|
+
const params = Array.from(url.searchParams.entries());
|
|
38
|
+
params.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
39
|
+
url.search = "";
|
|
40
|
+
for (const [k, v] of params)
|
|
41
|
+
url.searchParams.append(k, v);
|
|
42
|
+
}
|
|
43
|
+
let pathname = url.pathname;
|
|
44
|
+
if (pathname.length > 1 && pathname.endsWith("/"))
|
|
45
|
+
pathname = pathname.replace(/\/+$/, "");
|
|
46
|
+
const normalizedPath = pathname.replace(/%[0-9a-f]{2}/g, (m) => m.toUpperCase());
|
|
47
|
+
const normalizedSearch = url.search.replace(/%[0-9a-f]{2}/g, (m) => m.toUpperCase());
|
|
48
|
+
const origin = `${url.protocol}//${url.host}`;
|
|
49
|
+
return `${origin}${normalizedPath}${normalizedSearch}`;
|
|
50
|
+
}
|
|
51
|
+
function validateTimestamp(value) {
|
|
52
|
+
const parsed = new Date(value);
|
|
53
|
+
if (Number.isNaN(parsed.getTime()))
|
|
54
|
+
throw new Error(`Invalid ISO-8601 timestamp: ${JSON.stringify(value)}`);
|
|
55
|
+
const roundTripped = parsed.toISOString();
|
|
56
|
+
if (roundTripped !== value)
|
|
57
|
+
throw new Error(`Timestamp not in canonical ISO-8601 UTC form (got ${JSON.stringify(value)}, expected ${JSON.stringify(roundTripped)})`);
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
function nowTimestamp() {
|
|
61
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
62
|
+
}
|
|
63
|
+
function assertSchemaVersion(version) {
|
|
64
|
+
if (version !== SCHEMA_VERSION)
|
|
65
|
+
throw new Error(`Unsupported schemaVersion: got ${version}, expected ${SCHEMA_VERSION}`);
|
|
66
|
+
}
|
|
67
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
68
|
+
0 && (module.exports = {
|
|
69
|
+
CRAWL_TTL_MS,
|
|
70
|
+
SCHEMA_VERSION,
|
|
71
|
+
assertSchemaVersion,
|
|
72
|
+
normalizeUrl,
|
|
73
|
+
nowTimestamp,
|
|
74
|
+
validateTimestamp
|
|
75
|
+
});
|