@molecule/api-ai-tools 1.10.10 → 1.11.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/README.md +171 -1
- package/dist/command-position.d.ts +37 -0
- package/dist/command-position.d.ts.map +1 -1
- package/dist/command-position.js +164 -5
- package/dist/cut-run.d.ts.map +1 -1
- package/dist/cut-run.js +10 -6
- package/dist/trace-artifacts.d.ts +103 -5
- package/dist/trace-artifacts.d.ts.map +1 -1
- package/dist/trace-artifacts.js +700 -37
- package/dist/utilities.d.ts +9 -0
- package/dist/utilities.d.ts.map +1 -1
- package/dist/utilities.js +115 -17
- package/package.json +1 -1
package/dist/trace-artifacts.js
CHANGED
|
@@ -18,11 +18,14 @@
|
|
|
18
18
|
* 401`. So the platform reads it (bounded, per test) and puts it in the run's result
|
|
19
19
|
* ({@link failureTraceNote}), instead of leaving the executor to build its own browser to find out.
|
|
20
20
|
*
|
|
21
|
-
* Everything here is text in, text out: the sandbox runs {@link traceArtifactsScript} (Node
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
21
|
+
* Everything here is text in, text out: the sandbox runs {@link traceArtifactsScript} (plain Node —
|
|
22
|
+
* the zip is read without a dependency) and prints one `TRACE <json>` line per trace; the platform
|
|
23
|
+
* parses those lines ({@link parseTraceArtifacts}) and writes the note ({@link failureTraceNote}).
|
|
24
|
+
* The one exception is the selector replay behind a `locator.count()` reading: it resolves the
|
|
25
|
+
* project's own installed Playwright, replays the recorded selector against the recorded snapshot
|
|
26
|
+
* in that browser's DOM, and reports `browser-unavailable` (naming no elements) when it cannot.
|
|
27
|
+
* Both halves are covered by a test that runs the script against a `trace.zip` produced by a real
|
|
28
|
+
* Playwright run, in the sandbox's own shape.
|
|
26
29
|
*
|
|
27
30
|
* @module
|
|
28
31
|
*/
|
|
@@ -50,6 +53,12 @@ export const MAX_TRACED_BODY_CHARS = 300;
|
|
|
50
53
|
const MAX_TRACED_WEBSOCKET_CHARS = 600;
|
|
51
54
|
/** How many tests the note in a run's result names. */
|
|
52
55
|
export const MAX_TRACE_NOTE_TESTS = 5;
|
|
56
|
+
/** How many elements a count mismatch's row names, in document order. */
|
|
57
|
+
const MAX_COUNT_MATCHES = 6;
|
|
58
|
+
/** How much of one matched element's text the trace read keeps. */
|
|
59
|
+
const MAX_COUNT_TEXT_CHARS = 90;
|
|
60
|
+
/** How deep one matched element's ancestor chain is named (it and up to three ancestors). */
|
|
61
|
+
const MAX_COUNT_PATH_DEPTH = 4;
|
|
53
62
|
/** How many requests the note names per test. */
|
|
54
63
|
export const MAX_TRACE_NOTE_REQUESTS = 4;
|
|
55
64
|
/**
|
|
@@ -123,6 +132,16 @@ const MAX_ACTIONS = ${MAX_TRACED_ACTIONS}
|
|
|
123
132
|
const MAX_BODY = ${MAX_TRACED_BODY_CHARS}
|
|
124
133
|
const MAX_WS = ${MAX_TRACED_WEBSOCKET_CHARS}
|
|
125
134
|
const LOOP_REPEATS = ${TRACE_NOTE_LOOP_REPEATS}
|
|
135
|
+
const MAX_COUNT_MATCHES = ${MAX_COUNT_MATCHES}
|
|
136
|
+
const MAX_COUNT_TEXT = ${MAX_COUNT_TEXT_CHARS}
|
|
137
|
+
const MAX_COUNT_PATH_DEPTH = ${MAX_COUNT_PATH_DEPTH}
|
|
138
|
+
// The serialized failure snapshot is capped: a page whose DOM exceeds this is not replayed, and the
|
|
139
|
+
// reading reports no readable snapshot instead of a partial page.
|
|
140
|
+
const MAX_SNAPSHOT_HTML = 4000000
|
|
141
|
+
// A locator.count() older than this before the failing assertion is not paired with it: the value
|
|
142
|
+
// match in countMismatchOf() is the strong evidence, this only bounds a trace whose count recorded
|
|
143
|
+
// no result.
|
|
144
|
+
const COUNT_LOOKBACK_MS = 180000
|
|
126
145
|
const MAX_URL = 200
|
|
127
146
|
// A console line that is not an error but still has to reach the note (m148): one a molecule
|
|
128
147
|
// package wrote — it names the package, the \`[molecule/…]\` prefix, or the \`[scope]\` prefix
|
|
@@ -408,6 +427,438 @@ function hiddenBoxOf(message, frames) {
|
|
|
408
427
|
return reading
|
|
409
428
|
}
|
|
410
429
|
|
|
430
|
+
// --- count assertions (xr-molecule-flash-m321) -------------------------------
|
|
431
|
+
// A count assertion reports only numbers ("Expected: 3 / Received: 4"). The trace holds the rest:
|
|
432
|
+
// \`locator.count()\` is a Frame.queryCount entry carrying params.selector and the result value,
|
|
433
|
+
// \`expect(locator).toHaveCount(n)\` is a Frame.expect entry carrying the selector and the expected
|
|
434
|
+
// number, and the failure snapshot is the page's DOM at that moment. Two things name the matched
|
|
435
|
+
// elements: the \`__playwright_target__\` marks Playwright leaves on an assertion's own matches, and
|
|
436
|
+
// a replay of the recorded selector in the browser's own DOM against the snapshot (resolved from
|
|
437
|
+
// the project's Playwright; no hand-written CSS grammar). Everything else fails closed — a selector
|
|
438
|
+
// the browser rejects, a missing browser, an unreadable snapshot, or a replay whose count disagrees
|
|
439
|
+
// with the recorded one names no elements rather than inventing them.
|
|
440
|
+
|
|
441
|
+
function stripAnsi(text) {
|
|
442
|
+
return String(text || '').replace(/\\u001b\\[[0-9;]*m/g, '')
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// The two numbers a count assertion prints, when the failure is one. A string assertion's
|
|
446
|
+
// \`Expected: "…"\` and a comparison's \`Expected: > 3\` both parse to null.
|
|
447
|
+
function countNumbersOf(message) {
|
|
448
|
+
const text = stripAnsi(message)
|
|
449
|
+
const expected = /Expected:\\s*(\\d+)\\s*(?:\\n|$)/.exec(text)
|
|
450
|
+
const received = /Received:\\s*(\\d+)\\s*(?:\\n|$)/.exec(text)
|
|
451
|
+
if (expected === null || received === null) return null
|
|
452
|
+
return { expected: Number(expected[1]), received: Number(received[1]) }
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// The locator a count assertion printed (\`Locator: locator('.card…')\`), and its inner selector.
|
|
456
|
+
function locatorInError(message) {
|
|
457
|
+
const text = stripAnsi(message)
|
|
458
|
+
const named = /Locator:\\s*(locator\\([^\\n]*?\\))\\s*(?:\\n|$)/.exec(text)
|
|
459
|
+
if (named === null) return null
|
|
460
|
+
const inner = /^locator\\(\\s*(['"\`])([\\s\\S]*)\\1\\s*\\)$/.exec(named[1])
|
|
461
|
+
if (inner === null) return { locator: named[1], selector: null }
|
|
462
|
+
return { locator: named[1], selector: inner[2] }
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function cssClassesOf(node) {
|
|
466
|
+
return String(node.attrs.class || '').split(/\\s+/).filter(Boolean)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// The post-order node list Playwright's own \`snapshotNodes\` builds: text nodes in place, each
|
|
470
|
+
// element pushed after its children. A subtree reference is not a node and is not followed here.
|
|
471
|
+
function snapshotNodesOf(value) {
|
|
472
|
+
const nodes = []
|
|
473
|
+
const visit = function (n) {
|
|
474
|
+
if (typeof n === 'string') { nodes.push(n); return }
|
|
475
|
+
if (!Array.isArray(n) || typeof n[0] !== 'string') return
|
|
476
|
+
for (let i = 2; i < n.length; i++) visit(n[i])
|
|
477
|
+
nodes.push(n)
|
|
478
|
+
}
|
|
479
|
+
visit(value)
|
|
480
|
+
return nodes
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// The frame's own snapshots, in recording order — the list Playwright's subtree references index
|
|
484
|
+
// into. Main frames only (the reader drops subframe snapshots), matched by frameId when present.
|
|
485
|
+
function frameSnapshotsOf(frame, allFrames) {
|
|
486
|
+
if (typeof frame.frameId === 'string' && frame.frameId !== '') {
|
|
487
|
+
const same = allFrames.filter(function (f) { return f.frameId === frame.frameId })
|
|
488
|
+
if (same.length > 0) return same
|
|
489
|
+
}
|
|
490
|
+
return allFrames.filter(function (f) { return f.context === frame.context && f.pageId === frame.pageId })
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// One snapshot's DOM as { tag, attrs, children, parent } nodes, with subtree references expanded
|
|
494
|
+
// against the frame's earlier snapshots (reference \`[delta, nodeIndex]\` = the snapshot \`at - delta\`,
|
|
495
|
+
// exactly as the trace viewer resolves it). Null when any reference cannot be resolved or the tree
|
|
496
|
+
// is too large: an incomplete snapshot is not evidence about the page.
|
|
497
|
+
function resolvedSnapshotTree(frame, allFrames) {
|
|
498
|
+
if (frame === undefined || frame === null) return null
|
|
499
|
+
const tree = frame.snapshot
|
|
500
|
+
if (tree === undefined || tree === null) return null
|
|
501
|
+
const shown = frameSnapshotsOf(frame, allFrames)
|
|
502
|
+
const index = shown.indexOf(frame)
|
|
503
|
+
if (index < 0) return null
|
|
504
|
+
let budget = 60000
|
|
505
|
+
let readable = true
|
|
506
|
+
const build = function (value, at, depth) {
|
|
507
|
+
if (!readable) return null
|
|
508
|
+
if (budget-- < 0 || depth > 80) { readable = false; return null }
|
|
509
|
+
if (typeof value === 'string') return { tag: null, text: value, attrs: {}, children: [], parent: null }
|
|
510
|
+
if (!Array.isArray(value)) { readable = false; return null }
|
|
511
|
+
if (Array.isArray(value[0])) {
|
|
512
|
+
const delta = typeof value[0][0] === 'number' ? value[0][0] : -1
|
|
513
|
+
const nodeIndex = typeof value[0][1] === 'number' ? value[0][1] : -1
|
|
514
|
+
const refAt = at - delta
|
|
515
|
+
if (delta < 0 || refAt < 0 || refAt >= shown.length || nodeIndex < 0) { readable = false; return null }
|
|
516
|
+
const earlier = shown[refAt].snapshot
|
|
517
|
+
const nodes = earlier === undefined || earlier === null ? [] : snapshotNodesOf(earlier)
|
|
518
|
+
if (nodeIndex >= nodes.length) { readable = false; return null }
|
|
519
|
+
return build(nodes[nodeIndex], refAt, depth + 1)
|
|
520
|
+
}
|
|
521
|
+
if (typeof value[0] !== 'string') { readable = false; return null }
|
|
522
|
+
const node = {
|
|
523
|
+
tag: String(value[0]).toLowerCase(),
|
|
524
|
+
attrs: value[1] && typeof value[1] === 'object' ? value[1] : {},
|
|
525
|
+
children: [],
|
|
526
|
+
parent: null,
|
|
527
|
+
}
|
|
528
|
+
for (let i = 2; i < value.length; i++) {
|
|
529
|
+
const child = build(value[i], at, depth + 1)
|
|
530
|
+
if (child === null) { readable = false; return null }
|
|
531
|
+
child.parent = node
|
|
532
|
+
node.children.push(child)
|
|
533
|
+
}
|
|
534
|
+
return node
|
|
535
|
+
}
|
|
536
|
+
const root = build(tree, index, 0)
|
|
537
|
+
return readable ? root : null
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// An element's own text, whitespace-collapsed and bounded. Script and style bodies are skipped, as
|
|
541
|
+
// the trace viewer skips them: code is not what the page showed.
|
|
542
|
+
function elementTextOf(node) {
|
|
543
|
+
let out = ''
|
|
544
|
+
const visit = function (n) {
|
|
545
|
+
if (out.length > MAX_COUNT_TEXT) return
|
|
546
|
+
if (typeof n.text === 'string') { out += n.text; return }
|
|
547
|
+
if (n.tag === 'script' || n.tag === 'style') return
|
|
548
|
+
for (const child of n.children) visit(child)
|
|
549
|
+
}
|
|
550
|
+
visit(node)
|
|
551
|
+
return out.replace(/\\s+/g, ' ').trim().slice(0, MAX_COUNT_TEXT)
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// The element's ancestors, outermost first, for the row's "where it sits" (\`body >
|
|
555
|
+
// section.comments > div.card\`).
|
|
556
|
+
function elementPathOf(node) {
|
|
557
|
+
const parts = []
|
|
558
|
+
let at = node.parent
|
|
559
|
+
while (at !== null && at.tag !== null && parts.length < MAX_COUNT_PATH_DEPTH) {
|
|
560
|
+
const classes = cssClassesOf(at)
|
|
561
|
+
parts.unshift(at.tag + (classes.length > 0 ? '.' + classes[0] : ''))
|
|
562
|
+
at = at.parent
|
|
563
|
+
}
|
|
564
|
+
return parts.join(' > ').slice(0, 160)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function elementInForm(node) {
|
|
568
|
+
let at = node.parent
|
|
569
|
+
while (at !== null) {
|
|
570
|
+
if (at.tag === 'form') return true
|
|
571
|
+
at = at.parent
|
|
572
|
+
}
|
|
573
|
+
return false
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function countMatchOf(node) {
|
|
577
|
+
const match = { tag: node.tag }
|
|
578
|
+
const classes = String(node.attrs.class || '').trim()
|
|
579
|
+
if (classes) match.classes = classes.slice(0, 120)
|
|
580
|
+
const text = elementTextOf(node)
|
|
581
|
+
if (text) match.text = text
|
|
582
|
+
const path = elementPathOf(node)
|
|
583
|
+
if (path) match.path = path
|
|
584
|
+
if (elementInForm(node)) match.inForm = true
|
|
585
|
+
return match
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Every element carrying Playwright's own assertion-target mark, in document order.
|
|
589
|
+
function targetElementsOf(root) {
|
|
590
|
+
const out = []
|
|
591
|
+
const visit = function (node) {
|
|
592
|
+
if (node.tag === null) return
|
|
593
|
+
if (Object.prototype.hasOwnProperty.call(node.attrs, '__playwright_target__')) out.push(node)
|
|
594
|
+
for (const child of node.children) visit(child)
|
|
595
|
+
}
|
|
596
|
+
visit(root)
|
|
597
|
+
return out
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// The failure snapshot as HTML, so a real browser can parse it and answer the selector with the
|
|
601
|
+
// standard DOMParser/querySelectorAll instead of a hand-written grammar. Each element carries its
|
|
602
|
+
// node index, so a match maps back to the RECORDED element (text, classes, ancestors) rather than a
|
|
603
|
+
// re-derivation. Bounded, and every unsupported shape fails the serialization instead of producing
|
|
604
|
+
// a partial page: an incomplete DOM is not evidence about the page.
|
|
605
|
+
const SNAPSHOT_NODE_ATTR = 'data-mol-snapshot-node'
|
|
606
|
+
const SNAPSHOT_TAG = /^[a-z][a-z0-9-]*$/i
|
|
607
|
+
const SNAPSHOT_ATTR = /^[-_A-Za-z][-_A-Za-z0-9:.]*$/
|
|
608
|
+
const SNAPSHOT_VOID = {
|
|
609
|
+
area: 1, base: 1, br: 1, col: 1, embed: 1, hr: 1, img: 1, input: 1, link: 1,
|
|
610
|
+
meta: 1, param: 1, source: 1, track: 1, wbr: 1
|
|
611
|
+
}
|
|
612
|
+
function snapshotHtmlOf(root) {
|
|
613
|
+
const nodes = []
|
|
614
|
+
let html = ''
|
|
615
|
+
let failed = false
|
|
616
|
+
function append(text) {
|
|
617
|
+
if (failed) return
|
|
618
|
+
if (html.length + text.length > MAX_SNAPSHOT_HTML) { failed = true; return }
|
|
619
|
+
html += text
|
|
620
|
+
}
|
|
621
|
+
function escapeText(text) {
|
|
622
|
+
return String(text).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
623
|
+
}
|
|
624
|
+
function escapeAttr(text) {
|
|
625
|
+
return String(text).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<')
|
|
626
|
+
}
|
|
627
|
+
function visit(node) {
|
|
628
|
+
if (failed) return
|
|
629
|
+
if (node.tag === null) { append(escapeText(node.text === undefined ? '' : node.text)); return }
|
|
630
|
+
if (!SNAPSHOT_TAG.test(node.tag)) { failed = true; return }
|
|
631
|
+
const index = nodes.length
|
|
632
|
+
nodes.push(node)
|
|
633
|
+
append('<' + node.tag)
|
|
634
|
+
for (const name of Object.keys(node.attrs)) {
|
|
635
|
+
if (!SNAPSHOT_ATTR.test(name) || name.toLowerCase() === SNAPSHOT_NODE_ATTR) continue
|
|
636
|
+
const value = node.attrs[name]
|
|
637
|
+
if (typeof value !== 'string') continue
|
|
638
|
+
// A meta refresh is a navigation, not rendered content. The replay only needs the DOM the
|
|
639
|
+
// failure snapshot shows, so it is dropped and the isolated context stays inert.
|
|
640
|
+
if (node.tag === 'meta' && name.toLowerCase() === 'http-equiv' && /refresh/i.test(value)) continue
|
|
641
|
+
append(' ' + name + '="' + escapeAttr(value) + '"')
|
|
642
|
+
}
|
|
643
|
+
append(' ' + SNAPSHOT_NODE_ATTR + '="' + index + '">')
|
|
644
|
+
if (SNAPSHOT_VOID[node.tag]) {
|
|
645
|
+
if (node.children.length > 0) { failed = true; return }
|
|
646
|
+
return
|
|
647
|
+
}
|
|
648
|
+
for (const child of node.children) visit(child)
|
|
649
|
+
append('</' + node.tag + '>')
|
|
650
|
+
}
|
|
651
|
+
visit(root)
|
|
652
|
+
if (failed || html === '') return null
|
|
653
|
+
// A snapshot rooted at <html> IS the document; anything else is body content.
|
|
654
|
+
const documentHtml =
|
|
655
|
+
root.tag === 'html' ? '<!doctype html>' + html
|
|
656
|
+
: '<!doctype html><html><head></head><body>' + html + '</body></html>'
|
|
657
|
+
return { nodes: nodes, documentHtml: documentHtml }
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Playwright, resolved from the project whose traces these are (the sandbox installs it for the e2e
|
|
661
|
+
// run the trace came from). The trace reader itself stays dependency-free: when the module or the
|
|
662
|
+
// recorded browser's executable cannot be resolved, the replay says so and names no elements.
|
|
663
|
+
let playwrightModulePromise = null
|
|
664
|
+
function playwrightModule() {
|
|
665
|
+
if (playwrightModulePromise === null) {
|
|
666
|
+
playwrightModulePromise = Promise.resolve().then(function () {
|
|
667
|
+
const paths = []
|
|
668
|
+
if (ROOT) paths.push(ROOT)
|
|
669
|
+
paths.push(process.cwd())
|
|
670
|
+
for (const name of ['playwright', '@playwright/test']) {
|
|
671
|
+
try {
|
|
672
|
+
return require(require.resolve(name, { paths: paths }))
|
|
673
|
+
} catch (_error) {
|
|
674
|
+
// Not installed under this name at the project root: try the next module.
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
return null
|
|
678
|
+
})
|
|
679
|
+
}
|
|
680
|
+
return playwrightModulePromise
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// One browser per recorded engine, launched on the first selector replay and reused. A resolution
|
|
684
|
+
// or launch failure is cached as null, so a project without the browser does not pay for it once per
|
|
685
|
+
// trace.
|
|
686
|
+
const replayBrowsers = {}
|
|
687
|
+
function replayBrowserFor(name) {
|
|
688
|
+
const engineName = name || 'chromium'
|
|
689
|
+
if (replayBrowsers[engineName] === undefined) {
|
|
690
|
+
replayBrowsers[engineName] = playwrightModule().then(function (pw) {
|
|
691
|
+
if (pw === null) return null
|
|
692
|
+
const engine = pw[engineName]
|
|
693
|
+
if (!engine || typeof engine.launch !== 'function') return null
|
|
694
|
+
try {
|
|
695
|
+
return engine.launch({ headless: true, timeout: 10000 }).catch(function (_error) {
|
|
696
|
+
// No executable for this engine: the replay reports unavailable, never a guess.
|
|
697
|
+
return null
|
|
698
|
+
})
|
|
699
|
+
} catch (_error) {
|
|
700
|
+
return null
|
|
701
|
+
}
|
|
702
|
+
})
|
|
703
|
+
}
|
|
704
|
+
return replayBrowsers[engineName]
|
|
705
|
+
}
|
|
706
|
+
function closeReplayBrowsers() {
|
|
707
|
+
return Promise.all(Object.keys(replayBrowsers).map(function (name) {
|
|
708
|
+
return replayBrowsers[name].then(function (browser) {
|
|
709
|
+
if (browser === null) return null
|
|
710
|
+
return browser.close().catch(function (_error) { return null })
|
|
711
|
+
})
|
|
712
|
+
}))
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Replay one selector against the recorded snapshot with the browser's own DOM. The context is
|
|
716
|
+
// isolated and has JavaScript disabled (nothing in the snapshot can execute), is offline, and
|
|
717
|
+
// aborts every request (nothing it references can be fetched). null = no browser available;
|
|
718
|
+
// { unsupported: true } = the browser rejected the selector (a Playwright engine, chaining).
|
|
719
|
+
async function replaySelector(selector, snapshot, browserName) {
|
|
720
|
+
const browser = await replayBrowserFor(browserName)
|
|
721
|
+
if (browser === null) return null
|
|
722
|
+
let context = null
|
|
723
|
+
try {
|
|
724
|
+
context = await browser.newContext({
|
|
725
|
+
javaScriptEnabled: false,
|
|
726
|
+
offline: true,
|
|
727
|
+
serviceWorkers: 'block',
|
|
728
|
+
})
|
|
729
|
+
await context.route('**/*', function (route) { return route.abort() })
|
|
730
|
+
const page = await context.newPage()
|
|
731
|
+
await page.setContent(snapshot.documentHtml, { waitUntil: 'domcontentloaded', timeout: 10000 })
|
|
732
|
+
const found = await page.evaluate(function (args) {
|
|
733
|
+
try {
|
|
734
|
+
const elements = document.querySelectorAll(args.selector)
|
|
735
|
+
const indices = []
|
|
736
|
+
for (let i = 0; i < elements.length; i++) {
|
|
737
|
+
indices.push(elements[i].getAttribute(args.marker))
|
|
738
|
+
}
|
|
739
|
+
return { ok: true, indices: indices }
|
|
740
|
+
} catch (_error) {
|
|
741
|
+
return { ok: false }
|
|
742
|
+
}
|
|
743
|
+
}, { selector: selector, marker: SNAPSHOT_NODE_ATTR })
|
|
744
|
+
if (found === null || found === undefined || found.ok !== true || !Array.isArray(found.indices))
|
|
745
|
+
return { unsupported: true }
|
|
746
|
+
return { indices: found.indices }
|
|
747
|
+
} catch (_error) {
|
|
748
|
+
// The context could not hold the snapshot (launch failure, a malformed serialization): the
|
|
749
|
+
// replay is unavailable for this assertion, and no element is named.
|
|
750
|
+
return null
|
|
751
|
+
} finally {
|
|
752
|
+
if (context !== null) await context.close().catch(function (_error) { return null })
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// The count assertion this failure is, with the locator the trace recorded and the elements its
|
|
757
|
+
// failure snapshot matched. Null when the failure is not a count, when no locator can be attributed
|
|
758
|
+
// to it, or when both numbers cannot be read.
|
|
759
|
+
async function countMismatchOf(failedError, frames, allFrames, queryCounts, frameExpects, failedStep, browserName) {
|
|
760
|
+
const numbers = countNumbersOf(failedError)
|
|
761
|
+
if (numbers === null) return null
|
|
762
|
+
const assertionStart =
|
|
763
|
+
failedStep && typeof failedStep.startTime === 'number' ? failedStep.startTime : null
|
|
764
|
+
const recent = function (entry) {
|
|
765
|
+
if (assertionStart === null) return true
|
|
766
|
+
return entry.start <= assertionStart + 1000 && assertionStart - entry.start <= COUNT_LOOKBACK_MS
|
|
767
|
+
}
|
|
768
|
+
const named = locatorInError(failedError)
|
|
769
|
+
let locator = null
|
|
770
|
+
let selector = null
|
|
771
|
+
let source = null
|
|
772
|
+
if (named !== null && named.selector !== null) {
|
|
773
|
+
locator = named.locator
|
|
774
|
+
selector = named.selector
|
|
775
|
+
source = 'assertion'
|
|
776
|
+
}
|
|
777
|
+
// expect(locator).toHaveCount(n): the trace records the selector and the expected number.
|
|
778
|
+
if (locator === null) {
|
|
779
|
+
const wanted = frameExpects
|
|
780
|
+
.filter(function (entry) { return entry.expected === numbers.expected && recent(entry) })
|
|
781
|
+
.sort(function (a, b) { return b.start - a.start })[0]
|
|
782
|
+
if (wanted !== undefined) {
|
|
783
|
+
locator = wanted.locator
|
|
784
|
+
selector = wanted.selector
|
|
785
|
+
source = 'assertion'
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
// locator.count() then expect(count).toBe(n): the value the trace recorded IS the received one.
|
|
789
|
+
if (locator === null) {
|
|
790
|
+
const wanted = queryCounts
|
|
791
|
+
.filter(function (entry) { return entry.value === numbers.received && recent(entry) })
|
|
792
|
+
.sort(function (a, b) { return b.start - a.start })[0]
|
|
793
|
+
if (wanted !== undefined) {
|
|
794
|
+
locator = wanted.locator
|
|
795
|
+
selector = wanted.selector
|
|
796
|
+
source = 'locator-count'
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
if (locator === null || source === null) return null
|
|
800
|
+
const reading = {
|
|
801
|
+
locator: locator.slice(0, 200),
|
|
802
|
+
...(selector === null ? {} : { selector: selector.slice(0, 200) }),
|
|
803
|
+
expected: numbers.expected,
|
|
804
|
+
received: numbers.received,
|
|
805
|
+
source: source,
|
|
806
|
+
}
|
|
807
|
+
const tree = resolvedSnapshotTree(frames.length > 0 ? frames[frames.length - 1] : null, allFrames)
|
|
808
|
+
if (tree === null) {
|
|
809
|
+
reading.unreadable = 'no-snapshot'
|
|
810
|
+
return reading
|
|
811
|
+
}
|
|
812
|
+
// Playwright marks an assertion's own matches in the snapshot recorded with it. Only a mark set
|
|
813
|
+
// whose count IS the received count belongs to this assertion.
|
|
814
|
+
const targets = targetElementsOf(tree)
|
|
815
|
+
if (targets.length === numbers.received) {
|
|
816
|
+
reading.matched = targets.slice(0, MAX_COUNT_MATCHES).map(countMatchOf)
|
|
817
|
+
reading.matchedTotal = targets.length
|
|
818
|
+
reading.matchedFrom = 'snapshot-targets'
|
|
819
|
+
return reading
|
|
820
|
+
}
|
|
821
|
+
if (selector === null) {
|
|
822
|
+
reading.unreadable = 'unsupported-selector'
|
|
823
|
+
return reading
|
|
824
|
+
}
|
|
825
|
+
// The recorded selector is replayed against the recorded snapshot by the browser's own DOM: no
|
|
826
|
+
// hand-written CSS grammar, and every standard selector the engine the test ran in supports.
|
|
827
|
+
const snapshot = snapshotHtmlOf(tree)
|
|
828
|
+
if (snapshot === null) {
|
|
829
|
+
reading.unreadable = 'no-snapshot'
|
|
830
|
+
return reading
|
|
831
|
+
}
|
|
832
|
+
const replay = await replaySelector(selector, snapshot, browserName)
|
|
833
|
+
if (replay === null) {
|
|
834
|
+
reading.unreadable = 'browser-unavailable'
|
|
835
|
+
return reading
|
|
836
|
+
}
|
|
837
|
+
if (replay.unsupported) {
|
|
838
|
+
reading.unreadable = 'unsupported-selector'
|
|
839
|
+
return reading
|
|
840
|
+
}
|
|
841
|
+
const elements = []
|
|
842
|
+
let broken = false
|
|
843
|
+
for (const index of replay.indices) {
|
|
844
|
+
const at = typeof index === 'string' ? Number(index) : NaN
|
|
845
|
+
const node =
|
|
846
|
+
Number.isInteger(at) && at >= 0 && at < snapshot.nodes.length ? snapshot.nodes[at] : null
|
|
847
|
+
if (node === null || node.tag === null) { broken = true; break }
|
|
848
|
+
elements.push(node)
|
|
849
|
+
}
|
|
850
|
+
// The replay is evidence only when it agrees, element for element, with the count the run
|
|
851
|
+
// recorded; a stale or partial snapshot names nothing.
|
|
852
|
+
if (broken || elements.length !== numbers.received) {
|
|
853
|
+
reading.unreadable = 'count-mismatch'
|
|
854
|
+
return reading
|
|
855
|
+
}
|
|
856
|
+
reading.matched = elements.slice(0, MAX_COUNT_MATCHES).map(countMatchOf)
|
|
857
|
+
reading.matchedTotal = elements.length
|
|
858
|
+
reading.matchedFrom = 'selector-replay'
|
|
859
|
+
return reading
|
|
860
|
+
}
|
|
861
|
+
|
|
411
862
|
// A request/response body exactly as Playwright stored it: inline on the value, or in a resource
|
|
412
863
|
// entry the value names. Bounded, and binary is dropped — a JSON body is what this is for, and a
|
|
413
864
|
// multipart upload would print bytes.
|
|
@@ -546,6 +997,8 @@ function networkContextOf(name) {
|
|
|
546
997
|
return m === null ? null : m[1]
|
|
547
998
|
}
|
|
548
999
|
|
|
1000
|
+
// The reading is async only for the browser selector replay; every other fact is plain Node.
|
|
1001
|
+
async function main() {
|
|
549
1002
|
for (const one of found.slice(0, MAX_TRACES)) {
|
|
550
1003
|
let buf
|
|
551
1004
|
try { buf = fs.readFileSync(one.file) } catch (e) { continue }
|
|
@@ -580,6 +1033,19 @@ for (const one of found.slice(0, MAX_TRACES)) {
|
|
|
580
1033
|
ctx.network.push.apply(ctx.network, ndjson(zipData(buf, entry)))
|
|
581
1034
|
}
|
|
582
1035
|
}
|
|
1036
|
+
// The browser the test ran in, from the trace's own context-options: a selector replay uses the
|
|
1037
|
+
// same engine's DOM rather than assuming one. A trace that records none defaults to chromium.
|
|
1038
|
+
let browserName = ''
|
|
1039
|
+
for (const ctx of contexts) {
|
|
1040
|
+
for (const entry of ctx.trace) {
|
|
1041
|
+
if (entry && entry.type === 'context-options' && typeof entry.browserName === 'string' &&
|
|
1042
|
+
entry.browserName) {
|
|
1043
|
+
browserName = entry.browserName
|
|
1044
|
+
break
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
if (browserName) break
|
|
1048
|
+
}
|
|
583
1049
|
const requestsAll = []
|
|
584
1050
|
for (const ctx of contexts) {
|
|
585
1051
|
for (const line of ctx.network) {
|
|
@@ -644,11 +1110,30 @@ for (const one of found.slice(0, MAX_TRACES)) {
|
|
|
644
1110
|
// assertion's own page can be selected below (m160).
|
|
645
1111
|
const actionsAll = []
|
|
646
1112
|
const framesAll = []
|
|
1113
|
+
// The count entries a failing assertion can be paired with (m321): \`locator.count()\` and
|
|
1114
|
+
// \`expect(locator).toHaveCount(n)\` are the two ways a test compares a selector's match count.
|
|
1115
|
+
const queryCounts = []
|
|
1116
|
+
const frameExpects = []
|
|
1117
|
+
// The readable locator a runner step names (\`getByRole('button', { name: 'Post Comment' })\`),
|
|
1118
|
+
// keyed by the step id the library trace repeats on the entry it recorded for that step.
|
|
1119
|
+
const locatorByStep = {}
|
|
1120
|
+
for (const entry of runner) {
|
|
1121
|
+
if (!entry || entry.type !== 'before') continue
|
|
1122
|
+
const params = entry.params && typeof entry.params === 'object' ? entry.params : {}
|
|
1123
|
+
if (typeof entry.stepId === 'string' && typeof params.locator === 'string' && params.locator)
|
|
1124
|
+
locatorByStep[entry.stepId] = params.locator
|
|
1125
|
+
}
|
|
647
1126
|
for (const ctx of contexts) {
|
|
648
1127
|
const ended = {}
|
|
1128
|
+
const callValues = {}
|
|
649
1129
|
for (const entry of ctx.trace) {
|
|
650
1130
|
if (entry && entry.type === 'after' && typeof entry.callId === 'string' && typeof entry.endTime === 'number')
|
|
651
1131
|
ended[entry.callId] = entry.endTime
|
|
1132
|
+
// A count's own answer, on the trace's clock: the \`locator.count()\` entry records
|
|
1133
|
+
// \`result.value\`, and that number is the "Received:" the failing assertion printed.
|
|
1134
|
+
if (entry && entry.type === 'after' && typeof entry.callId === 'string' &&
|
|
1135
|
+
entry.result && typeof entry.result.value === 'number')
|
|
1136
|
+
callValues[entry.callId] = entry.result.value
|
|
652
1137
|
}
|
|
653
1138
|
const pageByCall = {}
|
|
654
1139
|
for (const entry of ctx.trace) {
|
|
@@ -697,6 +1182,39 @@ for (const one of found.slice(0, MAX_TRACES)) {
|
|
|
697
1182
|
}
|
|
698
1183
|
}
|
|
699
1184
|
ctx.actions.push(action)
|
|
1185
|
+
// The entries a count assertion can be attributed to (m321). A \`getByRole\`-style locator is
|
|
1186
|
+
// readable only through the runner step's own \`params.locator\`; the library entry's
|
|
1187
|
+
// \`params.selector\` is the internal engine string, kept for the CSS replay.
|
|
1188
|
+
const locator =
|
|
1189
|
+
(typeof entry.stepId === 'string' ? locatorByStep[entry.stepId] : undefined) ||
|
|
1190
|
+
'locator(' + JSON.stringify(params.selector) + ')'
|
|
1191
|
+
const end = typeof ended[entry.callId] === 'number' ? ended[entry.callId] : null
|
|
1192
|
+
if (api === 'Frame.queryCount' && typeof params.selector === 'string') {
|
|
1193
|
+
queryCounts.push({
|
|
1194
|
+
start: entry.startTime,
|
|
1195
|
+
end: end,
|
|
1196
|
+
selector: params.selector,
|
|
1197
|
+
value: typeof callValues[entry.callId] === 'number' ? callValues[entry.callId] : undefined,
|
|
1198
|
+
locator: locator,
|
|
1199
|
+
pageId: action.pageId,
|
|
1200
|
+
stepId: action.stepId,
|
|
1201
|
+
})
|
|
1202
|
+
}
|
|
1203
|
+
if (
|
|
1204
|
+
api === 'Frame.expect' &&
|
|
1205
|
+
/count/i.test(String(params.expression || '')) &&
|
|
1206
|
+
typeof params.selector === 'string'
|
|
1207
|
+
) {
|
|
1208
|
+
frameExpects.push({
|
|
1209
|
+
start: entry.startTime,
|
|
1210
|
+
end: end,
|
|
1211
|
+
selector: params.selector,
|
|
1212
|
+
expected: typeof params.expectedNumber === 'number' ? params.expectedNumber : undefined,
|
|
1213
|
+
locator: locator,
|
|
1214
|
+
pageId: action.pageId,
|
|
1215
|
+
stepId: action.stepId,
|
|
1216
|
+
})
|
|
1217
|
+
}
|
|
700
1218
|
}
|
|
701
1219
|
// Monotonic time, not archive order: two contexts interleave in the zip, and one context's
|
|
702
1220
|
// own actions still have to be read in the order the page performed them.
|
|
@@ -1026,27 +1544,17 @@ for (const one of found.slice(0, MAX_TRACES)) {
|
|
|
1026
1544
|
}
|
|
1027
1545
|
// The text a frame snapshot SHOWED, so a page that still carried the awaited value is never
|
|
1028
1546
|
// described as having dropped it. Playwright serializes the DOM as strings (text nodes) and
|
|
1029
|
-
// [tag, attrs, ...children] elements, with a subtree reference ([
|
|
1030
|
-
//
|
|
1031
|
-
//
|
|
1032
|
-
//
|
|
1547
|
+
// [tag, attrs, ...children] elements, with a subtree reference ([delta, nodeIndex]) to a node an
|
|
1548
|
+
// EARLIER snapshot of the same frame carried — the reference is relative to this snapshot's own
|
|
1549
|
+
// index in the frame's list, which is how the trace viewer resolves it (\`snapshotIndex - delta\`).
|
|
1550
|
+
// \`script\`/\`style\` bodies are skipped, as the trace viewer skips them: code is not what the page
|
|
1551
|
+
// showed. Null when the snapshot is absent or a reference cannot be resolved — an unreadable
|
|
1552
|
+
// snapshot proves nothing.
|
|
1033
1553
|
function snapshotShowed(frame) {
|
|
1034
1554
|
const tree = frame.snapshot
|
|
1035
1555
|
if (tree === undefined || tree === null) return null
|
|
1036
|
-
const shown =
|
|
1037
|
-
|
|
1038
|
-
})
|
|
1039
|
-
const nodesOf = function (value) {
|
|
1040
|
-
const nodes = []
|
|
1041
|
-
const collect = function (n) {
|
|
1042
|
-
if (typeof n === 'string') { nodes.push(n); return }
|
|
1043
|
-
if (!Array.isArray(n) || typeof n[0] !== 'string') return
|
|
1044
|
-
for (let i = 2; i < n.length; i++) collect(n[i])
|
|
1045
|
-
nodes.push(n)
|
|
1046
|
-
}
|
|
1047
|
-
collect(value)
|
|
1048
|
-
return nodes
|
|
1049
|
-
}
|
|
1556
|
+
const shown = frameSnapshotsOf(frame, framesAll)
|
|
1557
|
+
const index = shown.indexOf(frame)
|
|
1050
1558
|
const out = []
|
|
1051
1559
|
let visited = 0
|
|
1052
1560
|
let readable = true
|
|
@@ -1058,8 +1566,9 @@ for (const one of found.slice(0, MAX_TRACES)) {
|
|
|
1058
1566
|
const ref = n[0]
|
|
1059
1567
|
const at = Array.isArray(ref) && typeof ref[0] === 'number' ? ref[0] : -1
|
|
1060
1568
|
const node = Array.isArray(ref) && typeof ref[1] === 'number' ? ref[1] : -1
|
|
1061
|
-
const
|
|
1062
|
-
const
|
|
1569
|
+
const refAt = index - at
|
|
1570
|
+
const earlier = at >= 0 && refAt >= 0 && refAt < shown.length && shown[refAt].snapshot != null ? shown[refAt].snapshot : null
|
|
1571
|
+
const nodes = earlier === null ? [] : snapshotNodesOf(earlier)
|
|
1063
1572
|
if (node < 0 || node >= nodes.length) { readable = false; return }
|
|
1064
1573
|
visit(nodes[node])
|
|
1065
1574
|
return
|
|
@@ -1132,6 +1641,11 @@ for (const one of found.slice(0, MAX_TRACES)) {
|
|
|
1132
1641
|
// what the element is made of, so the note names the cause instead of sending the run at the
|
|
1133
1642
|
// selector (m197 — an <img> with no CSS size collapsed inside its link).
|
|
1134
1643
|
const hiddenBox = hiddenBoxOf(failedError, frames)
|
|
1644
|
+
// A count assertion whose locator and matched elements the trace itself holds (m321): the
|
|
1645
|
+
// locator from \`locator.count()\`/\`toHaveCount\`, the elements from the failure snapshot.
|
|
1646
|
+
const countMismatch = await countMismatchOf(
|
|
1647
|
+
failedError, frames, framesAll, queryCounts, frameExpects, failedStep, browserName
|
|
1648
|
+
)
|
|
1135
1649
|
function wire(r) {
|
|
1136
1650
|
return {
|
|
1137
1651
|
method: r.method,
|
|
@@ -1356,7 +1870,8 @@ for (const one of found.slice(0, MAX_TRACES)) {
|
|
|
1356
1870
|
chosen.length === 0 &&
|
|
1357
1871
|
keptConsole.length === 0 &&
|
|
1358
1872
|
actions.length === 0 &&
|
|
1359
|
-
hiddenBox === null
|
|
1873
|
+
hiddenBox === null &&
|
|
1874
|
+
countMismatch === null
|
|
1360
1875
|
)
|
|
1361
1876
|
continue
|
|
1362
1877
|
// Counted over the page's EVERY network entry, before the cap and dedupe above (m215).
|
|
@@ -1375,12 +1890,19 @@ for (const one of found.slice(0, MAX_TRACES)) {
|
|
|
1375
1890
|
...(missingText === null ? {} : { missingText: missingText }),
|
|
1376
1891
|
...(readContradiction === null ? {} : { readContradiction: readContradiction }),
|
|
1377
1892
|
...(hiddenBox === null ? {} : { hiddenBox: hiddenBox }),
|
|
1893
|
+
...(countMismatch === null ? {} : { countMismatch: countMismatch }),
|
|
1378
1894
|
console: keptConsole.slice(0, MAX_CONSOLE).map(function (text) { return text.slice(0, 300) }),
|
|
1379
1895
|
actions: actions.slice(-MAX_ACTIONS),
|
|
1380
1896
|
...(unattributed ? { unattributed: true } : {}),
|
|
1381
1897
|
...(otherContexts.length > 0 ? { otherContexts: otherContexts } : {}),
|
|
1382
1898
|
}) + '\\n')
|
|
1383
1899
|
}
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
main().catch(function (error) {
|
|
1903
|
+
process.stderr.write('trace read failed: ' +
|
|
1904
|
+
String(error && error.message ? error.message : error) + '\\n')
|
|
1905
|
+
}).then(closeReplayBrowsers)
|
|
1384
1906
|
`;
|
|
1385
1907
|
/**
|
|
1386
1908
|
* One request out of a `TRACE` line's json, or null when the value is not an object.
|
|
@@ -1496,6 +2018,62 @@ function parseHiddenBox(value) {
|
|
|
1496
2018
|
...(raw.empty === true ? { empty: true } : {}),
|
|
1497
2019
|
};
|
|
1498
2020
|
}
|
|
2021
|
+
/**
|
|
2022
|
+
* One `countMismatch` out of a `TRACE` line's json, or null when the value is not one.
|
|
2023
|
+
*
|
|
2024
|
+
* The locator and both numbers are required; the matched elements are kept only when the script
|
|
2025
|
+
* wrote them, and each needs a tag. A reading whose `unreadable` reason is unknown is dropped —
|
|
2026
|
+
* the note may not present a reason it cannot explain.
|
|
2027
|
+
*
|
|
2028
|
+
* @param value - The line's `countMismatch`.
|
|
2029
|
+
* @returns The count assertion the trace read, or null.
|
|
2030
|
+
*/
|
|
2031
|
+
function parseCountMismatch(value) {
|
|
2032
|
+
if (typeof value !== 'object' || value === null)
|
|
2033
|
+
return null;
|
|
2034
|
+
const raw = value;
|
|
2035
|
+
if (typeof raw.locator !== 'string' || raw.locator === '')
|
|
2036
|
+
return null;
|
|
2037
|
+
if (typeof raw.expected !== 'number' || !Number.isFinite(raw.expected))
|
|
2038
|
+
return null;
|
|
2039
|
+
if (typeof raw.received !== 'number' || !Number.isFinite(raw.received))
|
|
2040
|
+
return null;
|
|
2041
|
+
if (raw.source !== 'assertion' && raw.source !== 'locator-count')
|
|
2042
|
+
return null;
|
|
2043
|
+
const matched = Array.isArray(raw.matched)
|
|
2044
|
+
? raw.matched
|
|
2045
|
+
.filter((entry) => typeof entry === 'object' && entry !== null)
|
|
2046
|
+
.filter((entry) => typeof entry.tag === 'string' && entry.tag !== '')
|
|
2047
|
+
.map((entry) => ({
|
|
2048
|
+
tag: String(entry.tag),
|
|
2049
|
+
...(typeof entry.classes === 'string' && entry.classes ? { classes: entry.classes } : {}),
|
|
2050
|
+
...(typeof entry.text === 'string' && entry.text ? { text: entry.text } : {}),
|
|
2051
|
+
...(typeof entry.path === 'string' && entry.path ? { path: entry.path } : {}),
|
|
2052
|
+
...(entry.inForm === true ? { inForm: true } : {}),
|
|
2053
|
+
}))
|
|
2054
|
+
: [];
|
|
2055
|
+
const unreadable = raw.unreadable === 'unsupported-selector' ||
|
|
2056
|
+
raw.unreadable === 'no-snapshot' ||
|
|
2057
|
+
raw.unreadable === 'count-mismatch' ||
|
|
2058
|
+
raw.unreadable === 'browser-unavailable'
|
|
2059
|
+
? raw.unreadable
|
|
2060
|
+
: null;
|
|
2061
|
+
return {
|
|
2062
|
+
locator: raw.locator,
|
|
2063
|
+
...(typeof raw.selector === 'string' && raw.selector ? { selector: raw.selector } : {}),
|
|
2064
|
+
expected: raw.expected,
|
|
2065
|
+
received: raw.received,
|
|
2066
|
+
source: raw.source,
|
|
2067
|
+
...(matched.length === 0 ? {} : { matched }),
|
|
2068
|
+
...(typeof raw.matchedTotal === 'number' && Number.isFinite(raw.matchedTotal)
|
|
2069
|
+
? { matchedTotal: raw.matchedTotal }
|
|
2070
|
+
: {}),
|
|
2071
|
+
...(raw.matchedFrom === 'snapshot-targets' || raw.matchedFrom === 'selector-replay'
|
|
2072
|
+
? { matchedFrom: raw.matchedFrom }
|
|
2073
|
+
: {}),
|
|
2074
|
+
...(unreadable === null ? {} : { unreadable }),
|
|
2075
|
+
};
|
|
2076
|
+
}
|
|
1499
2077
|
/**
|
|
1500
2078
|
* One `lastAction` out of a `TRACE` line's json, or null when the value is not one.
|
|
1501
2079
|
*
|
|
@@ -1576,6 +2154,7 @@ export function parseTraceArtifacts(stdout) {
|
|
|
1576
2154
|
const missingText = parseMissingText(raw.missingText);
|
|
1577
2155
|
const readContradiction = parseReadContradiction(raw.readContradiction);
|
|
1578
2156
|
const hiddenBox = parseHiddenBox(raw.hiddenBox);
|
|
2157
|
+
const countMismatch = parseCountMismatch(raw.countMismatch);
|
|
1579
2158
|
const repeated = parseRepeated(raw.repeated);
|
|
1580
2159
|
const otherContexts = Array.isArray(raw.otherContexts)
|
|
1581
2160
|
? raw.otherContexts
|
|
@@ -1615,6 +2194,7 @@ export function parseTraceArtifacts(stdout) {
|
|
|
1615
2194
|
...(missingText === null ? {} : { missingText }),
|
|
1616
2195
|
...(readContradiction === null ? {} : { readContradiction }),
|
|
1617
2196
|
...(hiddenBox === null ? {} : { hiddenBox }),
|
|
2197
|
+
...(countMismatch === null ? {} : { countMismatch }),
|
|
1618
2198
|
...(repeated.length === 0 ? {} : { repeated }),
|
|
1619
2199
|
});
|
|
1620
2200
|
}
|
|
@@ -2150,6 +2730,9 @@ function traceRowEvidence(item, report) {
|
|
|
2150
2730
|
// have resolved the locator into an empty box, and that is the whole cause (m197's shape on a
|
|
2151
2731
|
// static page).
|
|
2152
2732
|
item.hiddenBox !== undefined ||
|
|
2733
|
+
// A count assertion's own reading is evidence too: the trace names the locator and the
|
|
2734
|
+
// elements that made the count too large, even when every request answered (m321).
|
|
2735
|
+
item.countMismatch !== undefined ||
|
|
2153
2736
|
item.console.length > 0 ||
|
|
2154
2737
|
item.requests.some((request) => !isStaticAssetRequest(request) && !unansweredRequest(request)) ||
|
|
2155
2738
|
// The one shape in which a subresource with no response IS the cause: the navigation the
|
|
@@ -3207,6 +3790,75 @@ export function hiddenBoxNote(items) {
|
|
|
3207
3790
|
}
|
|
3208
3791
|
return null;
|
|
3209
3792
|
}
|
|
3793
|
+
/** One matched element as a count row writes it: tag.class, its own text, and where it sits. */
|
|
3794
|
+
function countMatchSentence(match) {
|
|
3795
|
+
const classes = match.classes === undefined ? '' : `.${match.classes.trim().split(/\s+/).join('.')}`;
|
|
3796
|
+
const text = match.text === undefined ? '' : ` "${match.text}"`;
|
|
3797
|
+
const where = [
|
|
3798
|
+
match.path === undefined ? '' : `in \`${match.path}\``,
|
|
3799
|
+
match.inForm === true ? 'inside a form' : '',
|
|
3800
|
+
]
|
|
3801
|
+
.filter(Boolean)
|
|
3802
|
+
.join(', ');
|
|
3803
|
+
return `\`${match.tag}${classes}\`${text}${where === '' ? '' : ` (${where})`}`;
|
|
3804
|
+
}
|
|
3805
|
+
/**
|
|
3806
|
+
* The row's sentence for a failing count assertion: the locator, both numbers, and the elements
|
|
3807
|
+
* the failure snapshot held.
|
|
3808
|
+
*
|
|
3809
|
+
* xr-molecule-flash-m321's `comments.spec.ts:86` printed only `Expected: 3 / Received: 4`. The
|
|
3810
|
+
* helper counted `.card:not(.comment-form) .card-block`, and the app's comment form was a
|
|
3811
|
+
* `.comment-form` wrapper around a plain `.card`, so the form's own `.card-block` was the fourth
|
|
3812
|
+
* match. The trace held the selector and the four elements; the note held neither.
|
|
3813
|
+
*
|
|
3814
|
+
* @param mismatch - The trace's count reading ({@link TraceCountMismatch}).
|
|
3815
|
+
* @returns The sentence.
|
|
3816
|
+
*/
|
|
3817
|
+
export function countMismatchSentence(mismatch) {
|
|
3818
|
+
const counted = `the failing assertion counted \`${mismatch.locator}\`: ${mismatch.received} matched where ` +
|
|
3819
|
+
`${mismatch.expected} ${mismatch.expected === 1 ? 'was' : 'were'} expected`;
|
|
3820
|
+
if (mismatch.matched !== undefined && mismatch.matchedTotal !== undefined) {
|
|
3821
|
+
const shown = mismatch.matchedTotal - mismatch.matched.length;
|
|
3822
|
+
const how = mismatch.matchedFrom === 'snapshot-targets'
|
|
3823
|
+
? `the trace's failure snapshot marks the assertion's ${mismatch.matchedTotal} ` +
|
|
3824
|
+
`${mismatch.matchedTotal === 1 ? 'match' : 'matches'}`
|
|
3825
|
+
: `the selector replayed against the trace's failure snapshot matched ` +
|
|
3826
|
+
`${mismatch.matchedTotal}`;
|
|
3827
|
+
return (`${counted}; ${how}: ${mismatch.matched.map(countMatchSentence).join('; ')}` +
|
|
3828
|
+
(shown > 0 ? `; and ${shown} more` : ''));
|
|
3829
|
+
}
|
|
3830
|
+
if (mismatch.unreadable === 'unsupported-selector')
|
|
3831
|
+
return (`${counted}. The trace records the locator and the count but not the elements it selected ` +
|
|
3832
|
+
`(a browser's own CSS query cannot express that locator for the failure snapshot), ` +
|
|
3833
|
+
`so read that helper's selector against the page yourself`);
|
|
3834
|
+
if (mismatch.unreadable === 'browser-unavailable')
|
|
3835
|
+
return (`${counted}. The failure snapshot could not be replayed because the project's Playwright ` +
|
|
3836
|
+
`module or the recorded browser's executable was not available, so the matched elements ` +
|
|
3837
|
+
`are not named — read that helper's selector against the page yourself`);
|
|
3838
|
+
if (mismatch.unreadable === 'count-mismatch')
|
|
3839
|
+
return (`${counted}. The failure snapshot's DOM replayed to a different count, so it is not ` +
|
|
3840
|
+
`evidence for this assertion and the matched elements are not named`);
|
|
3841
|
+
return (`${counted}. The trace recorded no readable DOM snapshot at the failure, so the matched ` +
|
|
3842
|
+
`elements are not named`);
|
|
3843
|
+
}
|
|
3844
|
+
/**
|
|
3845
|
+
* The sentence a count assertion earns when every traced failure is one — the next read before
|
|
3846
|
+
* the API or the request is changed.
|
|
3847
|
+
*
|
|
3848
|
+
* @param items - The traces {@link parseTraceArtifacts} read.
|
|
3849
|
+
* @returns The sentence, or null when not every traced failure is a count mismatch.
|
|
3850
|
+
*/
|
|
3851
|
+
export function countMismatchNote(items) {
|
|
3852
|
+
if (items.length === 0)
|
|
3853
|
+
return null;
|
|
3854
|
+
if (!items.every((item) => item.countMismatch !== undefined))
|
|
3855
|
+
return null;
|
|
3856
|
+
return ('No listed request failed — none got a 4xx/5xx or a network error. Every failure here is a ' +
|
|
3857
|
+
'COUNT over a locator, and each row names the locator the trace recorded and the elements ' +
|
|
3858
|
+
'its failure snapshot held. Read the extra element and the selector before changing the ' +
|
|
3859
|
+
'request or the API: a match that sits inside another element (a form, a wrapper) is the ' +
|
|
3860
|
+
'selector reaching it, not the list rendering it.');
|
|
3861
|
+
}
|
|
3210
3862
|
/**
|
|
3211
3863
|
* The note a failed suite run's result carries: what each failing test's page actually sent, and
|
|
3212
3864
|
* what came back.
|
|
@@ -3353,6 +4005,9 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
|
|
|
3353
4005
|
// "the cause is in the page" sentence, which m197 read as "go look at the page" and then spent
|
|
3354
4006
|
// four minutes rasterising an SVG ({@link hiddenBoxNote}).
|
|
3355
4007
|
const hiddenBox = hiddenBoxNote(traced);
|
|
4008
|
+
// Every traced failure a count assertion: the next read is the locator's matches, not a request
|
|
4009
|
+
// or the page's state (m321's 30-minute `Expected: 3 / Received: 4`).
|
|
4010
|
+
const everyCountMismatch = countMismatchNote(traced);
|
|
3356
4011
|
// m145's shape: the app's HTTP client lost its base URL, so every API call reached the page
|
|
3357
4012
|
// origin without the prefix the app mounts its routes under. The prefix is READ — the caller's
|
|
3358
4013
|
// client config plus what the traces show — never assumed to be `/api`.
|
|
@@ -3449,6 +4104,10 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
|
|
|
3449
4104
|
const budget = stall?.budgetMs ?? null;
|
|
3450
4105
|
let room = MAX_TRACE_NOTE_REQUESTS;
|
|
3451
4106
|
const parts = [];
|
|
4107
|
+
// The count reading leads the row: the locator and the extra element are the fact the run
|
|
4108
|
+
// needs first (m321), the requests beside it explain nothing about a count.
|
|
4109
|
+
if (item.countMismatch !== undefined)
|
|
4110
|
+
parts.push(countMismatchSentence(item.countMismatch));
|
|
3452
4111
|
if (step !== undefined && !stalledNav) {
|
|
3453
4112
|
const head = `the test's last action was ${step.api} at ${actionClock(item, step.start)}`;
|
|
3454
4113
|
const pageBefore = step.pageBefore;
|
|
@@ -3762,17 +4421,21 @@ export function failureTraceNote(items, target, earlierFailures = [], apiPrefixe
|
|
|
3762
4421
|
`${serverSilentReading(silentApiRequests, silentNavigations, false)} ` +
|
|
3763
4422
|
`Re-run only these tests against ${target} (\`--last-failed\`) or inspect the app's ` +
|
|
3764
4423
|
'runtime log for the same window to distinguish the cause.'
|
|
3765
|
-
:
|
|
3766
|
-
?
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
:
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
'
|
|
3774
|
-
|
|
3775
|
-
|
|
4424
|
+
: everyCountMismatch !== null
|
|
4425
|
+
? // A count assertion's own reading outranks the generic "read the response body"
|
|
4426
|
+
// advice, which sends the run at the request the count never depended on (m321).
|
|
4427
|
+
everyCountMismatch
|
|
4428
|
+
: hasPendingRequests
|
|
4429
|
+
? 'No listed request failed with a 4xx/5xx or a network error, but some requests have no ' +
|
|
4430
|
+
'recorded response. This does not establish whether the server stalled or the browser cancelled ' +
|
|
4431
|
+
'the request. Read the pending paths alongside the failed action and its timeout.'
|
|
4432
|
+
: 'No listed request failed with a 4xx/5xx or a network error. That does not establish ' +
|
|
4433
|
+
"that its response body or the page state was correct. Read what the test's last action sent (the first line of each " +
|
|
4434
|
+
'row) and what the page did next: a request that answered (2xx included) while the page ' +
|
|
4435
|
+
'still did not show the state the assertion waited on is the fact to read — its status ' +
|
|
4436
|
+
'and response body, not the selector. If the request is not the one the test meant — an ' +
|
|
4437
|
+
'unfollow sent where the test clicked follow — the state the action read was stale ' +
|
|
4438
|
+
'before it.') +
|
|
3776
4439
|
prefixAdvice +
|
|
3777
4440
|
(wrapped
|
|
3778
4441
|
? ' The API received a body wrapped in `data`: app-http’s `post(url, data, config)` / ' +
|