paperlint 14.1.0 → 14.2.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/dist/skills/build-label-page/scripts/label-page-core.d.ts +43 -30
- package/package.json +1 -1
- package/skills/build-label-page/SKILL.md +15 -1
- package/skills/build-label-page/SKILL.md.spec.ts +14 -0
- package/skills/build-label-page/references/closed-choice-generator.md +42 -0
- package/skills/build-label-page/scripts/label-page-answerers.test.ts +381 -0
- package/skills/build-label-page/scripts/label-page-browser.test.ts +309 -0
- package/skills/build-label-page/scripts/label-page-core.js +206 -58
- package/skills/build-label-page/scripts/label-page-core.test.ts +24 -9
- package/skills/build-label-page/scripts/label-page-core.ts +333 -77
- 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 +205 -18
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from "playwright-core";
|
|
13
13
|
import { readFileSync } from "node:fs";
|
|
14
14
|
import { join } from "node:path";
|
|
15
|
+
import * as R from "remeda";
|
|
15
16
|
import { afterAll, test } from "vitest";
|
|
16
17
|
import { z } from "zod";
|
|
17
18
|
import { chromiumPath } from "../../../test/chromium.ts";
|
|
@@ -19,7 +20,9 @@ import {
|
|
|
19
20
|
APPROVAL_PRACTICE,
|
|
20
21
|
PRACTICE,
|
|
21
22
|
QUESTIONS,
|
|
23
|
+
ITEMS,
|
|
22
24
|
approval,
|
|
25
|
+
approvalCard,
|
|
23
26
|
build,
|
|
24
27
|
config,
|
|
25
28
|
setup,
|
|
@@ -32,6 +35,7 @@ const STORE = (
|
|
|
32
35
|
answers: Record<string, unknown> = {},
|
|
33
36
|
): string => `
|
|
34
37
|
window.__writes = [];
|
|
38
|
+
window.__ids = [];
|
|
35
39
|
(function () {
|
|
36
40
|
var docs = { answers: ${JSON.stringify(answers)}, groups: {}, practice: ${JSON.stringify(practice)} };
|
|
37
41
|
var subs = {};
|
|
@@ -44,6 +48,7 @@ const STORE = (
|
|
|
44
48
|
onSnapshot: function (cb) { (subs[n] = subs[n] || []).push(cb); snap(n); },
|
|
45
49
|
doc: function (id) { return { set: function (body) {
|
|
46
50
|
window.__writes.push(body);
|
|
51
|
+
window.__ids.push(n + "/" + id);
|
|
47
52
|
docs[n][id] = body;
|
|
48
53
|
return new Promise(function (r) { setTimeout(function () { r(); snap(n); }, ${String(echoMs)}); });
|
|
49
54
|
} }; }
|
|
@@ -606,3 +611,307 @@ test(
|
|
|
606
611
|
},
|
|
607
612
|
BROWSER_TEST_MS,
|
|
608
613
|
);
|
|
614
|
+
|
|
615
|
+
/** The collection and document id of every write, in order. */
|
|
616
|
+
const writtenIds = async (page: Page): Promise<readonly string[]> =>
|
|
617
|
+
z.array(z.string()).parse(await page.evaluate("window.__ids"));
|
|
618
|
+
/** A page served from an origin of its own, so it has a localStorage that a reload keeps. */
|
|
619
|
+
async function served(page: Page, html: string): Promise<void> {
|
|
620
|
+
await page.route("http://label.test/", (route) =>
|
|
621
|
+
route.fulfill({ body: html, contentType: "text/html" }),
|
|
622
|
+
);
|
|
623
|
+
await page.goto("http://label.test/");
|
|
624
|
+
}
|
|
625
|
+
const prefixedPractice = (who: string) =>
|
|
626
|
+
R.mapKeys(PRACTICE_DONE, (k) => `${who}~${k}`);
|
|
627
|
+
|
|
628
|
+
test(
|
|
629
|
+
"🔴 without answerers the page asks nobody's name and writes the documents it always wrote, with no seconds",
|
|
630
|
+
async () => {
|
|
631
|
+
const { page: html } = build(
|
|
632
|
+
setup(
|
|
633
|
+
"browser-plain-ids",
|
|
634
|
+
approval({ items: ITEMS.slice(0, 2).map(approvalCard) }),
|
|
635
|
+
),
|
|
636
|
+
);
|
|
637
|
+
const page = await (await sharedBrowser()).newPage();
|
|
638
|
+
try {
|
|
639
|
+
await page.addInitScript(STORE(PRACTICE_DONE, 20));
|
|
640
|
+
await page.goto("about:blank");
|
|
641
|
+
await page.setContent(html, { waitUntil: "domcontentloaded" });
|
|
642
|
+
assert.equal(await page.getByText("Who is answering?").count(), 0);
|
|
643
|
+
await page.getByRole("button", { name: /Keep/ }).first().click();
|
|
644
|
+
await page.waitForTimeout(100);
|
|
645
|
+
assert.deepEqual(await writtenIds(page), ["answers/G001"]);
|
|
646
|
+
assert.deepEqual(Object.keys((await writes(page))[0] ?? {}).sort(), [
|
|
647
|
+
"answers",
|
|
648
|
+
"reasons",
|
|
649
|
+
"savedAt",
|
|
650
|
+
"source",
|
|
651
|
+
]);
|
|
652
|
+
} finally {
|
|
653
|
+
await page.context().close();
|
|
654
|
+
}
|
|
655
|
+
},
|
|
656
|
+
BROWSER_TEST_MS,
|
|
657
|
+
);
|
|
658
|
+
|
|
659
|
+
/** Three approval cards: the first two for both answerers, the third for `author` only. */
|
|
660
|
+
const TWO_PAGE = (over: Record<string, unknown> = {}) =>
|
|
661
|
+
approval({
|
|
662
|
+
answerers: ["author", "second"],
|
|
663
|
+
items: ITEMS.slice(0, 3)
|
|
664
|
+
.map(approvalCard)
|
|
665
|
+
.map((i, n) => (n === 2 ? R.merge(i, { answerers: ["author"] }) : i)),
|
|
666
|
+
...over,
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
test(
|
|
670
|
+
"🔴 with answerers the page asks who is answering, remembers it, writes and reads only that answerer's documents, and can switch",
|
|
671
|
+
async () => {
|
|
672
|
+
const dir = setup("browser-answerers", TWO_PAGE());
|
|
673
|
+
const { page: html, key } = build(dir);
|
|
674
|
+
const third = Object.entries(key.ids).find(
|
|
675
|
+
([, v]) => v.source_id === "case-1002",
|
|
676
|
+
)?.[0];
|
|
677
|
+
const [first] = Object.keys(key.ids).filter((n) => n !== third);
|
|
678
|
+
assert.ok(third !== undefined && first !== undefined);
|
|
679
|
+
const page = await (await sharedBrowser()).newPage();
|
|
680
|
+
try {
|
|
681
|
+
await page.addInitScript(
|
|
682
|
+
STORE(prefixedPractice("second"), 20, {
|
|
683
|
+
[`author~${first}`]: { answers: { keep: "keep" }, source: "page" },
|
|
684
|
+
}),
|
|
685
|
+
);
|
|
686
|
+
await served(page, html);
|
|
687
|
+
assert.equal(await page.getByText("Who is answering?").count(), 1);
|
|
688
|
+
assert.equal(await page.locator("article.item").count(), 0);
|
|
689
|
+
await page.getByRole("button", { name: "second", exact: true }).click();
|
|
690
|
+
// Guards: second sees two items, none answered: author's answer is not theirs.
|
|
691
|
+
assert.equal(await page.locator("article.item").count(), 2);
|
|
692
|
+
assert.equal(await page.getByText("0 of 2 items complete").count(), 1);
|
|
693
|
+
await page
|
|
694
|
+
.locator("article.item", { hasText: first })
|
|
695
|
+
.getByRole("button", { name: /Keep/ })
|
|
696
|
+
.click();
|
|
697
|
+
await page.waitForTimeout(100);
|
|
698
|
+
assert.deepEqual(await writtenIds(page), [`answers/second~${first}`]);
|
|
699
|
+
// A reload remembers who is answering.
|
|
700
|
+
await page.reload();
|
|
701
|
+
// (The stand-in store starts afresh on a reload: only the name is checked here.)
|
|
702
|
+
assert.equal(await page.getByText("Answering as second").count(), 1);
|
|
703
|
+
await page.getByRole("button", { name: "Switch" }).click();
|
|
704
|
+
await page.getByRole("button", { name: "author", exact: true }).click();
|
|
705
|
+
// author has not done the practice: it opens, and its answers go under author's name.
|
|
706
|
+
assert.equal(
|
|
707
|
+
await page.getByText("Practice: 0 of 3 answered").count(),
|
|
708
|
+
1,
|
|
709
|
+
);
|
|
710
|
+
await page
|
|
711
|
+
.locator("article.item")
|
|
712
|
+
.first()
|
|
713
|
+
.getByRole("button", { name: /Keep/ })
|
|
714
|
+
.click();
|
|
715
|
+
await page.waitForTimeout(100);
|
|
716
|
+
assert.equal((await writtenIds(page)).at(-1), "practice/author~r1-P1");
|
|
717
|
+
} finally {
|
|
718
|
+
await page.context().close();
|
|
719
|
+
}
|
|
720
|
+
},
|
|
721
|
+
BROWSER_TEST_MS,
|
|
722
|
+
);
|
|
723
|
+
|
|
724
|
+
test(
|
|
725
|
+
"🔴 with answerers and no localStorage the page still works: it asks, and writes under the name picked",
|
|
726
|
+
async () => {
|
|
727
|
+
const { page: html } = build(setup("browser-answerers-nols", TWO_PAGE()));
|
|
728
|
+
const page = await (await sharedBrowser()).newPage();
|
|
729
|
+
try {
|
|
730
|
+
await page.addInitScript(
|
|
731
|
+
`Object.defineProperty(window, "localStorage", { get: function () { throw new Error("denied"); } });`,
|
|
732
|
+
);
|
|
733
|
+
await page.addInitScript(STORE(prefixedPractice("author"), 20));
|
|
734
|
+
await served(page, html);
|
|
735
|
+
await page.getByRole("button", { name: "author", exact: true }).click();
|
|
736
|
+
assert.equal(await page.locator("article.item").count(), 3);
|
|
737
|
+
await page
|
|
738
|
+
.locator("article.item", { hasText: "G001" })
|
|
739
|
+
.getByRole("button", { name: /Keep/ })
|
|
740
|
+
.click();
|
|
741
|
+
await page.waitForTimeout(100);
|
|
742
|
+
assert.deepEqual(await writtenIds(page), ["answers/author~G001"]);
|
|
743
|
+
} finally {
|
|
744
|
+
await page.context().close();
|
|
745
|
+
}
|
|
746
|
+
},
|
|
747
|
+
BROWSER_TEST_MS,
|
|
748
|
+
);
|
|
749
|
+
|
|
750
|
+
test(
|
|
751
|
+
"🔴 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",
|
|
752
|
+
async () => {
|
|
753
|
+
const { page: html } = build(
|
|
754
|
+
setup("browser-dwell", approval({ min_seconds: 2 })),
|
|
755
|
+
);
|
|
756
|
+
const page = await (
|
|
757
|
+
await sharedBrowser()
|
|
758
|
+
).newPage({ viewport: { width: 400, height: 700 } });
|
|
759
|
+
try {
|
|
760
|
+
await page.addInitScript(STORE(PRACTICE_DONE, 20));
|
|
761
|
+
await page.goto("about:blank");
|
|
762
|
+
await page.setContent(html, { waitUntil: "domcontentloaded" });
|
|
763
|
+
const cards = page.locator("article.item");
|
|
764
|
+
const firstCard = cards.first();
|
|
765
|
+
const lastCard = cards.last();
|
|
766
|
+
await firstCard.scrollIntoViewIfNeeded();
|
|
767
|
+
const keep = firstCard.getByRole("button", { name: /Keep/ });
|
|
768
|
+
assert.equal(await keep.isDisabled(), true);
|
|
769
|
+
assert.match((await firstCard.textContent()) ?? "", /answer in [12] s/);
|
|
770
|
+
await keep.click({ force: true });
|
|
771
|
+
assert.deepEqual(await writes(page), [], "nothing saves before the time");
|
|
772
|
+
await page.waitForTimeout(2600);
|
|
773
|
+
assert.equal(await keep.isDisabled(), false);
|
|
774
|
+
assert.equal(
|
|
775
|
+
await lastCard.getByRole("button", { name: /Keep/ }).isDisabled(),
|
|
776
|
+
true,
|
|
777
|
+
"the last card was never on screen",
|
|
778
|
+
);
|
|
779
|
+
await keep.click();
|
|
780
|
+
await page.waitForTimeout(100);
|
|
781
|
+
const [stored] = z
|
|
782
|
+
.array(z.object({ seconds: z.number().int() }).loose())
|
|
783
|
+
.parse(await page.evaluate("window.__writes"));
|
|
784
|
+
assert.ok(
|
|
785
|
+
stored !== undefined && stored.seconds >= 2 && stored.seconds <= 4,
|
|
786
|
+
JSON.stringify(stored),
|
|
787
|
+
);
|
|
788
|
+
await lastCard.scrollIntoViewIfNeeded();
|
|
789
|
+
await page.waitForTimeout(2600);
|
|
790
|
+
assert.equal(
|
|
791
|
+
await lastCard.getByRole("button", { name: /Keep/ }).isDisabled(),
|
|
792
|
+
false,
|
|
793
|
+
);
|
|
794
|
+
} finally {
|
|
795
|
+
await page.context().close();
|
|
796
|
+
}
|
|
797
|
+
},
|
|
798
|
+
BROWSER_TEST_MS,
|
|
799
|
+
);
|
|
800
|
+
|
|
801
|
+
test(
|
|
802
|
+
"🔴 with min_seconds a tall card's clock stops when less than its share is in view, even between the observer's steps",
|
|
803
|
+
async () => {
|
|
804
|
+
const { page: html } = build(
|
|
805
|
+
setup("browser-dwell-tall", approval({ min_seconds: 2 })),
|
|
806
|
+
);
|
|
807
|
+
const page = await (
|
|
808
|
+
await sharedBrowser()
|
|
809
|
+
).newPage({ viewport: { width: 400, height: 300 } });
|
|
810
|
+
try {
|
|
811
|
+
await page.addInitScript(STORE(PRACTICE_DONE, 20));
|
|
812
|
+
await page.goto("about:blank");
|
|
813
|
+
await page.setContent(html, { waitUntil: "domcontentloaded" });
|
|
814
|
+
await page.addStyleTag({
|
|
815
|
+
content: "article.item { min-height: 1200px; box-sizing: border-box; }",
|
|
816
|
+
});
|
|
817
|
+
const lastCard = page.locator("article.item").last();
|
|
818
|
+
const keep = lastCard.getByRole("button", { name: /Keep/ });
|
|
819
|
+
await keep.waitFor();
|
|
820
|
+
// Show `px` of the last card at the bottom of the viewport. The card is 1200 px and the
|
|
821
|
+
// viewport 300 px, so it is on screen with half the viewport, 150 px (12.5 %), in view.
|
|
822
|
+
const show = (px: number) =>
|
|
823
|
+
page.evaluate(
|
|
824
|
+
`(() => { const c = document.querySelectorAll("article.item"); const r = c[c.length - 1].getBoundingClientRect(); window.scrollTo(0, window.scrollY + r.top - window.innerHeight + ${String(px)}); })()`,
|
|
825
|
+
);
|
|
826
|
+
await show(170); // 14.2 %: above the share
|
|
827
|
+
await page.waitForTimeout(200);
|
|
828
|
+
await show(140); // 11.7 %: below it, and no 5 % step is crossed on the way
|
|
829
|
+
await page.waitForTimeout(2600);
|
|
830
|
+
assert.equal(
|
|
831
|
+
await keep.isDisabled(),
|
|
832
|
+
true,
|
|
833
|
+
"140 px of the card in view for 2.6 s does not count",
|
|
834
|
+
);
|
|
835
|
+
await show(200);
|
|
836
|
+
await page.waitForTimeout(2600);
|
|
837
|
+
assert.equal(
|
|
838
|
+
await keep.isDisabled(),
|
|
839
|
+
false,
|
|
840
|
+
"200 px in view for 2.6 s counts",
|
|
841
|
+
);
|
|
842
|
+
} finally {
|
|
843
|
+
await page.context().close();
|
|
844
|
+
}
|
|
845
|
+
},
|
|
846
|
+
BROWSER_TEST_MS,
|
|
847
|
+
);
|
|
848
|
+
|
|
849
|
+
test(
|
|
850
|
+
"🔴 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",
|
|
851
|
+
async () => {
|
|
852
|
+
const { page: html } = build(
|
|
853
|
+
setup("browser-dwell-huge", approval({ min_seconds: 2 })),
|
|
854
|
+
);
|
|
855
|
+
const page = await (
|
|
856
|
+
await sharedBrowser()
|
|
857
|
+
).newPage({ viewport: { width: 400, height: 300 } });
|
|
858
|
+
try {
|
|
859
|
+
await page.addInitScript(STORE(PRACTICE_DONE, 20));
|
|
860
|
+
await page.goto("about:blank");
|
|
861
|
+
await page.setContent(html, { waitUntil: "domcontentloaded" });
|
|
862
|
+
const cards = page.locator("article.item");
|
|
863
|
+
const huge = cards.last();
|
|
864
|
+
await huge.getByRole("button", { name: /Keep/ }).waitFor();
|
|
865
|
+
// The card grows after it was drawn and watched, with no render.
|
|
866
|
+
await huge.evaluate((el) => {
|
|
867
|
+
el.style.minHeight = "7000px";
|
|
868
|
+
el.scrollIntoView({ block: "start" });
|
|
869
|
+
window.scrollBy(0, 2000);
|
|
870
|
+
});
|
|
871
|
+
await page.waitForTimeout(2600);
|
|
872
|
+
assert.equal(
|
|
873
|
+
await huge.getByRole("button", { name: /Keep/ }).isDisabled(),
|
|
874
|
+
false,
|
|
875
|
+
"a 7000 px card filling a 300 px viewport for 2.6 s counts",
|
|
876
|
+
);
|
|
877
|
+
} finally {
|
|
878
|
+
await page.context().close();
|
|
879
|
+
}
|
|
880
|
+
},
|
|
881
|
+
BROWSER_TEST_MS,
|
|
882
|
+
);
|
|
883
|
+
|
|
884
|
+
test(
|
|
885
|
+
"🔴 with min_seconds a page that loaded in a hidden tab starts the clock of a card in view when the tab comes back",
|
|
886
|
+
async () => {
|
|
887
|
+
const { page: html } = build(
|
|
888
|
+
setup("browser-dwell-hidden", approval({ min_seconds: 2 })),
|
|
889
|
+
);
|
|
890
|
+
const page = await (
|
|
891
|
+
await sharedBrowser()
|
|
892
|
+
).newPage({ viewport: { width: 400, height: 700 } });
|
|
893
|
+
try {
|
|
894
|
+
await page.addInitScript(
|
|
895
|
+
`window.__vis = "hidden"; Object.defineProperty(Document.prototype, "visibilityState", { configurable: true, get: function () { return window.__vis; } });`,
|
|
896
|
+
);
|
|
897
|
+
await page.addInitScript(STORE(PRACTICE_DONE, 20));
|
|
898
|
+
await page.goto("about:blank");
|
|
899
|
+
await page.setContent(html, { waitUntil: "domcontentloaded" });
|
|
900
|
+
const firstCard = page.locator("article.item").first();
|
|
901
|
+
await firstCard.scrollIntoViewIfNeeded();
|
|
902
|
+
await page.waitForTimeout(300);
|
|
903
|
+
await page.evaluate(
|
|
904
|
+
`window.__vis = "visible"; document.dispatchEvent(new Event("visibilitychange"));`,
|
|
905
|
+
);
|
|
906
|
+
await page.waitForTimeout(2600);
|
|
907
|
+
assert.equal(
|
|
908
|
+
await firstCard.getByRole("button", { name: /Keep/ }).isDisabled(),
|
|
909
|
+
false,
|
|
910
|
+
"the card was in view for 2.6 s after the tab came back",
|
|
911
|
+
);
|
|
912
|
+
} finally {
|
|
913
|
+
await page.context().close();
|
|
914
|
+
}
|
|
915
|
+
},
|
|
916
|
+
BROWSER_TEST_MS,
|
|
917
|
+
);
|