@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.
Files changed (60) hide show
  1. package/dist/adapter.d.ts +14 -1
  2. package/dist/adapter.d.ts.map +1 -1
  3. package/dist/api/client.d.ts +60 -1
  4. package/dist/api/client.d.ts.map +1 -1
  5. package/dist/api/client.js +21 -2
  6. package/dist/api/client.js.map +1 -1
  7. package/dist/crawler/link-extractor.d.ts +8 -0
  8. package/dist/crawler/link-extractor.d.ts.map +1 -0
  9. package/dist/crawler/link-extractor.js +37 -0
  10. package/dist/crawler/link-extractor.js.map +1 -0
  11. package/dist/crawler/url-normalizer.d.ts +4 -0
  12. package/dist/crawler/url-normalizer.d.ts.map +1 -0
  13. package/dist/crawler/url-normalizer.js +27 -0
  14. package/dist/crawler/url-normalizer.js.map +1 -0
  15. package/dist/expertise/accessibility-expertise.d.ts +11 -0
  16. package/dist/expertise/accessibility-expertise.d.ts.map +1 -0
  17. package/dist/expertise/accessibility-expertise.js +131 -0
  18. package/dist/expertise/accessibility-expertise.js.map +1 -0
  19. package/dist/expertise/content-expertise.d.ts +10 -0
  20. package/dist/expertise/content-expertise.d.ts.map +1 -0
  21. package/dist/expertise/content-expertise.js +102 -0
  22. package/dist/expertise/content-expertise.js.map +1 -0
  23. package/dist/expertise/index.d.ts +3 -0
  24. package/dist/expertise/index.d.ts.map +1 -1
  25. package/dist/expertise/index.js +9 -4
  26. package/dist/expertise/index.js.map +1 -1
  27. package/dist/expertise/ui-expertise.d.ts +10 -0
  28. package/dist/expertise/ui-expertise.d.ts.map +1 -0
  29. package/dist/expertise/ui-expertise.js +76 -0
  30. package/dist/expertise/ui-expertise.js.map +1 -0
  31. package/dist/index.d.ts +1 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/orchestrator/decomposition.d.ts.map +1 -1
  34. package/dist/orchestrator/decomposition.js +558 -65
  35. package/dist/orchestrator/decomposition.js.map +1 -1
  36. package/dist/orchestrator/direct-navigation.d.ts +4 -0
  37. package/dist/orchestrator/direct-navigation.d.ts.map +1 -0
  38. package/dist/orchestrator/direct-navigation.js +47 -0
  39. package/dist/orchestrator/direct-navigation.js.map +1 -0
  40. package/dist/orchestrator/discovery.d.ts +10 -0
  41. package/dist/orchestrator/discovery.d.ts.map +1 -0
  42. package/dist/orchestrator/discovery.js +77 -0
  43. package/dist/orchestrator/discovery.js.map +1 -0
  44. package/dist/orchestrator/expertise.d.ts +5 -0
  45. package/dist/orchestrator/expertise.d.ts.map +1 -0
  46. package/dist/orchestrator/expertise.js +168 -0
  47. package/dist/orchestrator/expertise.js.map +1 -0
  48. package/dist/orchestrator/orchestrator.d.ts.map +1 -1
  49. package/dist/orchestrator/orchestrator.js +63 -37
  50. package/dist/orchestrator/orchestrator.js.map +1 -1
  51. package/dist/orchestrator/page-capture.d.ts +22 -0
  52. package/dist/orchestrator/page-capture.d.ts.map +1 -0
  53. package/dist/orchestrator/page-capture.js +112 -0
  54. package/dist/orchestrator/page-capture.js.map +1 -0
  55. package/dist/orchestrator/test-execution.d.ts.map +1 -1
  56. package/dist/orchestrator/test-execution.js +18 -37
  57. package/dist/orchestrator/test-execution.js.map +1 -1
  58. package/dist/orchestrator/types.d.ts +10 -0
  59. package/dist/orchestrator/types.d.ts.map +1 -1
  60. package/package.json +3 -3
@@ -1,7 +1,423 @@
1
1
  import { extractActionableItems } from "../extractors";
2
- import { detectScaffoldRegions } from "../scanner/component-detector";
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
- // Create a test suite for this decomposition job
71
- const suite = await api.insertTestSuite(config.runnerId, {
72
- title: `Page State #${job.pageStateId}`,
73
- description: `Auto-generated test suite for page state ${job.pageStateId} on ${page.relativePath}`,
74
- startingPageStateId: job.pageStateId,
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
- for (let i = 0; i < maxItems; i++) {
87
- const item = interactiveItems[i];
88
- const actionKind = item.actionKind || "click";
89
- const label = item.accessibleName ||
90
- item.tagName ||
91
- item.selector?.slice(0, 30) ||
92
- "element";
93
- // Determine test action type
94
- let actionType;
95
- if (actionKind === "navigate" || actionKind === "click") {
96
- actionType = "click";
97
- }
98
- else if (actionKind === "fill") {
99
- actionType = "fill";
100
- }
101
- else if (actionKind === "select") {
102
- actionType = "select";
103
- }
104
- else {
105
- actionType = "click";
106
- }
107
- const steps = [
108
- {
109
- action: {
110
- actionType: PlaywrightAction.Click,
111
- pageStateId: pageState.id,
112
- path: item.selector,
113
- playwrightCode: `await page.click('${item.selector.replace(/'/g, "\\'")}');`,
114
- description: `${actionType} on ${label}`,
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: `${actionType}: ${label}`,
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 ${i + 1}/${maxItems}: "${testCase.title}" selector=${item.selector?.slice(0, 60)}`);
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
- // Create test actions for each step
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: index,
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
  }