paperlint 14.1.0 → 14.3.0
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/CLAUDE.md +6 -1
- package/dist/skills/build-label-page/scripts/label-page-core.d.ts +70 -32
- package/docs/testing.md +12 -0
- package/eslint.config.mjs +17 -0
- package/package.json +2 -1
- package/skills/build-label-page/SKILL.md +19 -3
- package/skills/build-label-page/SKILL.md.spec.ts +18 -2
- package/skills/build-label-page/references/closed-choice-generator.md +86 -8
- package/skills/build-label-page/scripts/fixtures/v7-cards-items.json +138 -0
- package/skills/build-label-page/scripts/label-page-answerers.test.ts +381 -0
- package/skills/build-label-page/scripts/label-page-blocks.test.ts +277 -1
- package/skills/build-label-page/scripts/label-page-browser.test.ts +761 -10
- package/skills/build-label-page/scripts/label-page-core.js +311 -67
- package/skills/build-label-page/scripts/label-page-core.test.ts +49 -10
- package/skills/build-label-page/scripts/label-page-core.ts +511 -87
- package/skills/build-label-page/scripts/label-page-presets.test.ts +1 -1
- package/skills/build-label-page/scripts/label-page.js +5 -0
- package/skills/build-label-page/scripts/label-page.ts +5 -0
- package/skills/build-label-page/scripts/page-template.html +488 -62
|
@@ -9,9 +9,11 @@ import {
|
|
|
9
9
|
chromium,
|
|
10
10
|
type Locator,
|
|
11
11
|
type Page,
|
|
12
|
+
selectors,
|
|
12
13
|
} from "playwright-core";
|
|
13
14
|
import { readFileSync } from "node:fs";
|
|
14
15
|
import { join } from "node:path";
|
|
16
|
+
import * as R from "remeda";
|
|
15
17
|
import { afterAll, test } from "vitest";
|
|
16
18
|
import { z } from "zod";
|
|
17
19
|
import { chromiumPath } from "../../../test/chromium.ts";
|
|
@@ -19,7 +21,9 @@ import {
|
|
|
19
21
|
APPROVAL_PRACTICE,
|
|
20
22
|
PRACTICE,
|
|
21
23
|
QUESTIONS,
|
|
24
|
+
ITEMS,
|
|
22
25
|
approval,
|
|
26
|
+
approvalCard,
|
|
23
27
|
build,
|
|
24
28
|
config,
|
|
25
29
|
setup,
|
|
@@ -32,6 +36,7 @@ const STORE = (
|
|
|
32
36
|
answers: Record<string, unknown> = {},
|
|
33
37
|
): string => `
|
|
34
38
|
window.__writes = [];
|
|
39
|
+
window.__ids = [];
|
|
35
40
|
(function () {
|
|
36
41
|
var docs = { answers: ${JSON.stringify(answers)}, groups: {}, practice: ${JSON.stringify(practice)} };
|
|
37
42
|
var subs = {};
|
|
@@ -44,6 +49,7 @@ const STORE = (
|
|
|
44
49
|
onSnapshot: function (cb) { (subs[n] = subs[n] || []).push(cb); snap(n); },
|
|
45
50
|
doc: function (id) { return { set: function (body) {
|
|
46
51
|
window.__writes.push(body);
|
|
52
|
+
window.__ids.push(n + "/" + id);
|
|
47
53
|
docs[n][id] = body;
|
|
48
54
|
return new Promise(function (r) { setTimeout(function () { r(); snap(n); }, ${String(echoMs)}); });
|
|
49
55
|
} }; }
|
|
@@ -358,9 +364,9 @@ async function onWitnessPage(
|
|
|
358
364
|
}
|
|
359
365
|
}
|
|
360
366
|
|
|
361
|
-
/** The code, the linter's message, the question, the rule docs'
|
|
367
|
+
/** The code, the linter's message, the question, the rule docs' heading: one read of the laid-out card,
|
|
362
368
|
* so a re-render between reads cannot mix two layouts. */
|
|
363
|
-
const LAYOUT_OF = ["pre.numbered", "p.said", ".ask", "
|
|
369
|
+
const LAYOUT_OF = ["pre.numbered", "p.said", ".ask", ".rulebox > h3"];
|
|
364
370
|
const Layout = z.array(z.object({ top: z.number(), bottom: z.number() }));
|
|
365
371
|
const layout = async (card: Locator) =>
|
|
366
372
|
Layout.parse(
|
|
@@ -375,7 +381,7 @@ const layout = async (card: Locator) =>
|
|
|
375
381
|
);
|
|
376
382
|
|
|
377
383
|
test(
|
|
378
|
-
"🔴 at phone width a witness card labels the caught line in its numbered code, asks its question right under that code,
|
|
384
|
+
"🔴 at phone width a witness card labels the caught line in its numbered code, asks its question right under that code, shows the rule docs and the settings in place, opens a long text on «more», and nothing pushes the page sideways",
|
|
379
385
|
async () => {
|
|
380
386
|
await onWitnessPage("browser-witness", async (page) => {
|
|
381
387
|
// Measure after the store's practice answers arrived: before them the practice cards sit above.
|
|
@@ -386,19 +392,24 @@ test(
|
|
|
386
392
|
await card.locator("pre.numbered .hit").textContent(),
|
|
387
393
|
'▶18print(f"seeded {len(rows)} rows")◀ this line',
|
|
388
394
|
);
|
|
389
|
-
// Guards: the rule docs, the files like it and the
|
|
395
|
+
// Guards: nothing a card needs is folded (#392); the rule docs, the files like it and the
|
|
396
|
+
// settings are shown in place, each under its heading.
|
|
390
397
|
assert.deepEqual(
|
|
391
398
|
[
|
|
392
399
|
await card.locator("details").count(),
|
|
393
|
-
await card.locator("
|
|
400
|
+
await card.locator(".rulebox > h3").allTextContents(),
|
|
401
|
+
],
|
|
402
|
+
[
|
|
403
|
+
0,
|
|
404
|
+
[
|
|
405
|
+
"What the linter rule does (official docs)",
|
|
406
|
+
"3 more file(s) with the same situation",
|
|
407
|
+
"Settings behind this card",
|
|
408
|
+
],
|
|
394
409
|
],
|
|
395
|
-
[3, 0],
|
|
396
410
|
);
|
|
397
411
|
// The question sits right under the code it is about: only the linter's message between them,
|
|
398
412
|
// and the rule's docs start below the answers.
|
|
399
|
-
const docsSummary = card.locator("summary", {
|
|
400
|
-
hasText: "What the linter rule does (official docs)",
|
|
401
|
-
});
|
|
402
413
|
const box = await layout(card);
|
|
403
414
|
const [code, said, ask, docs] = box;
|
|
404
415
|
assert.ok(code && said && ask && docs, JSON.stringify(box));
|
|
@@ -408,7 +419,6 @@ test(
|
|
|
408
419
|
);
|
|
409
420
|
assert.ok(ask.top - said.bottom < 40, JSON.stringify(box));
|
|
410
421
|
assert.ok(docs.top >= ask.bottom, JSON.stringify(box));
|
|
411
|
-
await docsSummary.click();
|
|
412
422
|
const why = card.locator(".clamped", { hasText: "Why:" });
|
|
413
423
|
const folded = (await why.boundingBox())?.height ?? 0;
|
|
414
424
|
await card.getByRole("button", { name: "more" }).first().click();
|
|
@@ -606,3 +616,744 @@ test(
|
|
|
606
616
|
},
|
|
607
617
|
BROWSER_TEST_MS,
|
|
608
618
|
);
|
|
619
|
+
|
|
620
|
+
/** The collection and document id of every write, in order. */
|
|
621
|
+
const writtenIds = async (page: Page): Promise<readonly string[]> =>
|
|
622
|
+
z.array(z.string()).parse(await page.evaluate("window.__ids"));
|
|
623
|
+
/** A page served from an origin of its own, so it has a localStorage that a reload keeps. */
|
|
624
|
+
async function served(page: Page, html: string): Promise<void> {
|
|
625
|
+
await page.route("http://label.test/", (route) =>
|
|
626
|
+
route.fulfill({ body: html, contentType: "text/html" }),
|
|
627
|
+
);
|
|
628
|
+
await page.goto("http://label.test/");
|
|
629
|
+
}
|
|
630
|
+
const prefixedPractice = (who: string) =>
|
|
631
|
+
R.mapKeys(PRACTICE_DONE, (k) => `${who}~${k}`);
|
|
632
|
+
|
|
633
|
+
test(
|
|
634
|
+
"🔴 without answerers the page asks nobody's name and writes the documents it always wrote, with no seconds",
|
|
635
|
+
async () => {
|
|
636
|
+
const { page: html } = build(
|
|
637
|
+
setup(
|
|
638
|
+
"browser-plain-ids",
|
|
639
|
+
approval({ items: ITEMS.slice(0, 2).map(approvalCard) }),
|
|
640
|
+
),
|
|
641
|
+
);
|
|
642
|
+
const page = await (await sharedBrowser()).newPage();
|
|
643
|
+
try {
|
|
644
|
+
await page.addInitScript(STORE(PRACTICE_DONE, 20));
|
|
645
|
+
await page.goto("about:blank");
|
|
646
|
+
await page.setContent(html, { waitUntil: "domcontentloaded" });
|
|
647
|
+
assert.equal(await page.getByText("Who is answering?").count(), 0);
|
|
648
|
+
await page.getByRole("button", { name: /Keep/ }).first().click();
|
|
649
|
+
await page.waitForTimeout(100);
|
|
650
|
+
assert.deepEqual(await writtenIds(page), ["answers/G001"]);
|
|
651
|
+
assert.deepEqual(Object.keys((await writes(page))[0] ?? {}).sort(), [
|
|
652
|
+
"answers",
|
|
653
|
+
"reasons",
|
|
654
|
+
"savedAt",
|
|
655
|
+
"source",
|
|
656
|
+
]);
|
|
657
|
+
} finally {
|
|
658
|
+
await page.context().close();
|
|
659
|
+
}
|
|
660
|
+
},
|
|
661
|
+
BROWSER_TEST_MS,
|
|
662
|
+
);
|
|
663
|
+
|
|
664
|
+
/** Three approval cards: the first two for both answerers, the third for `author` only. */
|
|
665
|
+
const TWO_PAGE = (over: Record<string, unknown> = {}) =>
|
|
666
|
+
approval({
|
|
667
|
+
answerers: ["author", "second"],
|
|
668
|
+
items: ITEMS.slice(0, 3)
|
|
669
|
+
.map(approvalCard)
|
|
670
|
+
.map((i, n) => (n === 2 ? R.merge(i, { answerers: ["author"] }) : i)),
|
|
671
|
+
...over,
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
test(
|
|
675
|
+
"🔴 with answerers the page asks who is answering, remembers it, writes and reads only that answerer's documents, and can switch",
|
|
676
|
+
async () => {
|
|
677
|
+
const dir = setup("browser-answerers", TWO_PAGE());
|
|
678
|
+
const { page: html, key } = build(dir);
|
|
679
|
+
const third = Object.entries(key.ids).find(
|
|
680
|
+
([, v]) => v.source_id === "case-1002",
|
|
681
|
+
)?.[0];
|
|
682
|
+
const [first] = Object.keys(key.ids).filter((n) => n !== third);
|
|
683
|
+
assert.ok(third !== undefined && first !== undefined);
|
|
684
|
+
const page = await (await sharedBrowser()).newPage();
|
|
685
|
+
try {
|
|
686
|
+
await page.addInitScript(
|
|
687
|
+
STORE(prefixedPractice("second"), 20, {
|
|
688
|
+
[`author~${first}`]: { answers: { keep: "keep" }, source: "page" },
|
|
689
|
+
}),
|
|
690
|
+
);
|
|
691
|
+
await served(page, html);
|
|
692
|
+
assert.equal(await page.getByText("Who is answering?").count(), 1);
|
|
693
|
+
assert.equal(await page.locator("article.item").count(), 0);
|
|
694
|
+
await page.getByRole("button", { name: "second", exact: true }).click();
|
|
695
|
+
// Guards: second sees two items, none answered: author's answer is not theirs.
|
|
696
|
+
assert.equal(await page.locator("article.item").count(), 2);
|
|
697
|
+
assert.equal(await page.getByText("0 of 2 items complete").count(), 1);
|
|
698
|
+
await page
|
|
699
|
+
.locator("article.item", { hasText: first })
|
|
700
|
+
.getByRole("button", { name: /Keep/ })
|
|
701
|
+
.click();
|
|
702
|
+
await page.waitForTimeout(100);
|
|
703
|
+
assert.deepEqual(await writtenIds(page), [`answers/second~${first}`]);
|
|
704
|
+
// A reload remembers who is answering.
|
|
705
|
+
await page.reload();
|
|
706
|
+
// (The stand-in store starts afresh on a reload: only the name is checked here.)
|
|
707
|
+
assert.equal(await page.getByText("Answering as second").count(), 1);
|
|
708
|
+
await page.getByRole("button", { name: "Switch" }).click();
|
|
709
|
+
await page.getByRole("button", { name: "author", exact: true }).click();
|
|
710
|
+
// author has not done the practice: it opens, and its answers go under author's name.
|
|
711
|
+
assert.equal(
|
|
712
|
+
await page.getByText("Practice: 0 of 3 answered").count(),
|
|
713
|
+
1,
|
|
714
|
+
);
|
|
715
|
+
await page
|
|
716
|
+
.locator("article.item")
|
|
717
|
+
.first()
|
|
718
|
+
.getByRole("button", { name: /Keep/ })
|
|
719
|
+
.click();
|
|
720
|
+
await page.waitForTimeout(100);
|
|
721
|
+
assert.equal((await writtenIds(page)).at(-1), "practice/author~r1-P1");
|
|
722
|
+
} finally {
|
|
723
|
+
await page.context().close();
|
|
724
|
+
}
|
|
725
|
+
},
|
|
726
|
+
BROWSER_TEST_MS,
|
|
727
|
+
);
|
|
728
|
+
|
|
729
|
+
test(
|
|
730
|
+
"🔴 with answerers and no localStorage the page still works: it asks, and writes under the name picked",
|
|
731
|
+
async () => {
|
|
732
|
+
const { page: html } = build(setup("browser-answerers-nols", TWO_PAGE()));
|
|
733
|
+
const page = await (await sharedBrowser()).newPage();
|
|
734
|
+
try {
|
|
735
|
+
await page.addInitScript(
|
|
736
|
+
`Object.defineProperty(window, "localStorage", { get: function () { throw new Error("denied"); } });`,
|
|
737
|
+
);
|
|
738
|
+
await page.addInitScript(STORE(prefixedPractice("author"), 20));
|
|
739
|
+
await served(page, html);
|
|
740
|
+
await page.getByRole("button", { name: "author", exact: true }).click();
|
|
741
|
+
assert.equal(await page.locator("article.item").count(), 3);
|
|
742
|
+
await page
|
|
743
|
+
.locator("article.item", { hasText: "G001" })
|
|
744
|
+
.getByRole("button", { name: /Keep/ })
|
|
745
|
+
.click();
|
|
746
|
+
await page.waitForTimeout(100);
|
|
747
|
+
assert.deepEqual(await writtenIds(page), ["answers/author~G001"]);
|
|
748
|
+
} finally {
|
|
749
|
+
await page.context().close();
|
|
750
|
+
}
|
|
751
|
+
},
|
|
752
|
+
BROWSER_TEST_MS,
|
|
753
|
+
);
|
|
754
|
+
|
|
755
|
+
test(
|
|
756
|
+
"🔴 with min_seconds a card's answer saves only after the card was that long on screen; a card never on screen stays locked; the seconds are stored",
|
|
757
|
+
async () => {
|
|
758
|
+
const { page: html } = build(
|
|
759
|
+
setup("browser-dwell", approval({ min_seconds: 2 })),
|
|
760
|
+
);
|
|
761
|
+
const page = await (
|
|
762
|
+
await sharedBrowser()
|
|
763
|
+
).newPage({ viewport: { width: 400, height: 700 } });
|
|
764
|
+
try {
|
|
765
|
+
await page.addInitScript(STORE(PRACTICE_DONE, 20));
|
|
766
|
+
await page.goto("about:blank");
|
|
767
|
+
await page.setContent(html, { waitUntil: "domcontentloaded" });
|
|
768
|
+
const cards = page.locator("article.item");
|
|
769
|
+
const firstCard = cards.first();
|
|
770
|
+
const lastCard = cards.last();
|
|
771
|
+
await firstCard.scrollIntoViewIfNeeded();
|
|
772
|
+
const keep = firstCard.getByRole("button", { name: /Keep/ });
|
|
773
|
+
assert.equal(await keep.isDisabled(), true);
|
|
774
|
+
assert.match((await firstCard.textContent()) ?? "", /answer in [12] s/);
|
|
775
|
+
await keep.click({ force: true });
|
|
776
|
+
assert.deepEqual(await writes(page), [], "nothing saves before the time");
|
|
777
|
+
await page.waitForTimeout(2600);
|
|
778
|
+
assert.equal(await keep.isDisabled(), false);
|
|
779
|
+
assert.equal(
|
|
780
|
+
await lastCard.getByRole("button", { name: /Keep/ }).isDisabled(),
|
|
781
|
+
true,
|
|
782
|
+
"the last card was never on screen",
|
|
783
|
+
);
|
|
784
|
+
await keep.click();
|
|
785
|
+
await page.waitForTimeout(100);
|
|
786
|
+
const [stored] = z
|
|
787
|
+
.array(z.object({ seconds: z.number().int() }).loose())
|
|
788
|
+
.parse(await page.evaluate("window.__writes"));
|
|
789
|
+
assert.ok(
|
|
790
|
+
stored !== undefined && stored.seconds >= 2 && stored.seconds <= 4,
|
|
791
|
+
JSON.stringify(stored),
|
|
792
|
+
);
|
|
793
|
+
await lastCard.scrollIntoViewIfNeeded();
|
|
794
|
+
await page.waitForTimeout(2600);
|
|
795
|
+
assert.equal(
|
|
796
|
+
await lastCard.getByRole("button", { name: /Keep/ }).isDisabled(),
|
|
797
|
+
false,
|
|
798
|
+
);
|
|
799
|
+
} finally {
|
|
800
|
+
await page.context().close();
|
|
801
|
+
}
|
|
802
|
+
},
|
|
803
|
+
BROWSER_TEST_MS,
|
|
804
|
+
);
|
|
805
|
+
|
|
806
|
+
test(
|
|
807
|
+
"🔴 with min_seconds a tall card's clock stops when less than its share is in view, even between the observer's steps",
|
|
808
|
+
async () => {
|
|
809
|
+
const { page: html } = build(
|
|
810
|
+
setup("browser-dwell-tall", approval({ min_seconds: 2 })),
|
|
811
|
+
);
|
|
812
|
+
const page = await (
|
|
813
|
+
await sharedBrowser()
|
|
814
|
+
).newPage({ viewport: { width: 400, height: 300 } });
|
|
815
|
+
try {
|
|
816
|
+
await page.addInitScript(STORE(PRACTICE_DONE, 20));
|
|
817
|
+
await page.goto("about:blank");
|
|
818
|
+
await page.setContent(html, { waitUntil: "domcontentloaded" });
|
|
819
|
+
await page.addStyleTag({
|
|
820
|
+
content: "article.item { min-height: 1200px; box-sizing: border-box; }",
|
|
821
|
+
});
|
|
822
|
+
const lastCard = page.locator("article.item").last();
|
|
823
|
+
const keep = lastCard.getByRole("button", { name: /Keep/ });
|
|
824
|
+
await keep.waitFor();
|
|
825
|
+
// Show `px` of the last card at the bottom of the viewport. The card is 1200 px and the
|
|
826
|
+
// viewport 300 px, so it is on screen with half the viewport, 150 px (12.5 %), in view.
|
|
827
|
+
const show = (px: number) =>
|
|
828
|
+
page.evaluate(
|
|
829
|
+
`(() => { const c = document.querySelectorAll("article.item"); const r = c[c.length - 1].getBoundingClientRect(); window.scrollTo(0, window.scrollY + r.top - window.innerHeight + ${String(px)}); })()`,
|
|
830
|
+
);
|
|
831
|
+
await show(170); // 14.2 %: above the share
|
|
832
|
+
await page.waitForTimeout(200);
|
|
833
|
+
await show(140); // 11.7 %: below it, and no 5 % step is crossed on the way
|
|
834
|
+
await page.waitForTimeout(2600);
|
|
835
|
+
assert.equal(
|
|
836
|
+
await keep.isDisabled(),
|
|
837
|
+
true,
|
|
838
|
+
"140 px of the card in view for 2.6 s does not count",
|
|
839
|
+
);
|
|
840
|
+
await show(200);
|
|
841
|
+
await page.waitForTimeout(2600);
|
|
842
|
+
assert.equal(
|
|
843
|
+
await keep.isDisabled(),
|
|
844
|
+
false,
|
|
845
|
+
"200 px in view for 2.6 s counts",
|
|
846
|
+
);
|
|
847
|
+
} finally {
|
|
848
|
+
await page.context().close();
|
|
849
|
+
}
|
|
850
|
+
},
|
|
851
|
+
BROWSER_TEST_MS,
|
|
852
|
+
);
|
|
853
|
+
|
|
854
|
+
test(
|
|
855
|
+
"🔴 with min_seconds a card over 20 viewports tall still unlocks when it fills the screen, and one that grows is judged at its new height",
|
|
856
|
+
async () => {
|
|
857
|
+
const { page: html } = build(
|
|
858
|
+
setup("browser-dwell-huge", approval({ min_seconds: 2 })),
|
|
859
|
+
);
|
|
860
|
+
const page = await (
|
|
861
|
+
await sharedBrowser()
|
|
862
|
+
).newPage({ viewport: { width: 400, height: 300 } });
|
|
863
|
+
try {
|
|
864
|
+
await page.addInitScript(STORE(PRACTICE_DONE, 20));
|
|
865
|
+
await page.goto("about:blank");
|
|
866
|
+
await page.setContent(html, { waitUntil: "domcontentloaded" });
|
|
867
|
+
const cards = page.locator("article.item");
|
|
868
|
+
const huge = cards.last();
|
|
869
|
+
await huge.getByRole("button", { name: /Keep/ }).waitFor();
|
|
870
|
+
// The card grows after it was drawn and watched, with no render.
|
|
871
|
+
await huge.evaluate((el) => {
|
|
872
|
+
el.style.minHeight = "7000px";
|
|
873
|
+
el.scrollIntoView({ block: "start" });
|
|
874
|
+
window.scrollBy(0, 2000);
|
|
875
|
+
});
|
|
876
|
+
await page.waitForTimeout(2600);
|
|
877
|
+
assert.equal(
|
|
878
|
+
await huge.getByRole("button", { name: /Keep/ }).isDisabled(),
|
|
879
|
+
false,
|
|
880
|
+
"a 7000 px card filling a 300 px viewport for 2.6 s counts",
|
|
881
|
+
);
|
|
882
|
+
} finally {
|
|
883
|
+
await page.context().close();
|
|
884
|
+
}
|
|
885
|
+
},
|
|
886
|
+
BROWSER_TEST_MS,
|
|
887
|
+
);
|
|
888
|
+
|
|
889
|
+
test(
|
|
890
|
+
"🔴 with min_seconds a page that loaded in a hidden tab starts the clock of a card in view when the tab comes back",
|
|
891
|
+
async () => {
|
|
892
|
+
const { page: html } = build(
|
|
893
|
+
setup("browser-dwell-hidden", approval({ min_seconds: 2 })),
|
|
894
|
+
);
|
|
895
|
+
const page = await (
|
|
896
|
+
await sharedBrowser()
|
|
897
|
+
).newPage({ viewport: { width: 400, height: 700 } });
|
|
898
|
+
try {
|
|
899
|
+
await page.addInitScript(
|
|
900
|
+
`window.__vis = "hidden"; Object.defineProperty(Document.prototype, "visibilityState", { configurable: true, get: function () { return window.__vis; } });`,
|
|
901
|
+
);
|
|
902
|
+
await page.addInitScript(STORE(PRACTICE_DONE, 20));
|
|
903
|
+
await page.goto("about:blank");
|
|
904
|
+
await page.setContent(html, { waitUntil: "domcontentloaded" });
|
|
905
|
+
const firstCard = page.locator("article.item").first();
|
|
906
|
+
await firstCard.scrollIntoViewIfNeeded();
|
|
907
|
+
await page.waitForTimeout(300);
|
|
908
|
+
await page.evaluate(
|
|
909
|
+
`window.__vis = "visible"; document.dispatchEvent(new Event("visibilitychange"));`,
|
|
910
|
+
);
|
|
911
|
+
await page.waitForTimeout(2600);
|
|
912
|
+
assert.equal(
|
|
913
|
+
await firstCard.getByRole("button", { name: /Keep/ }).isDisabled(),
|
|
914
|
+
false,
|
|
915
|
+
"the card was in view for 2.6 s after the tab came back",
|
|
916
|
+
);
|
|
917
|
+
} finally {
|
|
918
|
+
await page.context().close();
|
|
919
|
+
}
|
|
920
|
+
},
|
|
921
|
+
BROWSER_TEST_MS,
|
|
922
|
+
);
|
|
923
|
+
|
|
924
|
+
// ── the approved card (#392): a code-review note on the marked line, tap-to-see notes on terms ──
|
|
925
|
+
// Every element these tests touch carries `data-test`, found only by getByTestId; its values are the
|
|
926
|
+
// JSON in `data-test-meta`. A text that is content (the annotation, the quote) is read against the
|
|
927
|
+
// fixture's own value, never retyped here.
|
|
928
|
+
selectors.setTestIdAttribute("data-test");
|
|
929
|
+
const V7_FIXTURE = join(import.meta.dirname, "fixtures", "v7-cards-items.json");
|
|
930
|
+
const V7_CARD = z
|
|
931
|
+
.object({
|
|
932
|
+
source_id: z.string(),
|
|
933
|
+
terms: z.array(
|
|
934
|
+
z.object({
|
|
935
|
+
term: z.string(),
|
|
936
|
+
note: z.array(
|
|
937
|
+
z
|
|
938
|
+
.object({
|
|
939
|
+
quote: z.string().optional(),
|
|
940
|
+
url: z.string(),
|
|
941
|
+
url_label: z.string(),
|
|
942
|
+
})
|
|
943
|
+
.loose(),
|
|
944
|
+
),
|
|
945
|
+
}),
|
|
946
|
+
),
|
|
947
|
+
excerpt: z.object({
|
|
948
|
+
gutter_header: z.string().optional(),
|
|
949
|
+
annotation: z.string(),
|
|
950
|
+
lines: z.array(
|
|
951
|
+
z.object({
|
|
952
|
+
n: z.number(),
|
|
953
|
+
mark: z.boolean(),
|
|
954
|
+
context: z.boolean().optional(),
|
|
955
|
+
gutter: z.string().optional(),
|
|
956
|
+
}),
|
|
957
|
+
),
|
|
958
|
+
}),
|
|
959
|
+
answer_notes: z.object({ forbid: z.record(z.string(), z.string()) }),
|
|
960
|
+
})
|
|
961
|
+
.loose();
|
|
962
|
+
const V7_CONFIG = z
|
|
963
|
+
.object({ practice: z.array(z.unknown()), items: z.array(z.unknown()) })
|
|
964
|
+
.loose()
|
|
965
|
+
.parse(JSON.parse(readFileSync(V7_FIXTURE, "utf8")));
|
|
966
|
+
const V7_CARDS = z.array(V7_CARD).parse(V7_CONFIG.items);
|
|
967
|
+
/** The fixture's card of that source id. */
|
|
968
|
+
const fixtureCard = (sourceId: string) => {
|
|
969
|
+
const found = V7_CARDS.find((i) => i.source_id === sourceId);
|
|
970
|
+
assert.ok(found !== undefined, sourceId);
|
|
971
|
+
return found;
|
|
972
|
+
};
|
|
973
|
+
/** A fixture text as the page shows it: its `backticks` are code spans, not characters. */
|
|
974
|
+
const unticked = (text: string): string => text.split("`").join("");
|
|
975
|
+
/** The values an element carries in `data-test-meta`. */
|
|
976
|
+
const metaOf = async (l: Locator): Promise<unknown> =>
|
|
977
|
+
JSON.parse((await l.getAttribute("data-test-meta")) ?? "null");
|
|
978
|
+
/** The elements of one kind, each with its values. */
|
|
979
|
+
const allByKind = async (scope: Page | Locator, kind: string) =>
|
|
980
|
+
Promise.all(
|
|
981
|
+
(await scope.getByTestId(kind).all()).map(async (loc) => ({
|
|
982
|
+
loc,
|
|
983
|
+
meta: await metaOf(loc),
|
|
984
|
+
})),
|
|
985
|
+
);
|
|
986
|
+
const Id = z.object({ id: z.string() });
|
|
987
|
+
const Term = z.object({ term: z.string() });
|
|
988
|
+
const Answer = z.object({ question: z.string(), value: z.string() });
|
|
989
|
+
const CodeLine = z.object({
|
|
990
|
+
line: z.number(),
|
|
991
|
+
marked: z.boolean(),
|
|
992
|
+
gutter: z.string(),
|
|
993
|
+
context: z.boolean(),
|
|
994
|
+
});
|
|
995
|
+
/** The three-card fixture, practice done, without its time on screen, at 400 px wide. */
|
|
996
|
+
async function onV7Page(
|
|
997
|
+
name: string,
|
|
998
|
+
steps: (
|
|
999
|
+
page: Page,
|
|
1000
|
+
cardOf: (sourceId: string) => Promise<Locator>,
|
|
1001
|
+
) => Promise<void>,
|
|
1002
|
+
withTime = false,
|
|
1003
|
+
): Promise<void> {
|
|
1004
|
+
const { page: html, key } = build(
|
|
1005
|
+
setup(name, withTime ? V7_CONFIG : R.omit(V7_CONFIG, ["min_seconds"])),
|
|
1006
|
+
);
|
|
1007
|
+
const done = ["forbid", "allow", "elsewhere"];
|
|
1008
|
+
const page = await (
|
|
1009
|
+
await sharedBrowser()
|
|
1010
|
+
).newPage({ viewport: { width: 400, height: 800 } });
|
|
1011
|
+
try {
|
|
1012
|
+
await page.addInitScript(
|
|
1013
|
+
STORE(
|
|
1014
|
+
Object.fromEntries(
|
|
1015
|
+
done.map((v, n) => [
|
|
1016
|
+
`r1-P${String(n + 1)}`,
|
|
1017
|
+
{ answers: { forbid: v }, source: "page" },
|
|
1018
|
+
]),
|
|
1019
|
+
),
|
|
1020
|
+
20,
|
|
1021
|
+
),
|
|
1022
|
+
);
|
|
1023
|
+
await page.goto("about:blank");
|
|
1024
|
+
await page.setContent(html, { waitUntil: "domcontentloaded" });
|
|
1025
|
+
await page.getByTestId("item").first().waitFor();
|
|
1026
|
+
const cardOf = async (sourceId: string) => {
|
|
1027
|
+
const id = Object.entries(key.ids).find(
|
|
1028
|
+
([, v]) => v.source_id === sourceId,
|
|
1029
|
+
)?.[0];
|
|
1030
|
+
const found = (await allByKind(page, "item")).find(
|
|
1031
|
+
(c) => Id.parse(c.meta).id === id,
|
|
1032
|
+
);
|
|
1033
|
+
assert.ok(found !== undefined, `${sourceId} is drawn`);
|
|
1034
|
+
return found.loc;
|
|
1035
|
+
};
|
|
1036
|
+
await steps(page, cardOf);
|
|
1037
|
+
} finally {
|
|
1038
|
+
await page.context().close();
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
/** The term of that text on a card. */
|
|
1042
|
+
const termOf = async (card: Locator, term: string) => {
|
|
1043
|
+
const found = (await allByKind(card, "term")).find(
|
|
1044
|
+
(t) => Term.parse(t.meta).term === term,
|
|
1045
|
+
);
|
|
1046
|
+
assert.ok(found !== undefined, `${term} is a button`);
|
|
1047
|
+
return found.loc;
|
|
1048
|
+
};
|
|
1049
|
+
const answerOf = async (card: Locator, value: string) => {
|
|
1050
|
+
const found = (await allByKind(card, "answer")).find(
|
|
1051
|
+
(a) => Answer.parse(a.meta).value === value,
|
|
1052
|
+
);
|
|
1053
|
+
assert.ok(found !== undefined, `${value} is an answer`);
|
|
1054
|
+
return found.loc;
|
|
1055
|
+
};
|
|
1056
|
+
/** The open note: whether it shows, which term, its box, and the terms marked open. */
|
|
1057
|
+
const Tip = z.object({ term: z.string(), open: z.boolean() });
|
|
1058
|
+
const tipNow = async (page: Page) => {
|
|
1059
|
+
const tip = page.getByTestId("tip");
|
|
1060
|
+
const drawn = (await tip.count()) > 0;
|
|
1061
|
+
const state = drawn
|
|
1062
|
+
? Tip.parse(await metaOf(tip))
|
|
1063
|
+
: { term: "", open: false };
|
|
1064
|
+
const box = drawn && state.open ? await tip.boundingBox() : null;
|
|
1065
|
+
const expanded = await Promise.all(
|
|
1066
|
+
(await allByKind(page, "term")).map(async (t) => ({
|
|
1067
|
+
term: Term.parse(t.meta).term,
|
|
1068
|
+
open: (await t.loc.getAttribute("aria-expanded")) === "true",
|
|
1069
|
+
})),
|
|
1070
|
+
);
|
|
1071
|
+
return {
|
|
1072
|
+
...state,
|
|
1073
|
+
left: box?.x ?? -1,
|
|
1074
|
+
right: box ? box.x + box.width : -1,
|
|
1075
|
+
width: box?.width ?? -1,
|
|
1076
|
+
expanded: expanded.filter((t) => t.open).map((t) => t.term),
|
|
1077
|
+
};
|
|
1078
|
+
};
|
|
1079
|
+
|
|
1080
|
+
/** Wait until the note reports itself closed (it closes on its own schedule). */
|
|
1081
|
+
const noteClosed = (page: Page) =>
|
|
1082
|
+
page.waitForFunction(
|
|
1083
|
+
`JSON.parse(document.querySelector('[data-test="tip"]').getAttribute("data-test-meta")).open === false`,
|
|
1084
|
+
undefined,
|
|
1085
|
+
{ timeout: 5_000 },
|
|
1086
|
+
);
|
|
1087
|
+
|
|
1088
|
+
test(
|
|
1089
|
+
"🔴 a dotted term opens one floating note on a tap, with its quote and its source link, anchored and inside a 400 px viewport",
|
|
1090
|
+
async () => {
|
|
1091
|
+
await onV7Page("browser-v7-tip", async (page, cardOf) => {
|
|
1092
|
+
const docstring = fixtureCard("card-docstring");
|
|
1093
|
+
const google = docstring.terms.find((t) => t.term === "Google-style");
|
|
1094
|
+
assert.ok(google !== undefined);
|
|
1095
|
+
await (await termOf(await cardOf("card-docstring"), google.term)).click();
|
|
1096
|
+
const tip = await tipNow(page);
|
|
1097
|
+
assert.deepEqual(
|
|
1098
|
+
[tip.term, tip.open, tip.expanded],
|
|
1099
|
+
[google.term, true, [google.term]],
|
|
1100
|
+
);
|
|
1101
|
+
assert.deepEqual(
|
|
1102
|
+
[
|
|
1103
|
+
await page.getByTestId("tip-quote").allTextContents(),
|
|
1104
|
+
(await allByKind(page, "tip-link")).map((l) => l.meta),
|
|
1105
|
+
await page.getByTestId("tip-link").allTextContents(),
|
|
1106
|
+
],
|
|
1107
|
+
[
|
|
1108
|
+
google.note.flatMap((e) => e.quote ?? []),
|
|
1109
|
+
google.note.map((e) => ({ url: e.url })),
|
|
1110
|
+
google.note.map((e) => e.url_label),
|
|
1111
|
+
],
|
|
1112
|
+
);
|
|
1113
|
+
// Inside the viewport and at most 300 px wide, for a term near either edge.
|
|
1114
|
+
const edges = [];
|
|
1115
|
+
for (const [source, term] of [
|
|
1116
|
+
["card-docstring", ".github/copilot-instructions.md"],
|
|
1117
|
+
["card-depth", "guard clauses"],
|
|
1118
|
+
["card-call", "document.getElementById"],
|
|
1119
|
+
] as const) {
|
|
1120
|
+
// Each term is measured on its own: the note of the one before may cover this one.
|
|
1121
|
+
await page.keyboard.press("Escape");
|
|
1122
|
+
await noteClosed(page);
|
|
1123
|
+
await (await termOf(await cardOf(source), term)).click();
|
|
1124
|
+
const t = await tipNow(page);
|
|
1125
|
+
edges.push([t.term, t.left >= 0 && t.right <= 400 && t.width <= 300]);
|
|
1126
|
+
}
|
|
1127
|
+
assert.deepEqual(edges, [
|
|
1128
|
+
[".github/copilot-instructions.md", true],
|
|
1129
|
+
["guard clauses", true],
|
|
1130
|
+
["document.getElementById", true],
|
|
1131
|
+
]);
|
|
1132
|
+
});
|
|
1133
|
+
},
|
|
1134
|
+
BROWSER_TEST_MS,
|
|
1135
|
+
);
|
|
1136
|
+
|
|
1137
|
+
test(
|
|
1138
|
+
"🔴 a tap outside the open note closes it and answers nothing; Esc closes it; a second term closes the first",
|
|
1139
|
+
async () => {
|
|
1140
|
+
await onV7Page("browser-v7-close", async (page, cardOf) => {
|
|
1141
|
+
const depth = await cardOf("card-depth");
|
|
1142
|
+
// The note covers the first answers; the tap lands on an answer it leaves visible.
|
|
1143
|
+
await (await termOf(depth, "guard clauses")).click();
|
|
1144
|
+
await (await answerOf(depth, "unsettled")).click();
|
|
1145
|
+
await page.waitForTimeout(100);
|
|
1146
|
+
assert.deepEqual(
|
|
1147
|
+
[(await tipNow(page)).open, (await writes(page)).length],
|
|
1148
|
+
[false, 0],
|
|
1149
|
+
);
|
|
1150
|
+
await (await termOf(await cardOf("card-call"), "AGENTS.md")).click();
|
|
1151
|
+
await page.keyboard.press("Escape");
|
|
1152
|
+
assert.equal((await tipNow(page)).open, false);
|
|
1153
|
+
const docstring = await cardOf("card-docstring");
|
|
1154
|
+
const d413 = fixtureCard("card-docstring").terms.find(
|
|
1155
|
+
(t) => t.term === "D413",
|
|
1156
|
+
);
|
|
1157
|
+
assert.ok(d413 !== undefined);
|
|
1158
|
+
await (await termOf(docstring, "Ruff")).click();
|
|
1159
|
+
await (await termOf(docstring, d413.term)).click();
|
|
1160
|
+
const t = await tipNow(page);
|
|
1161
|
+
assert.deepEqual(
|
|
1162
|
+
[
|
|
1163
|
+
t.open,
|
|
1164
|
+
t.expanded,
|
|
1165
|
+
await page.getByTestId("tip-quote").allTextContents(),
|
|
1166
|
+
],
|
|
1167
|
+
[true, [d413.term], d413.note.flatMap((e) => e.quote ?? [])],
|
|
1168
|
+
);
|
|
1169
|
+
// With the note closed, the same answer saves.
|
|
1170
|
+
await page.keyboard.press("Escape");
|
|
1171
|
+
await (await answerOf(depth, "unsettled")).click();
|
|
1172
|
+
await page.waitForTimeout(100);
|
|
1173
|
+
assert.equal((await writes(page)).length, 1);
|
|
1174
|
+
});
|
|
1175
|
+
},
|
|
1176
|
+
BROWSER_TEST_MS,
|
|
1177
|
+
);
|
|
1178
|
+
|
|
1179
|
+
test(
|
|
1180
|
+
"🔴 a note opened by hover stays open while the pointer crosses the gap to it, keeps its link reachable, and closes after the pointer leaves for good",
|
|
1181
|
+
async () => {
|
|
1182
|
+
await onV7Page("browser-v7-hover", async (page, cardOf) => {
|
|
1183
|
+
const docstring = await cardOf("card-docstring");
|
|
1184
|
+
const google = fixtureCard("card-docstring").terms.find(
|
|
1185
|
+
(t) => t.term === "Google-style",
|
|
1186
|
+
);
|
|
1187
|
+
assert.ok(google !== undefined);
|
|
1188
|
+
const term = await termOf(docstring, google.term);
|
|
1189
|
+
// A slow runner must not outrun the grace delay while the pointer crosses the gap.
|
|
1190
|
+
await page.evaluate("window.__tipGraceMs = 5000");
|
|
1191
|
+
// At its top left corner: a term that wraps has a box whose middle may lie over another term.
|
|
1192
|
+
await term.hover({ position: { x: 4, y: 4 } });
|
|
1193
|
+
assert.deepEqual(
|
|
1194
|
+
[(await tipNow(page)).term, (await tipNow(page)).open],
|
|
1195
|
+
[google.term, true],
|
|
1196
|
+
);
|
|
1197
|
+
// Straight down from the term, in small steps, through the gap into the note. Only the gap
|
|
1198
|
+
// lies between them on this path, so no other term opens on the way.
|
|
1199
|
+
const tip = page.getByTestId("tip");
|
|
1200
|
+
const from = await term.boundingBox();
|
|
1201
|
+
const to = await tip.boundingBox();
|
|
1202
|
+
assert.ok(from !== null && to !== null);
|
|
1203
|
+
const top = from.y + from.height;
|
|
1204
|
+
// The other terms do not react while the pointer crosses: only the gap is under test, and
|
|
1205
|
+
// which term lies in it depends on how the line wraps. The arrow bridges the gap at the
|
|
1206
|
+
// middle of the term, so the crossing is at its right end.
|
|
1207
|
+
await page.addStyleTag({
|
|
1208
|
+
content: 'button.term[aria-expanded="false"] { pointer-events: none; }',
|
|
1209
|
+
});
|
|
1210
|
+
const x = from.x + from.width - 8;
|
|
1211
|
+
assert.ok(
|
|
1212
|
+
to.y > top && x > to.x && x < to.x + to.width,
|
|
1213
|
+
"the note lies below the term and covers that column",
|
|
1214
|
+
);
|
|
1215
|
+
await page.mouse.move(x, from.y + from.height - 1);
|
|
1216
|
+
for (const k of R.range(0, Math.ceil((to.y + 10 - top) / 3))) {
|
|
1217
|
+
await page.mouse.move(x, top + 3 * k);
|
|
1218
|
+
}
|
|
1219
|
+
await page.mouse.move(x, to.y + 10);
|
|
1220
|
+
const inside = await tipNow(page);
|
|
1221
|
+
assert.deepEqual(
|
|
1222
|
+
[inside.term, inside.open, inside.expanded],
|
|
1223
|
+
[google.term, true, [google.term]],
|
|
1224
|
+
);
|
|
1225
|
+
// Guards: the link answers a click at its own position, nothing covers it.
|
|
1226
|
+
await page.getByTestId("tip-link").first().click({ trial: true });
|
|
1227
|
+
// Far from the term and the note, it closes by itself once the grace delay has run out.
|
|
1228
|
+
await page.evaluate("window.__tipGraceMs = 50");
|
|
1229
|
+
await page.mouse.move(2, 2, { steps: 20 });
|
|
1230
|
+
await noteClosed(page);
|
|
1231
|
+
assert.equal((await tipNow(page)).open, false);
|
|
1232
|
+
});
|
|
1233
|
+
},
|
|
1234
|
+
BROWSER_TEST_MS,
|
|
1235
|
+
);
|
|
1236
|
+
|
|
1237
|
+
/** What the depth card shows around its marked line, read from the page. */
|
|
1238
|
+
async function annotatedFacts(depth: Locator) {
|
|
1239
|
+
const lines = await allByKind(depth, "code-line");
|
|
1240
|
+
const header = depth.getByTestId("code-header");
|
|
1241
|
+
const anno = depth.getByTestId("annotation");
|
|
1242
|
+
const subs = await allByKind(depth, "answer-sub");
|
|
1243
|
+
return {
|
|
1244
|
+
annotation: [await metaOf(anno), await anno.textContent()],
|
|
1245
|
+
header: [
|
|
1246
|
+
await metaOf(header),
|
|
1247
|
+
await header.getByTestId("gutter").textContent(),
|
|
1248
|
+
],
|
|
1249
|
+
lines: lines.map((l) => l.meta),
|
|
1250
|
+
gutters: await Promise.all(
|
|
1251
|
+
lines.map((l) => l.loc.getByTestId("gutter").textContent()),
|
|
1252
|
+
),
|
|
1253
|
+
gaps: (await allByKind(depth, "gap")).map((g) => g.meta),
|
|
1254
|
+
subs: await Promise.all(
|
|
1255
|
+
subs.map(async (s) => [s.meta, await s.loc.textContent()]),
|
|
1256
|
+
),
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
/** The same facts, as the fixture states them. */
|
|
1260
|
+
function annotatedExpected(fixture: z.infer<typeof V7_CARD>) {
|
|
1261
|
+
const rows = fixture.excerpt.lines;
|
|
1262
|
+
return {
|
|
1263
|
+
annotation: [
|
|
1264
|
+
{ line: rows.find((l) => l.mark)?.n },
|
|
1265
|
+
unticked(fixture.excerpt.annotation),
|
|
1266
|
+
],
|
|
1267
|
+
header: [
|
|
1268
|
+
{ gutter: fixture.excerpt.gutter_header },
|
|
1269
|
+
fixture.excerpt.gutter_header,
|
|
1270
|
+
],
|
|
1271
|
+
lines: rows.map((l) => ({
|
|
1272
|
+
line: l.n,
|
|
1273
|
+
marked: l.mark,
|
|
1274
|
+
gutter: l.gutter ?? "",
|
|
1275
|
+
context: l.context ?? false,
|
|
1276
|
+
})),
|
|
1277
|
+
gutters: rows.map((l) => l.gutter ?? ""),
|
|
1278
|
+
gaps: R.zip(rows, rows.slice(1))
|
|
1279
|
+
.filter(([a, b]) => b.n > a.n + 1)
|
|
1280
|
+
.map(([a, b]) => ({ from: a.n + 1, to: b.n - 1 })),
|
|
1281
|
+
subs: Object.entries(fixture.answer_notes.forbid).map(([v, text]) => [
|
|
1282
|
+
{ question: "forbid", value: v },
|
|
1283
|
+
unticked(text),
|
|
1284
|
+
]),
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
test(
|
|
1289
|
+
"🔴 the annotation sits right under the marked line inside the code; the gutter column has its header and its values; each answer carries its sub-line",
|
|
1290
|
+
async () => {
|
|
1291
|
+
await onV7Page("browser-v7-card", async (page, cardOf) => {
|
|
1292
|
+
const depth = await cardOf("card-depth");
|
|
1293
|
+
const lines = await allByKind(depth, "code-line");
|
|
1294
|
+
const marked = lines.find((l) => CodeLine.parse(l.meta).marked);
|
|
1295
|
+
assert.ok(marked !== undefined, "the marked line is drawn");
|
|
1296
|
+
const [hit, note] = [
|
|
1297
|
+
await marked.loc.boundingBox(),
|
|
1298
|
+
await depth.getByTestId("annotation").boundingBox(),
|
|
1299
|
+
];
|
|
1300
|
+
assert.ok(hit && note, "both drawn");
|
|
1301
|
+
assert.ok(
|
|
1302
|
+
note.y >= hit.y + hit.height && note.y - (hit.y + hit.height) < 16,
|
|
1303
|
+
JSON.stringify([hit, note]),
|
|
1304
|
+
);
|
|
1305
|
+
assert.deepEqual(
|
|
1306
|
+
await annotatedFacts(depth),
|
|
1307
|
+
annotatedExpected(fixtureCard("card-depth")),
|
|
1308
|
+
);
|
|
1309
|
+
assert.equal(
|
|
1310
|
+
await page.evaluate(() => document.documentElement.scrollWidth),
|
|
1311
|
+
400,
|
|
1312
|
+
);
|
|
1313
|
+
});
|
|
1314
|
+
},
|
|
1315
|
+
BROWSER_TEST_MS,
|
|
1316
|
+
);
|
|
1317
|
+
|
|
1318
|
+
test(
|
|
1319
|
+
"🔴 a card still on its time shows its wait bar with the seconds left, in the page's own words, and the answers wait",
|
|
1320
|
+
async () => {
|
|
1321
|
+
await onV7Page(
|
|
1322
|
+
"browser-v7-wait",
|
|
1323
|
+
async (page, cardOf) => {
|
|
1324
|
+
const { labels, min_seconds: total } = z
|
|
1325
|
+
.object({
|
|
1326
|
+
labels: z.object({ wait: z.string() }),
|
|
1327
|
+
min_seconds: z.number(),
|
|
1328
|
+
})
|
|
1329
|
+
.loose()
|
|
1330
|
+
.parse(V7_CONFIG);
|
|
1331
|
+
const bar = (await cardOf("card-depth")).getByTestId("wait");
|
|
1332
|
+
// One read of both, so a tick between two reads cannot mix them.
|
|
1333
|
+
const [text, raw] = z
|
|
1334
|
+
.tuple([z.string(), z.string()])
|
|
1335
|
+
.parse(
|
|
1336
|
+
await bar.evaluate((n) => [
|
|
1337
|
+
n.textContent,
|
|
1338
|
+
n.getAttribute("data-test-meta"),
|
|
1339
|
+
]),
|
|
1340
|
+
);
|
|
1341
|
+
const meta = z
|
|
1342
|
+
.object({ id: z.string(), seconds: z.number() })
|
|
1343
|
+
.parse(JSON.parse(raw));
|
|
1344
|
+
assert.deepEqual(
|
|
1345
|
+
[
|
|
1346
|
+
meta.seconds > 0 && meta.seconds <= total,
|
|
1347
|
+
text,
|
|
1348
|
+
await (
|
|
1349
|
+
await answerOf(await cardOf("card-depth"), "forbid")
|
|
1350
|
+
).isDisabled(),
|
|
1351
|
+
],
|
|
1352
|
+
[true, labels.wait.replace("{s}", String(meta.seconds)), true],
|
|
1353
|
+
);
|
|
1354
|
+
},
|
|
1355
|
+
true,
|
|
1356
|
+
);
|
|
1357
|
+
},
|
|
1358
|
+
BROWSER_TEST_MS,
|
|
1359
|
+
);
|