paperlint 14.0.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 +105 -25
- package/dist/skills/build-label-page/scripts/label-page-presets.d.ts +10 -0
- package/package.json +1 -1
- package/skills/build-label-page/SKILL.md +20 -2
- package/skills/build-label-page/SKILL.md.spec.ts +19 -1
- package/skills/build-label-page/references/closed-choice-generator.md +171 -5
- package/skills/build-label-page/scripts/fixtures/blocks-items.json +214 -0
- package/skills/build-label-page/scripts/fixtures/linter-config-witness-items.json +448 -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 +335 -0
- package/skills/build-label-page/scripts/label-page-browser.test.ts +637 -2
- package/skills/build-label-page/scripts/label-page-core.js +360 -70
- package/skills/build-label-page/scripts/label-page-core.test.ts +78 -11
- package/skills/build-label-page/scripts/label-page-core.ts +585 -107
- package/skills/build-label-page/scripts/label-page-html.test.ts +22 -0
- package/skills/build-label-page/scripts/label-page-presets.js +222 -0
- package/skills/build-label-page/scripts/label-page-presets.test.ts +175 -0
- package/skills/build-label-page/scripts/label-page-presets.ts +257 -0
- package/skills/build-label-page/scripts/label-page.js +13 -2
- package/skills/build-label-page/scripts/label-page.ts +13 -2
- package/skills/build-label-page/scripts/page-template.html +438 -44
|
@@ -10,21 +10,34 @@ import {
|
|
|
10
10
|
type Locator,
|
|
11
11
|
type Page,
|
|
12
12
|
} from "playwright-core";
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import * as R from "remeda";
|
|
13
16
|
import { afterAll, test } from "vitest";
|
|
14
17
|
import { z } from "zod";
|
|
15
18
|
import { chromiumPath } from "../../../test/chromium.ts";
|
|
16
19
|
import {
|
|
17
20
|
APPROVAL_PRACTICE,
|
|
21
|
+
PRACTICE,
|
|
22
|
+
QUESTIONS,
|
|
23
|
+
ITEMS,
|
|
18
24
|
approval,
|
|
25
|
+
approvalCard,
|
|
19
26
|
build,
|
|
27
|
+
config,
|
|
20
28
|
setup,
|
|
21
29
|
} from "../../../test/label-page-kit.ts";
|
|
22
30
|
|
|
23
31
|
/** A store that keeps every write and echoes it back as a snapshot after `echoMs`. */
|
|
24
|
-
const STORE = (
|
|
32
|
+
const STORE = (
|
|
33
|
+
practice: Record<string, unknown>,
|
|
34
|
+
echoMs: number,
|
|
35
|
+
answers: Record<string, unknown> = {},
|
|
36
|
+
): string => `
|
|
25
37
|
window.__writes = [];
|
|
38
|
+
window.__ids = [];
|
|
26
39
|
(function () {
|
|
27
|
-
var docs = { answers: {}, groups: {}, practice: ${JSON.stringify(practice)} };
|
|
40
|
+
var docs = { answers: ${JSON.stringify(answers)}, groups: {}, practice: ${JSON.stringify(practice)} };
|
|
28
41
|
var subs = {};
|
|
29
42
|
function snap(n) {
|
|
30
43
|
(subs[n] || []).forEach(function (cb) {
|
|
@@ -35,6 +48,7 @@ const STORE = (practice: Record<string, unknown>, echoMs: number): string => `
|
|
|
35
48
|
onSnapshot: function (cb) { (subs[n] = subs[n] || []).push(cb); snap(n); },
|
|
36
49
|
doc: function (id) { return { set: function (body) {
|
|
37
50
|
window.__writes.push(body);
|
|
51
|
+
window.__ids.push(n + "/" + id);
|
|
38
52
|
docs[n][id] = body;
|
|
39
53
|
return new Promise(function (r) { setTimeout(function () { r(); snap(n); }, ${String(echoMs)}); });
|
|
40
54
|
} }; }
|
|
@@ -175,6 +189,35 @@ test(
|
|
|
175
189
|
BROWSER_TEST_MS,
|
|
176
190
|
);
|
|
177
191
|
|
|
192
|
+
test(
|
|
193
|
+
"🔴 an answer that needs a reason is not saved while its reason is empty; another answer replaces it unsaved",
|
|
194
|
+
async () => {
|
|
195
|
+
await onApprovalPage("browser-refuse", 20, async (page) => {
|
|
196
|
+
await page.waitForTimeout(1200); // longer than the reason's pause: nothing may arrive
|
|
197
|
+
assert.deepEqual(await writes(page), []);
|
|
198
|
+
assert.match(
|
|
199
|
+
(await page.locator("article.item").first().textContent()) ?? "",
|
|
200
|
+
/Not saved yet/,
|
|
201
|
+
);
|
|
202
|
+
// Leaving the page with the box empty saves nothing either.
|
|
203
|
+
await page.evaluate(() => {
|
|
204
|
+
window.dispatchEvent(new Event("pagehide"));
|
|
205
|
+
});
|
|
206
|
+
await page.waitForTimeout(100);
|
|
207
|
+
assert.deepEqual(await writes(page), []);
|
|
208
|
+
await page.getByRole("button", { name: /Keep/ }).first().click();
|
|
209
|
+
await page.waitForTimeout(100);
|
|
210
|
+
assert.deepEqual(
|
|
211
|
+
await page.evaluate(
|
|
212
|
+
"window.__writes.map(function (w) { return w.answers; })",
|
|
213
|
+
),
|
|
214
|
+
[{ keep: "keep" }],
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
},
|
|
218
|
+
BROWSER_TEST_MS,
|
|
219
|
+
);
|
|
220
|
+
|
|
178
221
|
test(
|
|
179
222
|
"a Keep needs no reason and writes none: the page stays quiet",
|
|
180
223
|
async () => {
|
|
@@ -280,3 +323,595 @@ test(
|
|
|
280
323
|
},
|
|
281
324
|
BROWSER_TEST_MS,
|
|
282
325
|
);
|
|
326
|
+
|
|
327
|
+
const WITNESS_FIXTURE = join(
|
|
328
|
+
import.meta.dirname,
|
|
329
|
+
"fixtures",
|
|
330
|
+
"linter-config-witness-items.json",
|
|
331
|
+
);
|
|
332
|
+
/** The generic witness page, every practice card answered as expected, at phone width. */
|
|
333
|
+
async function onWitnessPage(
|
|
334
|
+
name: string,
|
|
335
|
+
steps: (page: Page) => Promise<void>,
|
|
336
|
+
practiceDone = true,
|
|
337
|
+
): Promise<void> {
|
|
338
|
+
const cfg = z
|
|
339
|
+
.object({
|
|
340
|
+
practice: z.array(
|
|
341
|
+
z.object({ expected: z.record(z.string(), z.string()) }).loose(),
|
|
342
|
+
),
|
|
343
|
+
})
|
|
344
|
+
.loose()
|
|
345
|
+
.parse(JSON.parse(readFileSync(WITNESS_FIXTURE, "utf8")));
|
|
346
|
+
const done = Object.fromEntries(
|
|
347
|
+
cfg.practice.map((p, n) => [
|
|
348
|
+
`r1-P${String(n + 1)}`,
|
|
349
|
+
{ answers: p.expected, source: "page" },
|
|
350
|
+
]),
|
|
351
|
+
);
|
|
352
|
+
const { page: html } = build(setup(name, cfg));
|
|
353
|
+
const page = await (
|
|
354
|
+
await sharedBrowser()
|
|
355
|
+
).newPage({ viewport: { width: 400, height: 900 } });
|
|
356
|
+
try {
|
|
357
|
+
await page.addInitScript(STORE(practiceDone ? done : {}, 20));
|
|
358
|
+
await page.goto("about:blank");
|
|
359
|
+
await page.setContent(html, { waitUntil: "domcontentloaded" });
|
|
360
|
+
await steps(page);
|
|
361
|
+
} finally {
|
|
362
|
+
await page.context().close();
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** The code, the linter's message, the question, the rule docs' toggle: one read of the laid-out card,
|
|
367
|
+
* so a re-render between reads cannot mix two layouts. */
|
|
368
|
+
const LAYOUT_OF = ["pre.numbered", "p.said", ".ask", "details > summary"];
|
|
369
|
+
const Layout = z.array(z.object({ top: z.number(), bottom: z.number() }));
|
|
370
|
+
const layout = async (card: Locator) =>
|
|
371
|
+
Layout.parse(
|
|
372
|
+
await card.evaluate(
|
|
373
|
+
(e, sels) =>
|
|
374
|
+
sels.map((s) => {
|
|
375
|
+
const r = e.querySelector(s)?.getBoundingClientRect();
|
|
376
|
+
return { top: r?.top ?? NaN, bottom: r?.bottom ?? NaN };
|
|
377
|
+
}),
|
|
378
|
+
LAYOUT_OF,
|
|
379
|
+
),
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
test(
|
|
383
|
+
"🔴 at phone width a witness card labels the caught line in its numbered code, asks its question right under that code, folds the rule docs and the settings, opens a long text on «more», and nothing pushes the page sideways",
|
|
384
|
+
async () => {
|
|
385
|
+
await onWitnessPage("browser-witness", async (page) => {
|
|
386
|
+
// Measure after the store's practice answers arrived: before them the practice cards sit above.
|
|
387
|
+
await page.locator("summary", { hasText: "How to answer" }).waitFor();
|
|
388
|
+
const card = page.locator("article.item", { hasText: "seed_db.py" });
|
|
389
|
+
// The marked line reads as marked without colour: a gutter marker and a label.
|
|
390
|
+
assert.equal(
|
|
391
|
+
await card.locator("pre.numbered .hit").textContent(),
|
|
392
|
+
'▶18print(f"seeded {len(rows)} rows")◀ this line',
|
|
393
|
+
);
|
|
394
|
+
// Guards: the rule docs, the files like it and the settings are folded.
|
|
395
|
+
assert.deepEqual(
|
|
396
|
+
[
|
|
397
|
+
await card.locator("details").count(),
|
|
398
|
+
await card.locator("details[open]").count(),
|
|
399
|
+
],
|
|
400
|
+
[3, 0],
|
|
401
|
+
);
|
|
402
|
+
// The question sits right under the code it is about: only the linter's message between them,
|
|
403
|
+
// and the rule's docs start below the answers.
|
|
404
|
+
const docsSummary = card.locator("summary", {
|
|
405
|
+
hasText: "What the linter rule does (official docs)",
|
|
406
|
+
});
|
|
407
|
+
const box = await layout(card);
|
|
408
|
+
const [code, said, ask, docs] = box;
|
|
409
|
+
assert.ok(code && said && ask && docs, JSON.stringify(box));
|
|
410
|
+
assert.ok(
|
|
411
|
+
said.top >= code.bottom && ask.top >= said.bottom,
|
|
412
|
+
JSON.stringify(box),
|
|
413
|
+
);
|
|
414
|
+
assert.ok(ask.top - said.bottom < 40, JSON.stringify(box));
|
|
415
|
+
assert.ok(docs.top >= ask.bottom, JSON.stringify(box));
|
|
416
|
+
await docsSummary.click();
|
|
417
|
+
const why = card.locator(".clamped", { hasText: "Why:" });
|
|
418
|
+
const folded = (await why.boundingBox())?.height ?? 0;
|
|
419
|
+
await card.getByRole("button", { name: "more" }).first().click();
|
|
420
|
+
const opened = await card.locator("p", { hasText: "Why:" }).boundingBox();
|
|
421
|
+
assert.ok(
|
|
422
|
+
opened && opened.height > folded,
|
|
423
|
+
`${String(folded)} → ${String(opened?.height)}`,
|
|
424
|
+
);
|
|
425
|
+
assert.equal(
|
|
426
|
+
await page.evaluate(() => document.documentElement.scrollWidth),
|
|
427
|
+
400,
|
|
428
|
+
);
|
|
429
|
+
});
|
|
430
|
+
},
|
|
431
|
+
BROWSER_TEST_MS,
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
test(
|
|
435
|
+
"🔴 a witness practice card's answer buttons say their example is about other code",
|
|
436
|
+
async () => {
|
|
437
|
+
await onWitnessPage(
|
|
438
|
+
"browser-witness-buttons",
|
|
439
|
+
async (page) => {
|
|
440
|
+
const practice = page.locator("article.item").first();
|
|
441
|
+
assert.deepEqual(
|
|
442
|
+
[
|
|
443
|
+
await practice.locator(".id").textContent(),
|
|
444
|
+
await practice.locator(".opts button").count(),
|
|
445
|
+
await practice.locator(".opts .ex").count(),
|
|
446
|
+
await practice.locator(".opts .ex").first().textContent(),
|
|
447
|
+
],
|
|
448
|
+
[
|
|
449
|
+
"Practice P1",
|
|
450
|
+
4,
|
|
451
|
+
4,
|
|
452
|
+
"Example of this answer, about other code: «Use ===» and the line a == b: yes, the requirement forbids it",
|
|
453
|
+
],
|
|
454
|
+
);
|
|
455
|
+
},
|
|
456
|
+
false,
|
|
457
|
+
);
|
|
458
|
+
},
|
|
459
|
+
BROWSER_TEST_MS,
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
test(
|
|
463
|
+
"🔴 under 'Next unanswered' the card just answered stays, with Change, until the next card is answered",
|
|
464
|
+
async () => {
|
|
465
|
+
await onWitnessPage("browser-witness-next", async (page) => {
|
|
466
|
+
await page.getByRole("button", { name: "Next unanswered" }).click();
|
|
467
|
+
const cards = page.locator("article.item");
|
|
468
|
+
assert.equal(await cards.count(), 3);
|
|
469
|
+
await cards.nth(0).getByRole("button", { name: "Yes" }).click();
|
|
470
|
+
await page.waitForTimeout(100);
|
|
471
|
+
assert.equal(await cards.count(), 3, "the answered card is still there");
|
|
472
|
+
assert.equal(
|
|
473
|
+
await cards.nth(0).getByRole("button", { name: "Change" }).count(),
|
|
474
|
+
1,
|
|
475
|
+
);
|
|
476
|
+
await cards.nth(1).getByRole("button", { name: "Yes" }).click();
|
|
477
|
+
await page.waitForTimeout(100);
|
|
478
|
+
assert.equal(await cards.count(), 2, "the first one has gone");
|
|
479
|
+
});
|
|
480
|
+
},
|
|
481
|
+
BROWSER_TEST_MS,
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
test(
|
|
485
|
+
"🔴 on a page of two questions, an answer still waiting for its reason is in no write, even when the next question is answered",
|
|
486
|
+
async () => {
|
|
487
|
+
const [label, boundary] = QUESTIONS;
|
|
488
|
+
assert.ok(label !== undefined && boundary !== undefined);
|
|
489
|
+
const withWhy = {
|
|
490
|
+
...label,
|
|
491
|
+
options: label.options.map((o) =>
|
|
492
|
+
o.value === "no" ? { ...o, ask_why: "Why not?" } : o,
|
|
493
|
+
),
|
|
494
|
+
};
|
|
495
|
+
const { page: html } = build(
|
|
496
|
+
setup("browser-pending-two", config({ questions: [withWhy, boundary] })),
|
|
497
|
+
);
|
|
498
|
+
const done = Object.fromEntries(
|
|
499
|
+
PRACTICE.map((p, n) => [
|
|
500
|
+
`r1-P${String(n + 1)}`,
|
|
501
|
+
{ answers: p.expected, source: "page" },
|
|
502
|
+
]),
|
|
503
|
+
);
|
|
504
|
+
const page = await (await sharedBrowser()).newPage();
|
|
505
|
+
try {
|
|
506
|
+
await page.addInitScript(STORE(done, 20));
|
|
507
|
+
await page.goto("about:blank");
|
|
508
|
+
await page.setContent(html, { waitUntil: "domcontentloaded" });
|
|
509
|
+
const card = page.locator("article.item").filter({ hasText: "G001" });
|
|
510
|
+
await card.getByRole("button", { name: "No", exact: true }).click();
|
|
511
|
+
await card.getByRole("button", { name: "Clear-cut" }).click();
|
|
512
|
+
await page.waitForTimeout(100);
|
|
513
|
+
// Guards: the reasonless «no» never reaches the store; the answer without a reason does.
|
|
514
|
+
assert.deepEqual(
|
|
515
|
+
(await writes(page)).map((w) => w["answers"]),
|
|
516
|
+
[{ boundary: "clear" }],
|
|
517
|
+
);
|
|
518
|
+
await card.locator("textarea").fill("the output is out of order");
|
|
519
|
+
await card.locator("textarea").blur();
|
|
520
|
+
await page.waitForTimeout(100);
|
|
521
|
+
assert.deepEqual((await writes(page)).at(-1), {
|
|
522
|
+
...(await writes(page)).at(-1),
|
|
523
|
+
answers: { label: "no", boundary: "clear" },
|
|
524
|
+
reasons: { label: "the output is out of order" },
|
|
525
|
+
});
|
|
526
|
+
} finally {
|
|
527
|
+
await page.context().close();
|
|
528
|
+
}
|
|
529
|
+
},
|
|
530
|
+
BROWSER_TEST_MS,
|
|
531
|
+
);
|
|
532
|
+
|
|
533
|
+
test(
|
|
534
|
+
"🔴 a saved answer changed to one still waiting for its reason stays saved when another question is answered",
|
|
535
|
+
async () => {
|
|
536
|
+
const [label, boundary] = QUESTIONS;
|
|
537
|
+
assert.ok(label !== undefined && boundary !== undefined);
|
|
538
|
+
const withWhy = {
|
|
539
|
+
...label,
|
|
540
|
+
options: label.options.map((o) =>
|
|
541
|
+
o.value === "no" ? { ...o, ask_why: "Why not?" } : o,
|
|
542
|
+
),
|
|
543
|
+
};
|
|
544
|
+
const { page: html } = build(
|
|
545
|
+
setup(
|
|
546
|
+
"browser-pending-saved",
|
|
547
|
+
config({ questions: [withWhy, boundary] }),
|
|
548
|
+
),
|
|
549
|
+
);
|
|
550
|
+
const done = Object.fromEntries(
|
|
551
|
+
PRACTICE.map((p, n) => [
|
|
552
|
+
`r1-P${String(n + 1)}`,
|
|
553
|
+
{ answers: p.expected, source: "page" },
|
|
554
|
+
]),
|
|
555
|
+
);
|
|
556
|
+
const page = await (await sharedBrowser()).newPage();
|
|
557
|
+
try {
|
|
558
|
+
await page.addInitScript(
|
|
559
|
+
STORE(done, 20, {
|
|
560
|
+
G001: {
|
|
561
|
+
answers: { label: "yes", boundary: "clear" },
|
|
562
|
+
reasons: {},
|
|
563
|
+
source: "page",
|
|
564
|
+
},
|
|
565
|
+
}),
|
|
566
|
+
);
|
|
567
|
+
await page.goto("about:blank");
|
|
568
|
+
await page.setContent(html, { waitUntil: "domcontentloaded" });
|
|
569
|
+
const card = page.locator("article.item").filter({ hasText: "G001" });
|
|
570
|
+
await card.getByRole("button", { name: "Change" }).first().click();
|
|
571
|
+
await card.getByRole("button", { name: "No", exact: true }).click();
|
|
572
|
+
await card.getByRole("button", { name: "Change" }).last().click();
|
|
573
|
+
await card.getByRole("button", { name: "Boundary" }).click();
|
|
574
|
+
await page.waitForTimeout(100);
|
|
575
|
+
// Guards: the store keeps «yes» until the reason for «no» is typed; the new boundary is saved.
|
|
576
|
+
assert.deepEqual(
|
|
577
|
+
(await writes(page)).map((w) => w["answers"]),
|
|
578
|
+
[{ label: "yes", boundary: "boundary" }],
|
|
579
|
+
);
|
|
580
|
+
} finally {
|
|
581
|
+
await page.context().close();
|
|
582
|
+
}
|
|
583
|
+
},
|
|
584
|
+
BROWSER_TEST_MS,
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
test(
|
|
588
|
+
"🔴 a stored answer to a question the page no longer asks does not stop the next answer from saving",
|
|
589
|
+
async () => {
|
|
590
|
+
const { page: html } = build(setup("browser-stale-question", approval()));
|
|
591
|
+
const page = await (await sharedBrowser()).newPage();
|
|
592
|
+
try {
|
|
593
|
+
await page.addInitScript(
|
|
594
|
+
STORE(PRACTICE_DONE, 20, {
|
|
595
|
+
G001: { answers: { renamed: "x" }, source: "page" },
|
|
596
|
+
}),
|
|
597
|
+
);
|
|
598
|
+
await page.goto("about:blank");
|
|
599
|
+
await page.setContent(html, { waitUntil: "domcontentloaded" });
|
|
600
|
+
const card = page.locator("article.item").filter({ hasText: "G001" });
|
|
601
|
+
await card.getByRole("button", { name: /Keep/ }).click();
|
|
602
|
+
await page.waitForTimeout(100);
|
|
603
|
+
// Guards: the answer is written, and the stored answer it does not know is kept as it was.
|
|
604
|
+
assert.deepEqual(
|
|
605
|
+
(await writes(page)).map((w) => w["answers"]),
|
|
606
|
+
[{ renamed: "x", keep: "keep" }],
|
|
607
|
+
);
|
|
608
|
+
} finally {
|
|
609
|
+
await page.context().close();
|
|
610
|
+
}
|
|
611
|
+
},
|
|
612
|
+
BROWSER_TEST_MS,
|
|
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
|
+
);
|