@sudobility/testomniac_runner_service 0.1.36 → 0.1.38
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/dist/adapter.d.ts +14 -1
- package/dist/adapter.d.ts.map +1 -1
- package/dist/api/client.d.ts +60 -1
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +21 -2
- package/dist/api/client.js.map +1 -1
- package/dist/crawler/link-extractor.d.ts +8 -0
- package/dist/crawler/link-extractor.d.ts.map +1 -0
- package/dist/crawler/link-extractor.js +37 -0
- package/dist/crawler/link-extractor.js.map +1 -0
- package/dist/crawler/url-normalizer.d.ts +4 -0
- package/dist/crawler/url-normalizer.d.ts.map +1 -0
- package/dist/crawler/url-normalizer.js +27 -0
- package/dist/crawler/url-normalizer.js.map +1 -0
- package/dist/expertise/accessibility-expertise.d.ts +11 -0
- package/dist/expertise/accessibility-expertise.d.ts.map +1 -0
- package/dist/expertise/accessibility-expertise.js +131 -0
- package/dist/expertise/accessibility-expertise.js.map +1 -0
- package/dist/expertise/content-expertise.d.ts +10 -0
- package/dist/expertise/content-expertise.d.ts.map +1 -0
- package/dist/expertise/content-expertise.js +102 -0
- package/dist/expertise/content-expertise.js.map +1 -0
- package/dist/expertise/index.d.ts +3 -0
- package/dist/expertise/index.d.ts.map +1 -1
- package/dist/expertise/index.js +9 -4
- package/dist/expertise/index.js.map +1 -1
- package/dist/expertise/ui-expertise.d.ts +10 -0
- package/dist/expertise/ui-expertise.d.ts.map +1 -0
- package/dist/expertise/ui-expertise.js +76 -0
- package/dist/expertise/ui-expertise.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/orchestrator/decomposition.d.ts.map +1 -1
- package/dist/orchestrator/decomposition.js +558 -65
- package/dist/orchestrator/decomposition.js.map +1 -1
- package/dist/orchestrator/direct-navigation.d.ts +4 -0
- package/dist/orchestrator/direct-navigation.d.ts.map +1 -0
- package/dist/orchestrator/direct-navigation.js +47 -0
- package/dist/orchestrator/direct-navigation.js.map +1 -0
- package/dist/orchestrator/discovery.d.ts +10 -0
- package/dist/orchestrator/discovery.d.ts.map +1 -0
- package/dist/orchestrator/discovery.js +77 -0
- package/dist/orchestrator/discovery.js.map +1 -0
- package/dist/orchestrator/expertise.d.ts +5 -0
- package/dist/orchestrator/expertise.d.ts.map +1 -0
- package/dist/orchestrator/expertise.js +168 -0
- package/dist/orchestrator/expertise.js.map +1 -0
- package/dist/orchestrator/orchestrator.d.ts.map +1 -1
- package/dist/orchestrator/orchestrator.js +63 -37
- package/dist/orchestrator/orchestrator.js.map +1 -1
- package/dist/orchestrator/page-capture.d.ts +22 -0
- package/dist/orchestrator/page-capture.d.ts.map +1 -0
- package/dist/orchestrator/page-capture.js +112 -0
- package/dist/orchestrator/page-capture.js.map +1 -0
- package/dist/orchestrator/test-execution.d.ts.map +1 -1
- package/dist/orchestrator/test-execution.js +18 -37
- package/dist/orchestrator/test-execution.js.map +1 -1
- package/dist/orchestrator/types.d.ts +10 -0
- package/dist/orchestrator/types.d.ts.map +1 -1
- package/package.json +3 -3
|
@@ -1,7 +1,423 @@
|
|
|
1
1
|
import { extractActionableItems } from "../extractors";
|
|
2
|
-
import {
|
|
2
|
+
import { computeDecomposedHashes } from "../browser/page-utils";
|
|
3
|
+
import { detectScaffoldRegions, } from "../scanner/component-detector";
|
|
4
|
+
import { getBody, getContentBody, getFixedBody, } from "../scanner/html-decomposer";
|
|
5
|
+
import { detectPatternsWithInstances } from "../scanner/pattern-detector";
|
|
6
|
+
import { toRelativePath } from "../crawler/url-normalizer";
|
|
3
7
|
import { PlaywrightAction, ExpectationType, ExpectationSeverity, } from "../domain/types";
|
|
4
8
|
const LOG = (...args) => console.warn("[decomposition]", ...args);
|
|
9
|
+
const MAX_PAGE_INTERACTION_CASES = 24;
|
|
10
|
+
const MAX_REVERSIBLE_CHAINS = 4;
|
|
11
|
+
const MAX_TAB_CHAINS_PER_GROUP = 2;
|
|
12
|
+
function getStateTransitionPriority(item) {
|
|
13
|
+
const role = (item.role || "").toLowerCase();
|
|
14
|
+
const tagName = (item.tagName || "").toLowerCase();
|
|
15
|
+
const text = `${item.accessibleName || ""} ${item.textContent || ""}`.toLowerCase();
|
|
16
|
+
const attributes = item.attributes || {};
|
|
17
|
+
if (role === "tab" || attributes["aria-selected"] != null) {
|
|
18
|
+
return 100;
|
|
19
|
+
}
|
|
20
|
+
if (attributes["aria-expanded"] != null ||
|
|
21
|
+
attributes["aria-controls"] != null) {
|
|
22
|
+
return 95;
|
|
23
|
+
}
|
|
24
|
+
if (attributes["aria-haspopup"] === "menu" ||
|
|
25
|
+
text.includes("menu") ||
|
|
26
|
+
role === "menuitem") {
|
|
27
|
+
return 90;
|
|
28
|
+
}
|
|
29
|
+
if (tagName === "summary" ||
|
|
30
|
+
text.includes("accordion") ||
|
|
31
|
+
text.includes("expand") ||
|
|
32
|
+
text.includes("collapse")) {
|
|
33
|
+
return 85;
|
|
34
|
+
}
|
|
35
|
+
if (text.includes("open") ||
|
|
36
|
+
text.includes("details") ||
|
|
37
|
+
text.includes("show") ||
|
|
38
|
+
text.includes("more")) {
|
|
39
|
+
return 80;
|
|
40
|
+
}
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
function prioritizeItemsForExploration(items) {
|
|
44
|
+
return [...items].sort((left, right) => {
|
|
45
|
+
const delta = getStateTransitionPriority(right) - getStateTransitionPriority(left);
|
|
46
|
+
if (delta !== 0) {
|
|
47
|
+
return delta;
|
|
48
|
+
}
|
|
49
|
+
return (left.selector || "").localeCompare(right.selector || "");
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
function escapeSelector(selector) {
|
|
53
|
+
return selector.replace(/'/g, "\\'");
|
|
54
|
+
}
|
|
55
|
+
function buildExpectation() {
|
|
56
|
+
return {
|
|
57
|
+
expectationType: ExpectationType.NoConsoleErrors,
|
|
58
|
+
severity: ExpectationSeverity.ShouldPass,
|
|
59
|
+
description: "No console errors after interaction",
|
|
60
|
+
playwrightCode: "expect(consoleErrors).toHaveLength(0);",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function buildClickStep(item, pageStateId, description) {
|
|
64
|
+
return {
|
|
65
|
+
action: {
|
|
66
|
+
actionType: PlaywrightAction.Click,
|
|
67
|
+
pageStateId,
|
|
68
|
+
path: item.selector,
|
|
69
|
+
playwrightCode: `await page.click('${escapeSelector(item.selector)}');`,
|
|
70
|
+
description,
|
|
71
|
+
},
|
|
72
|
+
expectations: [buildExpectation()],
|
|
73
|
+
description,
|
|
74
|
+
continueOnFailure: false,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function isTabLike(item) {
|
|
78
|
+
const role = (item.role || "").toLowerCase();
|
|
79
|
+
const attributes = item.attributes || {};
|
|
80
|
+
return role === "tab" || attributes["aria-selected"] != null;
|
|
81
|
+
}
|
|
82
|
+
function isSelfReversibleStateControl(item) {
|
|
83
|
+
const tagName = (item.tagName || "").toLowerCase();
|
|
84
|
+
const text = `${item.accessibleName || ""} ${item.textContent || ""}`.toLowerCase();
|
|
85
|
+
const attributes = item.attributes || {};
|
|
86
|
+
return (attributes["aria-expanded"] != null ||
|
|
87
|
+
attributes["aria-haspopup"] === "menu" ||
|
|
88
|
+
attributes["aria-controls"] != null ||
|
|
89
|
+
tagName === "summary" ||
|
|
90
|
+
text.includes("open") ||
|
|
91
|
+
text.includes("close") ||
|
|
92
|
+
text.includes("details") ||
|
|
93
|
+
text.includes("show") ||
|
|
94
|
+
text.includes("more"));
|
|
95
|
+
}
|
|
96
|
+
function isExplicitCloseControl(item) {
|
|
97
|
+
const text = `${item.accessibleName || ""} ${item.textContent || ""}`.toLowerCase();
|
|
98
|
+
return (text.includes("close") ||
|
|
99
|
+
text.includes("dismiss") ||
|
|
100
|
+
text.includes("cancel") ||
|
|
101
|
+
text.includes("hide") ||
|
|
102
|
+
text.includes("done"));
|
|
103
|
+
}
|
|
104
|
+
async function resolveCloseControl(adapter, item, items) {
|
|
105
|
+
const controlledId = typeof item.attributes?.["aria-controls"] === "string"
|
|
106
|
+
? String(item.attributes["aria-controls"])
|
|
107
|
+
: null;
|
|
108
|
+
const closeCandidates = items.filter(candidate => candidate.selector &&
|
|
109
|
+
candidate.selector !== item.selector &&
|
|
110
|
+
candidate.visible &&
|
|
111
|
+
!candidate.disabled &&
|
|
112
|
+
isExplicitCloseControl(candidate));
|
|
113
|
+
if (closeCandidates.length === 0 || !controlledId) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
const selectors = closeCandidates.map(candidate => candidate.selector);
|
|
117
|
+
const matchingSelector = await adapter.evaluate((...args) => {
|
|
118
|
+
const triggerSelector = args[0];
|
|
119
|
+
const targetId = args[1];
|
|
120
|
+
const candidateSelectors = args[2];
|
|
121
|
+
try {
|
|
122
|
+
const trigger = document.querySelector(triggerSelector);
|
|
123
|
+
const controlled = document.getElementById(targetId) ||
|
|
124
|
+
document.querySelector(`[aria-labelledby="${targetId}"], [data-panel="${targetId}"]`);
|
|
125
|
+
if (!trigger || !controlled)
|
|
126
|
+
return null;
|
|
127
|
+
for (const selector of candidateSelectors) {
|
|
128
|
+
const candidate = document.querySelector(selector);
|
|
129
|
+
if (candidate &&
|
|
130
|
+
(controlled.contains(candidate) ||
|
|
131
|
+
candidate.closest(`[id="${targetId}"]`))) {
|
|
132
|
+
return selector;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}, item.selector, controlledId, selectors);
|
|
141
|
+
if (!matchingSelector) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
return (closeCandidates.find(candidate => candidate.selector === matchingSelector) ?? null);
|
|
145
|
+
}
|
|
146
|
+
async function buildStateTransitionDefinitions(adapter, items, pageStateId) {
|
|
147
|
+
const definitions = [];
|
|
148
|
+
const seenTitles = new Set();
|
|
149
|
+
const reversibleItems = items
|
|
150
|
+
.filter(item => isSelfReversibleStateControl(item) && !isExplicitCloseControl(item))
|
|
151
|
+
.slice(0, MAX_REVERSIBLE_CHAINS);
|
|
152
|
+
for (const item of reversibleItems) {
|
|
153
|
+
const label = item.accessibleName ||
|
|
154
|
+
item.textContent ||
|
|
155
|
+
item.tagName ||
|
|
156
|
+
item.selector?.slice(0, 30) ||
|
|
157
|
+
"element";
|
|
158
|
+
const title = `state chain: ${label}`;
|
|
159
|
+
if (seenTitles.has(title))
|
|
160
|
+
continue;
|
|
161
|
+
seenTitles.add(title);
|
|
162
|
+
const explicitCloseControl = await resolveCloseControl(adapter, item, items);
|
|
163
|
+
definitions.push({
|
|
164
|
+
title,
|
|
165
|
+
actionType: "state_chain",
|
|
166
|
+
item,
|
|
167
|
+
steps: [
|
|
168
|
+
buildClickStep(item, pageStateId, `open ${label}`),
|
|
169
|
+
buildClickStep(explicitCloseControl ?? item, pageStateId, `close ${label}`),
|
|
170
|
+
],
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
const tabItems = items.filter(isTabLike);
|
|
174
|
+
const tabGroups = await resolveTabGroups(adapter, tabItems);
|
|
175
|
+
for (const group of tabGroups) {
|
|
176
|
+
for (let index = 0; index < Math.min(group.length - 1, MAX_TAB_CHAINS_PER_GROUP); index += 1) {
|
|
177
|
+
const first = group[index];
|
|
178
|
+
const second = group[index + 1];
|
|
179
|
+
const firstLabel = first.accessibleName || first.textContent || first.selector || "tab";
|
|
180
|
+
const secondLabel = second.accessibleName || second.textContent || second.selector || "tab";
|
|
181
|
+
const title = `tab chain: ${firstLabel} -> ${secondLabel}`;
|
|
182
|
+
if (seenTitles.has(title))
|
|
183
|
+
continue;
|
|
184
|
+
seenTitles.add(title);
|
|
185
|
+
definitions.push({
|
|
186
|
+
title,
|
|
187
|
+
actionType: "tab_chain",
|
|
188
|
+
item: first,
|
|
189
|
+
steps: [
|
|
190
|
+
buildClickStep(first, pageStateId, `activate ${firstLabel}`),
|
|
191
|
+
buildClickStep(second, pageStateId, `activate ${secondLabel}`),
|
|
192
|
+
],
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return definitions;
|
|
197
|
+
}
|
|
198
|
+
async function resolveTabGroups(adapter, items) {
|
|
199
|
+
if (items.length < 2) {
|
|
200
|
+
return items.length > 0 ? [items] : [];
|
|
201
|
+
}
|
|
202
|
+
const metadata = await adapter.evaluate((...args) => {
|
|
203
|
+
const selectors = args[0];
|
|
204
|
+
return selectors.map((selector, index) => {
|
|
205
|
+
try {
|
|
206
|
+
const el = document.querySelector(selector);
|
|
207
|
+
if (!el) {
|
|
208
|
+
return { selector, groupKey: "__missing__", order: index };
|
|
209
|
+
}
|
|
210
|
+
const container = el.closest('[role="tablist"]') ||
|
|
211
|
+
el.closest("[data-tabs]") ||
|
|
212
|
+
el.closest(".tabs") ||
|
|
213
|
+
el.closest('[role="toolbar"]') ||
|
|
214
|
+
el.parentElement;
|
|
215
|
+
const groupKey = container?.getAttribute("id") ||
|
|
216
|
+
container?.getAttribute("aria-label") ||
|
|
217
|
+
container?.getAttribute("data-tabs") ||
|
|
218
|
+
container?.tagName ||
|
|
219
|
+
"__ungrouped__";
|
|
220
|
+
const siblings = container
|
|
221
|
+
? Array.from(container.querySelectorAll('[role="tab"], [aria-selected], button, a'))
|
|
222
|
+
: [];
|
|
223
|
+
const order = siblings.findIndex(candidate => candidate === el);
|
|
224
|
+
return {
|
|
225
|
+
selector,
|
|
226
|
+
groupKey,
|
|
227
|
+
order: order >= 0 ? order : index,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return { selector, groupKey: "__error__", order: index };
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
}, items.map(item => item.selector));
|
|
235
|
+
const itemBySelector = new Map(items.map(item => [item.selector, item]));
|
|
236
|
+
const groups = new Map();
|
|
237
|
+
for (const entry of metadata) {
|
|
238
|
+
const item = itemBySelector.get(entry.selector);
|
|
239
|
+
if (!item)
|
|
240
|
+
continue;
|
|
241
|
+
const bucket = groups.get(entry.groupKey) ?? [];
|
|
242
|
+
bucket.push({ item, order: entry.order });
|
|
243
|
+
groups.set(entry.groupKey, bucket);
|
|
244
|
+
}
|
|
245
|
+
return Array.from(groups.values())
|
|
246
|
+
.map(group => group
|
|
247
|
+
.sort((left, right) => left.order - right.order)
|
|
248
|
+
.map(entry => entry.item))
|
|
249
|
+
.filter(group => group.length >= 2);
|
|
250
|
+
}
|
|
251
|
+
function dedupeDefinitions(definitions) {
|
|
252
|
+
const seen = new Set();
|
|
253
|
+
const deduped = [];
|
|
254
|
+
for (const definition of definitions) {
|
|
255
|
+
if (seen.has(definition.title))
|
|
256
|
+
continue;
|
|
257
|
+
seen.add(definition.title);
|
|
258
|
+
deduped.push(definition);
|
|
259
|
+
}
|
|
260
|
+
return deduped;
|
|
261
|
+
}
|
|
262
|
+
function inferFillValue(item) {
|
|
263
|
+
const inputType = (item.inputType || "").toLowerCase();
|
|
264
|
+
const label = `${item.accessibleName || ""} ${item.textContent || ""}`.toLowerCase();
|
|
265
|
+
if (inputType === "email" || label.includes("email")) {
|
|
266
|
+
return "testomniac@example.com";
|
|
267
|
+
}
|
|
268
|
+
if (inputType === "tel" || label.includes("phone")) {
|
|
269
|
+
return "4155550100";
|
|
270
|
+
}
|
|
271
|
+
if (inputType === "number") {
|
|
272
|
+
return "1";
|
|
273
|
+
}
|
|
274
|
+
if (inputType === "search" || label.includes("search")) {
|
|
275
|
+
return "test";
|
|
276
|
+
}
|
|
277
|
+
if (inputType === "url" ||
|
|
278
|
+
label.includes("url") ||
|
|
279
|
+
label.includes("website")) {
|
|
280
|
+
return "https://example.com";
|
|
281
|
+
}
|
|
282
|
+
if (inputType === "password") {
|
|
283
|
+
return "Testomniac123!";
|
|
284
|
+
}
|
|
285
|
+
return "Testomniac";
|
|
286
|
+
}
|
|
287
|
+
async function resolveSelectValue(adapter, selector) {
|
|
288
|
+
return adapter.evaluate((...args) => {
|
|
289
|
+
const targetSelector = args[0];
|
|
290
|
+
try {
|
|
291
|
+
const select = document.querySelector(targetSelector);
|
|
292
|
+
if (!(select instanceof HTMLSelectElement)) {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
const options = Array.from(select.options).filter(option => !option.disabled);
|
|
296
|
+
const preferred = options.find(option => option.value && option.value !== select.value) ||
|
|
297
|
+
options.find(option => option.value) ||
|
|
298
|
+
options.find(option => option.textContent?.trim());
|
|
299
|
+
return preferred?.value || null;
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
}, selector);
|
|
305
|
+
}
|
|
306
|
+
async function buildGeneratedTestCase(adapter, item, pageStateId) {
|
|
307
|
+
const actionKind = item.actionKind || "click";
|
|
308
|
+
const label = item.accessibleName ||
|
|
309
|
+
item.tagName ||
|
|
310
|
+
item.selector?.slice(0, 30) ||
|
|
311
|
+
"element";
|
|
312
|
+
let actionType;
|
|
313
|
+
let playwrightAction;
|
|
314
|
+
let playwrightCode;
|
|
315
|
+
let value;
|
|
316
|
+
if (actionKind === "navigate" || actionKind === "click") {
|
|
317
|
+
actionType = "click";
|
|
318
|
+
playwrightAction = PlaywrightAction.Click;
|
|
319
|
+
playwrightCode = `await page.click('${escapeSelector(item.selector)}');`;
|
|
320
|
+
}
|
|
321
|
+
else if (actionKind === "fill") {
|
|
322
|
+
actionType = "fill";
|
|
323
|
+
playwrightAction = PlaywrightAction.Fill;
|
|
324
|
+
value = inferFillValue(item);
|
|
325
|
+
playwrightCode = `await page.fill('${escapeSelector(item.selector)}', '${value.replace(/'/g, "\\'")}');`;
|
|
326
|
+
}
|
|
327
|
+
else if (actionKind === "select") {
|
|
328
|
+
actionType = "select";
|
|
329
|
+
playwrightAction = PlaywrightAction.SelectOption;
|
|
330
|
+
value = (await resolveSelectValue(adapter, item.selector)) ?? undefined;
|
|
331
|
+
if (!value) {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
playwrightCode = `await page.selectOption('${escapeSelector(item.selector)}', '${value.replace(/'/g, "\\'")}');`;
|
|
335
|
+
}
|
|
336
|
+
else if (actionKind === "radio_select") {
|
|
337
|
+
actionType = "radio_select";
|
|
338
|
+
playwrightAction = PlaywrightAction.Click;
|
|
339
|
+
playwrightCode = `await page.click('${escapeSelector(item.selector)}');`;
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
actionType = "click";
|
|
343
|
+
playwrightAction = PlaywrightAction.Click;
|
|
344
|
+
playwrightCode = `await page.click('${escapeSelector(item.selector)}');`;
|
|
345
|
+
}
|
|
346
|
+
const steps = [
|
|
347
|
+
{
|
|
348
|
+
action: {
|
|
349
|
+
actionType: playwrightAction,
|
|
350
|
+
pageStateId,
|
|
351
|
+
path: item.selector,
|
|
352
|
+
value,
|
|
353
|
+
playwrightCode,
|
|
354
|
+
description: `${actionType} on ${label}`,
|
|
355
|
+
},
|
|
356
|
+
expectations: [buildExpectation()],
|
|
357
|
+
description: `${actionType} on ${label}`,
|
|
358
|
+
continueOnFailure: false,
|
|
359
|
+
},
|
|
360
|
+
];
|
|
361
|
+
return {
|
|
362
|
+
title: `${actionType}: ${label}`,
|
|
363
|
+
actionType,
|
|
364
|
+
item,
|
|
365
|
+
steps,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
async function classifyItemsByScaffold(adapter, items, scaffolds) {
|
|
369
|
+
if (items.length === 0 || scaffolds.length === 0) {
|
|
370
|
+
return { pageItems: items, scaffoldItems: new Map() };
|
|
371
|
+
}
|
|
372
|
+
const assignments = await adapter.evaluate((...args) => {
|
|
373
|
+
const selectors = args[0];
|
|
374
|
+
const scaffoldSelectors = args[1];
|
|
375
|
+
return selectors.map(selector => {
|
|
376
|
+
try {
|
|
377
|
+
const el = document.querySelector(selector);
|
|
378
|
+
if (!el)
|
|
379
|
+
return null;
|
|
380
|
+
for (const scaffold of scaffoldSelectors) {
|
|
381
|
+
try {
|
|
382
|
+
const scaffoldEl = document.querySelector(scaffold.selector);
|
|
383
|
+
if (scaffoldEl &&
|
|
384
|
+
(el === scaffoldEl || scaffoldEl.contains(el))) {
|
|
385
|
+
return scaffold.scaffoldId;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
// Ignore invalid scaffold selector.
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
// Ignore invalid item selector.
|
|
395
|
+
}
|
|
396
|
+
return null;
|
|
397
|
+
});
|
|
398
|
+
}, items.map(item => item.selector), scaffolds.map(scaffold => ({
|
|
399
|
+
scaffoldId: scaffold.scaffoldId,
|
|
400
|
+
selector: scaffold.selector,
|
|
401
|
+
})));
|
|
402
|
+
const scaffoldItems = new Map();
|
|
403
|
+
const pageItems = [];
|
|
404
|
+
for (const [index, item] of items.entries()) {
|
|
405
|
+
const scaffoldId = assignments[index];
|
|
406
|
+
if (typeof scaffoldId === "number") {
|
|
407
|
+
const existing = scaffoldItems.get(scaffoldId) ?? [];
|
|
408
|
+
existing.push(item);
|
|
409
|
+
scaffoldItems.set(scaffoldId, existing);
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
pageItems.push(item);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return { pageItems, scaffoldItems };
|
|
416
|
+
}
|
|
417
|
+
function findExistingSuiteId(suites, scaffoldId) {
|
|
418
|
+
const suite = suites.find(item => item.scaffoldId === scaffoldId);
|
|
419
|
+
return suite?.id ?? null;
|
|
420
|
+
}
|
|
5
421
|
/**
|
|
6
422
|
* Process a decomposition job: extract actionable items from the live page
|
|
7
423
|
* and generate click/hover test cases. Returns created test case IDs.
|
|
@@ -18,7 +434,14 @@ export async function processDecompositionJob(job, adapter, config, api, events)
|
|
|
18
434
|
throw new Error(`Page ${pageState.pageId} not found`);
|
|
19
435
|
}
|
|
20
436
|
LOG(`Page found: relativePath=${page.relativePath}`);
|
|
437
|
+
const targetUrl = new URL(page.relativePath, config.baseUrl).toString();
|
|
438
|
+
const currentUrl = await adapter.getUrl();
|
|
439
|
+
if (toRelativePath(currentUrl) !== toRelativePath(targetUrl)) {
|
|
440
|
+
LOG(`Navigating to ${targetUrl} for decomposition job ${job.id}`);
|
|
441
|
+
await adapter.goto(targetUrl, { waitUntil: "networkidle0" });
|
|
442
|
+
}
|
|
21
443
|
// Extract actionable items from the live page
|
|
444
|
+
const currentHtml = await adapter.content();
|
|
22
445
|
const items = await extractActionableItems(adapter);
|
|
23
446
|
LOG(`Extracted ${items.length} actionable items:`, items.map(i => ({
|
|
24
447
|
selector: i.selector?.slice(0, 60),
|
|
@@ -33,6 +456,7 @@ export async function processDecompositionJob(job, adapter, config, api, events)
|
|
|
33
456
|
const scaffolds = await detectScaffoldRegions(adapter);
|
|
34
457
|
LOG(`Detected ${scaffolds.length} scaffolds:`, scaffolds.map(s => ({ type: s.type, selector: s.selector })));
|
|
35
458
|
// Persist scaffolds and link to page state
|
|
459
|
+
const persistedScaffolds = [];
|
|
36
460
|
if (scaffolds.length > 0) {
|
|
37
461
|
const scaffoldIds = [];
|
|
38
462
|
for (const scaffold of scaffolds) {
|
|
@@ -44,6 +468,7 @@ export async function processDecompositionJob(job, adapter, config, api, events)
|
|
|
44
468
|
html: scaffold.outerHtml,
|
|
45
469
|
});
|
|
46
470
|
scaffoldIds.push(saved.id);
|
|
471
|
+
persistedScaffolds.push({ ...scaffold, scaffoldId: saved.id });
|
|
47
472
|
LOG(`Scaffold saved: type=${scaffold.type} id=${saved.id}`);
|
|
48
473
|
}
|
|
49
474
|
catch (err) {
|
|
@@ -60,6 +485,23 @@ export async function processDecompositionJob(job, adapter, config, api, events)
|
|
|
60
485
|
}
|
|
61
486
|
}
|
|
62
487
|
}
|
|
488
|
+
const patterns = await detectPatternsWithInstances(adapter);
|
|
489
|
+
const bodyHtml = getBody(currentHtml);
|
|
490
|
+
const { contentBody } = getContentBody(bodyHtml, scaffolds);
|
|
491
|
+
const patternInstances = patterns.flatMap(pattern => pattern.instances);
|
|
492
|
+
const { fixedBody } = getFixedBody(contentBody, patternInstances);
|
|
493
|
+
const decomposedHashes = await computeDecomposedHashes(fixedBody, scaffolds, patterns);
|
|
494
|
+
const existingDecomposedState = await api.findMatchingPageStateDecomposed(page.id, decomposedHashes, config.sizeClass);
|
|
495
|
+
await api.updatePageStateDecomposedHashes(pageState.id, decomposedHashes);
|
|
496
|
+
await api.insertPageStatePatterns(pageState.id, patterns.map(pattern => ({
|
|
497
|
+
type: pattern.type,
|
|
498
|
+
selector: pattern.selector,
|
|
499
|
+
count: pattern.count,
|
|
500
|
+
})));
|
|
501
|
+
if (existingDecomposedState && existingDecomposedState.id !== pageState.id) {
|
|
502
|
+
LOG(`Skipping decomposition for pageState ${pageState.id}; matched existing decomposed state ${existingDecomposedState.id}`);
|
|
503
|
+
return [];
|
|
504
|
+
}
|
|
63
505
|
// Filter to visible, enabled, interactive items
|
|
64
506
|
const interactiveItems = items.filter(i => i.visible && !i.disabled && i.selector);
|
|
65
507
|
LOG(`${interactiveItems.length} items after filtering (visible, enabled, has selector)`);
|
|
@@ -67,84 +509,62 @@ export async function processDecompositionJob(job, adapter, config, api, events)
|
|
|
67
509
|
LOG("No interactive items found — skipping decomposition");
|
|
68
510
|
return [];
|
|
69
511
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
startingPath: page.relativePath,
|
|
76
|
-
sizeClass: config.sizeClass,
|
|
77
|
-
priority: 3,
|
|
78
|
-
suite_tags: ["auto-generated"],
|
|
79
|
-
decompositionJobId: job.id,
|
|
80
|
-
});
|
|
81
|
-
LOG(`Created test suite: id=${suite.id}, title=${suite.title}`);
|
|
82
|
-
events.onTestSuiteCreated({ suiteId: suite.id, title: suite.title });
|
|
512
|
+
const { pageItems, scaffoldItems } = await classifyItemsByScaffold(adapter, interactiveItems, persistedScaffolds);
|
|
513
|
+
LOG(`Classified ${pageItems.length} page items and ${Array.from(scaffoldItems.values()).reduce((count, value) => count + value.length, 0)} scaffold items`);
|
|
514
|
+
const existingSuites = await api.getTestSuitesByRunner(config.runnerId);
|
|
515
|
+
const existingCases = await api.getTestCasesByRunner(config.runnerId);
|
|
516
|
+
let pageSuiteId = null;
|
|
83
517
|
// Generate one test case per actionable item (cap at 20 to avoid explosion)
|
|
84
|
-
const maxItems = Math.min(interactiveItems.length, 20);
|
|
85
518
|
const createdIds = [];
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
expectations: [
|
|
117
|
-
{
|
|
118
|
-
expectationType: ExpectationType.NoConsoleErrors,
|
|
119
|
-
severity: ExpectationSeverity.ShouldPass,
|
|
120
|
-
description: "No console errors after interaction",
|
|
121
|
-
playwrightCode: "expect(consoleErrors).toHaveLength(0);",
|
|
122
|
-
},
|
|
123
|
-
],
|
|
124
|
-
description: `${actionType} on ${label}`,
|
|
125
|
-
continueOnFailure: false,
|
|
126
|
-
},
|
|
127
|
-
];
|
|
519
|
+
const prioritizedPageItems = prioritizeItemsForExploration(pageItems);
|
|
520
|
+
const pageChainDefinitions = await buildStateTransitionDefinitions(adapter, prioritizedPageItems, pageState.id);
|
|
521
|
+
const pageSingleDefinitions = (await Promise.all(prioritizedPageItems
|
|
522
|
+
.slice(0, MAX_PAGE_INTERACTION_CASES)
|
|
523
|
+
.map(item => buildGeneratedTestCase(adapter, item, pageState.id)))).filter((definition) => Boolean(definition));
|
|
524
|
+
const pageDefinitions = dedupeDefinitions([
|
|
525
|
+
...pageChainDefinitions,
|
|
526
|
+
...pageSingleDefinitions,
|
|
527
|
+
]).slice(0, MAX_PAGE_INTERACTION_CASES);
|
|
528
|
+
if (pageDefinitions.length > 0) {
|
|
529
|
+
const pageSuite = await api.insertTestSuite(config.runnerId, {
|
|
530
|
+
title: `Page State #${job.pageStateId}`,
|
|
531
|
+
description: `Auto-generated test suite for page state ${job.pageStateId} on ${page.relativePath}`,
|
|
532
|
+
startingPageStateId: job.pageStateId,
|
|
533
|
+
startingPath: page.relativePath,
|
|
534
|
+
sizeClass: config.sizeClass,
|
|
535
|
+
priority: 3,
|
|
536
|
+
suite_tags: ["auto-generated", "page-interactions"],
|
|
537
|
+
decompositionJobId: job.id,
|
|
538
|
+
});
|
|
539
|
+
pageSuiteId = pageSuite.id;
|
|
540
|
+
LOG(`Created test suite: id=${pageSuite.id}, title=${pageSuite.title}`);
|
|
541
|
+
events.onTestSuiteCreated({
|
|
542
|
+
suiteId: pageSuite.id,
|
|
543
|
+
title: pageSuite.title,
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
for (const [index, definition] of pageDefinitions.entries()) {
|
|
547
|
+
if (!pageSuiteId)
|
|
548
|
+
break;
|
|
128
549
|
const testCase = {
|
|
129
|
-
title:
|
|
550
|
+
title: definition.title,
|
|
130
551
|
type: "interaction",
|
|
131
552
|
sizeClass: config.sizeClass,
|
|
132
|
-
suite_tags: ["auto-generated", "mouse-scanning"],
|
|
553
|
+
suite_tags: ["auto-generated", "mouse-scanning", "page-interactions"],
|
|
133
554
|
page_id: page.id,
|
|
134
555
|
priority: 3,
|
|
135
556
|
startingPageStateId: pageState.id,
|
|
136
557
|
startingPath: page.relativePath,
|
|
137
|
-
steps,
|
|
558
|
+
steps: definition.steps,
|
|
138
559
|
globalExpectations: [],
|
|
139
560
|
};
|
|
140
|
-
LOG(`Creating test case ${
|
|
141
|
-
const tc = await api.insertTestCase(config.runnerId, testCase);
|
|
561
|
+
LOG(`Creating page test case ${index + 1}/${pageDefinitions.length}: "${testCase.title}" selector=${definition.item.selector?.slice(0, 60)}`);
|
|
562
|
+
const tc = await api.insertTestCase(config.runnerId, testCase, pageSuiteId);
|
|
142
563
|
createdIds.push(tc.id);
|
|
143
|
-
|
|
144
|
-
for (const [index, step] of steps.entries()) {
|
|
564
|
+
for (const [stepIndex, step] of definition.steps.entries()) {
|
|
145
565
|
await api.createTestAction({
|
|
146
566
|
testCaseId: tc.id,
|
|
147
|
-
stepOrder:
|
|
567
|
+
stepOrder: stepIndex,
|
|
148
568
|
actionType: step.action.actionType,
|
|
149
569
|
pageStateId: step.action.pageStateId,
|
|
150
570
|
elementIdentityId: step.action.elementIdentityId,
|
|
@@ -159,6 +579,79 @@ export async function processDecompositionJob(job, adapter, config, api, events)
|
|
|
159
579
|
});
|
|
160
580
|
}
|
|
161
581
|
}
|
|
582
|
+
for (const scaffold of persistedScaffolds) {
|
|
583
|
+
const itemsForScaffold = scaffoldItems.get(scaffold.scaffoldId) ?? [];
|
|
584
|
+
if (itemsForScaffold.length === 0) {
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
let scaffoldSuiteId = findExistingSuiteId(existingSuites, scaffold.scaffoldId);
|
|
588
|
+
if (!scaffoldSuiteId) {
|
|
589
|
+
const suite = await api.insertTestSuite(config.runnerId, {
|
|
590
|
+
title: `Shared Scaffold: ${scaffold.type}`,
|
|
591
|
+
description: `Auto-generated shared scaffold suite for ${scaffold.type}`,
|
|
592
|
+
startingPageStateId: job.pageStateId,
|
|
593
|
+
startingPath: page.relativePath,
|
|
594
|
+
sizeClass: config.sizeClass,
|
|
595
|
+
scaffoldId: scaffold.scaffoldId,
|
|
596
|
+
scaffoldType: scaffold.type,
|
|
597
|
+
priority: 2,
|
|
598
|
+
suite_tags: ["auto-generated", "shared-scaffold"],
|
|
599
|
+
});
|
|
600
|
+
scaffoldSuiteId = suite.id;
|
|
601
|
+
existingSuites.push(suite);
|
|
602
|
+
LOG(`Created scaffold suite ${suite.id} for scaffold ${scaffold.scaffoldId}`);
|
|
603
|
+
events.onTestSuiteCreated({ suiteId: suite.id, title: suite.title });
|
|
604
|
+
}
|
|
605
|
+
const prioritizedScaffoldItems = prioritizeItemsForExploration(itemsForScaffold);
|
|
606
|
+
const scaffoldChainDefinitions = await buildStateTransitionDefinitions(adapter, prioritizedScaffoldItems, pageState.id);
|
|
607
|
+
const scaffoldSingleDefinitions = (await Promise.all(prioritizedScaffoldItems
|
|
608
|
+
.slice(0, MAX_PAGE_INTERACTION_CASES)
|
|
609
|
+
.map(item => buildGeneratedTestCase(adapter, item, pageState.id)))).filter((definition) => Boolean(definition));
|
|
610
|
+
const scaffoldDefinitions = dedupeDefinitions([
|
|
611
|
+
...scaffoldChainDefinitions,
|
|
612
|
+
...scaffoldSingleDefinitions,
|
|
613
|
+
]).slice(0, MAX_PAGE_INTERACTION_CASES);
|
|
614
|
+
for (const definition of scaffoldDefinitions) {
|
|
615
|
+
const duplicate = existingCases.find(testCase => testCase.scaffoldId === scaffold.scaffoldId &&
|
|
616
|
+
testCase.title === definition.title);
|
|
617
|
+
if (duplicate) {
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
const testCase = {
|
|
621
|
+
title: definition.title,
|
|
622
|
+
type: "interaction",
|
|
623
|
+
sizeClass: config.sizeClass,
|
|
624
|
+
suite_tags: ["auto-generated", "mouse-scanning", "shared-scaffold"],
|
|
625
|
+
page_id: page.id,
|
|
626
|
+
scaffoldId: scaffold.scaffoldId,
|
|
627
|
+
priority: 2,
|
|
628
|
+
startingPageStateId: pageState.id,
|
|
629
|
+
startingPath: page.relativePath,
|
|
630
|
+
steps: definition.steps,
|
|
631
|
+
globalExpectations: [],
|
|
632
|
+
};
|
|
633
|
+
const tc = await api.insertTestCase(config.runnerId, testCase, scaffoldSuiteId);
|
|
634
|
+
existingCases.push(tc);
|
|
635
|
+
createdIds.push(tc.id);
|
|
636
|
+
for (const [stepIndex, step] of definition.steps.entries()) {
|
|
637
|
+
await api.createTestAction({
|
|
638
|
+
testCaseId: tc.id,
|
|
639
|
+
stepOrder: stepIndex,
|
|
640
|
+
actionType: step.action.actionType,
|
|
641
|
+
pageStateId: step.action.pageStateId,
|
|
642
|
+
elementIdentityId: step.action.elementIdentityId,
|
|
643
|
+
containerType: step.action.containerType,
|
|
644
|
+
containerElementIdentityId: step.action.containerElementIdentityId,
|
|
645
|
+
value: step.action.value,
|
|
646
|
+
path: step.action.path,
|
|
647
|
+
playwrightCode: step.action.playwrightCode,
|
|
648
|
+
description: step.description,
|
|
649
|
+
expectations: step.expectations,
|
|
650
|
+
continueOnFailure: step.continueOnFailure,
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
162
655
|
LOG(`Decomposition complete: created ${createdIds.length} test cases`);
|
|
163
656
|
return createdIds;
|
|
164
657
|
}
|