paperlint 13.6.1 → 14.0.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.
@@ -1,4 +1,4 @@
1
- import { type RuleRef } from "./rule-descriptions.ts";
1
+ import { type Described as RuleText, type RuleRef } from "./rule-descriptions.ts";
2
2
  export declare class Refusal extends Error {
3
3
  readonly exitCode: number;
4
4
  constructor(exitCode: number, message: string);
@@ -13,8 +13,8 @@ type Json = null | boolean | number | string | readonly Json[] | {
13
13
  type JsonObject = {
14
14
  readonly [key: string]: Json;
15
15
  };
16
- declare const PAGE_FIELDS: readonly ["group", "title", "body", "code", "context"];
17
- type SchemaNode = {
16
+ declare const PAGE_FIELDS: readonly ["group", "title", "body", "code", "context", "file"];
17
+ type SchemaNode = ({
18
18
  kind: "string";
19
19
  required: boolean;
20
20
  pattern?: string;
@@ -40,7 +40,16 @@ type SchemaNode = {
40
40
  required: boolean;
41
41
  closed: boolean;
42
42
  fields: Readonly<Record<string, SchemaNode>>;
43
+ }) & {
44
+ why?: string;
43
45
  };
46
+ /**
47
+ * THE PAGE KINDS, and the card fields each requires on every item and practice card (#373). A fix to
48
+ * a card is a field; a kind that needs it makes it required, so a page built without it fails the
49
+ * schema with the field named instead of shipping the old card.
50
+ */
51
+ type Kind = "approval" | "witness" | "labels";
52
+ /** The declared schema (`schema` prints it): every field, those a kind requires left optional. */
44
53
  export declare const CONFIG_SCHEMA: SchemaNode;
45
54
  interface Option {
46
55
  readonly value: string;
@@ -58,28 +67,33 @@ interface Question {
58
67
  interface Unit {
59
68
  readonly id: string;
60
69
  readonly text: string;
70
+ readonly mark: string;
61
71
  readonly part: number;
62
72
  readonly of: number;
63
73
  }
64
- type Item = {
74
+ /** What a card shows, on an item and on a practice item alike. */
75
+ type Card = {
65
76
  readonly [F in (typeof PAGE_FIELDS)[number]]?: string;
66
77
  } & {
67
- readonly source_id: string;
68
- readonly group: string;
69
78
  readonly body: string;
70
79
  readonly rules?: readonly RuleRef[];
71
80
  readonly unit?: Unit;
81
+ readonly readings?: {
82
+ readonly a: string;
83
+ readonly b: string;
84
+ };
72
85
  };
73
- interface Practice {
74
- readonly title?: string;
75
- readonly body: string;
76
- readonly code?: string;
77
- readonly context?: string;
86
+ type Item = Card & {
87
+ readonly source_id: string;
88
+ readonly group: string;
89
+ };
90
+ type Practice = Card & {
78
91
  readonly expected: Readonly<Record<string, string>>;
79
92
  readonly why: string;
80
- }
93
+ };
81
94
  interface Config {
82
95
  readonly study: string;
96
+ readonly kind: Kind;
83
97
  readonly title: string;
84
98
  readonly intro?: string;
85
99
  readonly seed: number;
@@ -97,8 +111,8 @@ interface KeyEntry {
97
111
  readonly group: string;
98
112
  readonly on_page: boolean;
99
113
  }
100
- /** Rule → the linter's description, for every rule an item on the page names. */
101
- type Described = ReadonlyMap<string, string>;
114
+ /** Rule → the linter's description (and a ruff rule's family), for every rule a card names. */
115
+ type Described = ReadonlyMap<string, RuleText>;
102
116
  /** Only what the labeller can answer goes on the page, and only that gets an id, in id order. */
103
117
  declare function itemsOnPage(cfg: Config, keyPath: string): {
104
118
  items: Item[];
@@ -123,8 +137,21 @@ declare function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>, de
123
137
  questions: readonly Question[];
124
138
  practice: {
125
139
  round: number;
126
- items: readonly (Practice & {
127
- readonly id: string;
140
+ items: ({
141
+ rules?: never;
142
+ id: string;
143
+ expected: Readonly<Record<string, string>>;
144
+ why: string;
145
+ } | {
146
+ rules: {
147
+ description?: string;
148
+ family?: string;
149
+ linter: "eslint" | "ruff";
150
+ id: string;
151
+ }[];
152
+ id: string;
153
+ expected: Readonly<Record<string, string>>;
154
+ why: string;
128
155
  })[];
129
156
  };
130
157
  items: ({
@@ -132,9 +159,10 @@ declare function payloadOf(cfg: Config, page: ReturnType<typeof itemsOnPage>, de
132
159
  id: string;
133
160
  } | {
134
161
  rules: {
162
+ description?: string;
163
+ family?: string;
135
164
  linter: "eslint" | "ruff";
136
165
  id: string;
137
- description: string;
138
166
  }[];
139
167
  id: string;
140
168
  })[];
@@ -162,8 +190,21 @@ export declare function prepareBuild(configPath: string, outDir: string): {
162
190
  questions: readonly Question[];
163
191
  practice: {
164
192
  round: number;
165
- items: readonly (Practice & {
166
- readonly id: string;
193
+ items: ({
194
+ rules?: never;
195
+ id: string;
196
+ expected: Readonly<Record<string, string>>;
197
+ why: string;
198
+ } | {
199
+ rules: {
200
+ description?: string;
201
+ family?: string;
202
+ linter: "eslint" | "ruff";
203
+ id: string;
204
+ }[];
205
+ id: string;
206
+ expected: Readonly<Record<string, string>>;
207
+ why: string;
167
208
  })[];
168
209
  };
169
210
  items: ({
@@ -171,9 +212,10 @@ export declare function prepareBuild(configPath: string, outDir: string): {
171
212
  id: string;
172
213
  } | {
173
214
  rules: {
215
+ description?: string;
216
+ family?: string;
174
217
  linter: "eslint" | "ruff";
175
218
  id: string;
176
- description: string;
177
219
  }[];
178
220
  id: string;
179
221
  })[];
@@ -2,6 +2,11 @@ export interface RuleRef {
2
2
  readonly linter: "eslint" | "ruff";
3
3
  readonly id: string;
4
4
  }
5
+ /** What the linter says a rule does; a ruff rule also names the family it comes from. */
6
+ export interface Described {
7
+ readonly description: string;
8
+ readonly family?: string;
9
+ }
5
10
  /** A process run to its end: the exit status and what it printed. */
6
11
  export type RunProcess = (cmd: string, args: readonly string[]) => {
7
12
  readonly status: number | null;
@@ -9,4 +14,4 @@ export type RunProcess = (cmd: string, args: readonly string[]) => {
9
14
  };
10
15
  export declare const runProcess: RunProcess;
11
16
  /** The linter's own one-line description of `ref`, or undefined when it has none. */
12
- export declare function describeRule(ref: RuleRef, fromDir: string, run?: RunProcess): string | undefined;
17
+ export declare function describeRule(ref: RuleRef, fromDir: string, run?: RunProcess): Described | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paperlint",
3
- "version": "13.6.1",
3
+ "version": "14.0.0",
4
4
  "description": "A linter for scientific papers written in LaTeX: catches mistakes before you submit to a conference or journal.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -4,7 +4,7 @@ description: Build a phone labelling page published as a claude.ai Artifact with
4
4
  allowed-tools: [Read, Write, Edit, Grep, Glob, Bash, Artifact, ArtifactData]
5
5
  ---
6
6
 
7
- <!-- vigiles:sha256:bd616b2694831fa1 compiled from skills/build-label-page/SKILL.md.spec.ts -->
7
+ <!-- vigiles:sha256:6e828827dc6fcfc1 compiled from skills/build-label-page/SKILL.md.spec.ts -->
8
8
 
9
9
  # build-label-page — a page the person labels on a phone, and the labels back in the repo
10
10
 
@@ -63,8 +63,8 @@ building. In one line each:
63
63
  source labels on the page. The generator refuses to build when an item carries a field outside an
64
64
  allow-list; a page written by hand checks its items against the same list.
65
65
  5. **Ask only what the labeller can answer.** Only the languages and tools they know; one "how well do
66
- you know it" question per group, used later as an analysis filter (off on an approval page,
67
- `"familiarity": false`: the author has not seen those repositories); "clear-cut or real boundary"
66
+ you know it" question per group when the study filters on it (`"familiarity": true`; left
67
+ out, none is asked, as on an approval page, whose author has not seen those repositories); "clear-cut or real boundary"
68
68
  is the labeller's judgement, not the designer's.
69
69
  6. **Export into the repository right away**, one JSON per page into the study's `data/` with the
70
70
  export date, commit it, and record answers given in chat with `source: "chat"`. If the page fails,
@@ -104,7 +104,8 @@ building. In one line each:
104
104
  ```
105
105
 
106
106
  It writes `label-page/page.html` and `label-page/key.json` (the only link from `G001` to the
107
- source id; it never goes on the page). Written by hand, the page meets the requirements above.
107
+ source id; it never goes on the page). `kind` (`approval`, `witness`, `labels`) is required,
108
+ and each kind requires its card fields on every item and practice card. Written by hand, the page meets the requirements above.
108
109
  4. **Read it as the labeller, then look at it.** Read the page text once as a person who knows
109
110
  nothing about the source: if anything on it tells the answer, take it out. Read each question as
110
111
  someone who did not design the study: can they tell what each answer means from the question, its
@@ -74,8 +74,8 @@ building. In one line each:
74
74
  source labels on the page. The generator refuses to build when an item carries a field outside an
75
75
  allow-list; a page written by hand checks its items against the same list.
76
76
  5. **Ask only what the labeller can answer.** Only the languages and tools they know; one "how well do
77
- you know it" question per group, used later as an analysis filter (off on an approval page,
78
- \`"familiarity": false\`: the author has not seen those repositories); "clear-cut or real boundary"
77
+ you know it" question per group when the study filters on it (\`"familiarity": true\`; left
78
+ out, none is asked, as on an approval page, whose author has not seen those repositories); "clear-cut or real boundary"
79
79
  is the labeller's judgement, not the designer's.
80
80
  6. **Export into the repository right away**, one JSON per page into the study's \`data/\` with the
81
81
  export date, commit it, and record answers given in chat with \`source: "chat"\`. If the page fails,
@@ -115,7 +115,8 @@ building. In one line each:
115
115
  \`\`\`
116
116
 
117
117
  It writes \`label-page/page.html\` and \`label-page/key.json\` (the only link from \`G001\` to the
118
- source id; it never goes on the page). Written by hand, the page meets the requirements above.
118
+ source id; it never goes on the page). \`kind\` (\`approval\`, \`witness\`, \`labels\`) is required,
119
+ and each kind requires its card fields on every item and practice card. Written by hand, the page meets the requirements above.
119
120
  4. **Read it as the labeller, then look at it.** Read the page text once as a person who knows
120
121
  nothing about the source: if anything on it tells the answer, take it out. Read each question as
121
122
  someone who did not design the study: can they tell what each answer means from the question, its
@@ -81,11 +81,11 @@ the text of an allowed field says.
81
81
 
82
82
  - Restrict the items to the languages and tools the labeller knows (`labeller_knows`). Items of
83
83
  other groups are not put on the page and get no id.
84
- - Add one question per group, "how well do you know this language / tool" (well / somewhat / not
85
- really). It is an analysis filter: the study can report the labels with and without the groups
86
- the labeller does not know well. An approval page asks none (`"familiarity": false` in
87
- `items.json`): its question is about a drawn unit, not about repositories the author has seen,
88
- and an answer to "how well do you know the projects of this group" there means nothing.
84
+ - When the study filters on it, ask one question per group, "how well do you know this language /
85
+ tool" (well / somewhat / not really), with `"familiarity": true` in `items.json`: the study can
86
+ then report the labels with and without the groups the labeller does not know well. Left out, no
87
+ page asks it: on an approval page the question is about a drawn unit, not about repositories the
88
+ author has seen, and "how well do you know the projects of this group" there means nothing.
89
89
  - The per-item judgement "clear-cut or a real boundary case" is the labeller's. The study design
90
90
  does not decide it in advance and does not pre-fill it.
91
91
 
@@ -94,7 +94,7 @@ answer is the only way to find it afterwards. And a boundary case is boundary be
94
94
  person hesitates; a designer who tags it in advance has measured the design, not the item.
95
95
 
96
96
  **Check.** The build prints how many items were left out; the export has a familiarity value for
97
- every group, or the page was built with `"familiarity": false` and the export has no groups.
97
+ every group, or the page was built without `"familiarity": true` and the export has no groups.
98
98
 
99
99
  ## 6. Export into the repository right away
100
100
 
@@ -33,24 +33,45 @@ length.
33
33
  required `example`: a real case of the study, in a line, that takes that answer. `icon` (an
34
34
  emoji) is optional. Two labels that read the same, or two equal values, stop the build.
35
35
  - **`practice`**: 3 or more real cases whose answer you are sure of, each with `expected` (one answer
36
- per question) and a one-line `why`. Never an item that is also on the page to be labelled.
36
+ per question) and a one-line `why`. Never an item that is also on the page to be labelled. A
37
+ practice card carries the same fields as the real cards it rehearses (`rules`, `unit`,
38
+ `readings`, `file`).
37
39
  - **`practice_round`**: raise it after rewording a question; the practice reopens and the answers of
38
40
  the earlier round are kept but not counted.
39
41
  - **`items`**: `source_id` (the study's own, never shown), `group`, `body`, and optionally `title`,
40
- `code`, `context`, `rules`, `unit`. Nothing else: the allow-list is fixed in the generator, and a field outside it
41
- is taken out of the item, not added to the list to make a build pass.
42
+ `code`, `context`, `rules`, `unit`, `readings`, `file`. Nothing else: the allow-list is fixed in
43
+ the generator, and a field outside it is taken out of the item, not added to the list to make a
44
+ build pass.
45
+ - **`kind`** (required): what the page asks, and so which card fields every item and practice card
46
+ must carry. A card without one fails the schema, naming the field and why the kind needs it:
47
+
48
+ | kind | the labeller decides | required on every card |
49
+ | ---------- | ---------------------------------------------------- | -------------------------------------------------- |
50
+ | `approval` | keep or drop a drawn requirement | `unit` (the line, the requirement marked), `rules` |
51
+ | `witness` | why two readings of a requirement disagree on a file | `rules`, `readings`, `file` |
52
+ | `labels` | a gold label, a blind review | nothing beyond the item |
53
+
54
+ Nothing reads `context`: it stays free text. A rule, a line or two readings written there show
55
+ none of what their fields show, so on an `approval` or `witness` page they go in the fields.
56
+
42
57
  - **`rules`**: `[{linter, id}]` (`linter` is `eslint` or `ruff`) on an item read as a linter rule.
43
- The card shows "Rule ruff TID253: Checks for module-level imports that should instead be imported
44
- lazily…", the description taken at build time from the linter itself: `ruff rule <id>`'s "What it
45
- does" paragraph, or the ESLint rule's `meta.docs.description` from the ESLint and plugins
46
- installed where `items.json` is. The build refuses a rule the linter cannot describe; a
58
+ The card's "Rule" row shows "ruff `TID253` (flake8-tidy-imports): Checks for module-level
59
+ imports that should instead be imported lazily…", taken at build time from the linter itself:
60
+ `ruff rule <id>`'s "What it does" paragraph and family, or the ESLint rule's
61
+ `meta.docs.description` from the ESLint and plugins installed where `items.json` is (a plugin
62
+ rule's own name may hold a slash: `n/no-unsupported-features/node-builtins`). The build refuses a rule the linter cannot describe; a
47
63
  description is never typed into `items.json`.
48
- - **`unit`**: `{id, text, part, of}` on an item that is one requirement of a line holding several
49
- ("TypeScript only (no JavaScript); top-level await is allowed" is two). Each requirement is its
50
- own item with its own question, and its card shows "From the line (requirement 1 of 2):" and the
51
- whole line beneath, so the labeller judges one requirement at a time and sees where it came from.
52
- The build refuses a line whose parts do not add up (one missing, repeated, a different `of`, or
53
- split across groups), naming the item. `id` names one occurrence of the line (a file and line
64
+ - **`unit`**: `{id, text, mark, part, of}` on an item drawn from a source line: the whole line,
65
+ the requirement's own words in it (`mark`, shown highlighted), and which requirement of the line
66
+ it is ("TypeScript only (no JavaScript); top-level await is allowed" is two). The card's "Line"
67
+ row shows the line with the mark, and "part 1 of 2" when the line holds several. A drawn sample
68
+ may hold one part of a line and not the others; the build refuses a `mark` that is not in the
69
+ line, and parts of one line that disagree (another text or `of`, a part repeated, split across
70
+ groups), naming the item.
71
+ - **`readings`**: `{a, b}` on an item that compares two readings of one requirement: two rows,
72
+ "Reading A" on a solid accent rule and "Reading B" on a dashed one, in the same place on every
73
+ card. **`file`**: the witness file (a path, a link, what each reading did with it), on its own
74
+ "File" row. `id` names one occurrence of the line (a file and line
54
75
  number, say) and is never shown: two files holding the same words are two lines, each judged in
55
76
  full. Splitting the line is the study's job, before the page.
56
77
  - **`seed`**: fixes the order. Rebuilding with the same seed keeps every id, and a new item gets the
@@ -89,9 +110,10 @@ drop, the drop carrying `ask_why`.
89
110
  }
90
111
  ```
91
112
 
92
- The page sets `"familiarity": false`: the author is judging drawn units, not repositories they
93
- have seen, so the per-group "how well do you know it" question is left out (and a
94
- `familiarity_question` beside it is refused).
113
+ The page sets `"kind": "approval"`, so every card carries its `unit` and `rules`, and leaves
114
+ `familiarity` out: the author is judging drawn units, not repositories they have
115
+ seen, so no per-group "how well do you know it" question is asked. Only a page that sets
116
+ `"familiarity": true` asks it (and a `familiarity_question` without it is refused).
95
117
 
96
118
  The items are the drawn units verbatim, in a language the author reads. Units in other languages
97
119
  are taken out before the draw, as a population rule the study's `plan.json` states ("English-language
@@ -4,8 +4,13 @@
4
4
  * cannot show: focus, typing, and a page that is left without the box ever losing focus.
5
5
  */
6
6
  import assert from "node:assert/strict";
7
- import { chromium, type Page } from "playwright-core";
8
- import { test } from "vitest";
7
+ import {
8
+ type Browser,
9
+ chromium,
10
+ type Locator,
11
+ type Page,
12
+ } from "playwright-core";
13
+ import { afterAll, test } from "vitest";
9
14
  import { z } from "zod";
10
15
  import { chromiumPath } from "../../../test/chromium.ts";
11
16
  import {
@@ -66,6 +71,20 @@ async function reasonStored(page: Page, text: string): Promise<void> {
66
71
  assert.equal(await storedReason(page), text);
67
72
  }
68
73
 
74
+ /**
75
+ * One Chromium for the whole file, each test in a context of its own (`newPage` opens one): a launch
76
+ * per test cost about 5 s apiece on the macOS runner, and the file's wall time grew with every test.
77
+ */
78
+ let launched: Browser | undefined;
79
+ const sharedBrowser = async (): Promise<Browser> =>
80
+ (launched ??= await chromium.launch({
81
+ executablePath: chromiumPath(),
82
+ args: ["--no-sandbox"],
83
+ }));
84
+ afterAll(async () => {
85
+ await launched?.close();
86
+ });
87
+
69
88
  /** A browser test launches Chromium and types: far above vitest's 5 s default on a slow runner. */
70
89
  const BROWSER_TEST_MS = 60_000;
71
90
 
@@ -76,12 +95,9 @@ async function onApprovalPage(
76
95
  filter: "all" | "next" = "all",
77
96
  ): Promise<void> {
78
97
  const { page: html } = build(setup(name, approval()));
79
- const browser = await chromium.launch({
80
- executablePath: chromiumPath(),
81
- args: ["--no-sandbox"],
82
- });
98
+ const browser = await sharedBrowser();
99
+ const page = await browser.newPage();
83
100
  try {
84
- const page = await browser.newPage();
85
101
  await page.addInitScript(STORE(PRACTICE_DONE, echoMs));
86
102
  await page.goto("about:blank");
87
103
  await page.setContent(html, { waitUntil: "domcontentloaded" });
@@ -90,7 +106,7 @@ async function onApprovalPage(
90
106
  await page.getByRole("button", { name: /Drop/ }).first().click();
91
107
  await steps(page);
92
108
  } finally {
93
- await browser.close();
109
+ await page.context().close();
94
110
  }
95
111
  }
96
112
 
@@ -163,12 +179,9 @@ test(
163
179
  "a Keep needs no reason and writes none: the page stays quiet",
164
180
  async () => {
165
181
  const { page: html } = build(setup("browser-keep", approval()));
166
- const browser = await chromium.launch({
167
- executablePath: chromiumPath(),
168
- args: ["--no-sandbox"],
169
- });
182
+ const browser = await sharedBrowser();
183
+ const page = await browser.newPage();
170
184
  try {
171
- const page = await browser.newPage();
172
185
  await page.addInitScript(STORE(PRACTICE_DONE, 20));
173
186
  await page.goto("about:blank");
174
187
  await page.setContent(html, { waitUntil: "domcontentloaded" });
@@ -180,7 +193,89 @@ test(
180
193
  [{}],
181
194
  );
182
195
  } finally {
183
- await browser.close();
196
+ await page.context().close();
197
+ }
198
+ },
199
+ BROWSER_TEST_MS,
200
+ );
201
+
202
+ /** A two-reading card with a long URL and compact JSON: what pushed the page sideways on a phone. */
203
+ const URL_LONG = `https://github.com/a/b/blob/${"0".repeat(40)}/src/a/long/path/to/witness.ts`;
204
+ const WITNESS = {
205
+ source_id: "s1",
206
+ group: "python",
207
+ body: "Warn for unused variables, except those prefixed with `_`",
208
+ unit: {
209
+ id: "L",
210
+ text: "Warn for unused variables",
211
+ mark: "unused",
212
+ part: 1,
213
+ of: 1,
214
+ },
215
+ rules: [{ linter: "eslint", id: "no-console" }],
216
+ readings: {
217
+ a: `[{"argsIgnorePattern":"^_","varsIgnorePattern":"^_","caughtErrors":"all"}]`,
218
+ b: "[]",
219
+ },
220
+ file: URL_LONG,
221
+ context: URL_LONG,
222
+ };
223
+
224
+ /** Where the line, the rule, reading A, reading B and the question sit on a card, top to bottom. */
225
+ const blocksOf = async (card: Locator) =>
226
+ Promise.all(
227
+ [
228
+ ".quote",
229
+ ".row:has(.lbl:text-is('Rule'))",
230
+ ".reading.a",
231
+ ".reading.b",
232
+ ".ask",
233
+ ].map(async (sel) => card.locator(sel).first().boundingBox()),
234
+ );
235
+
236
+ test(
237
+ "🔴 at phone width the card reads by position: line, rule, reading A above reading B, then the question; nothing pushes the page sideways",
238
+ async () => {
239
+ const { page: html } = build(
240
+ setup("browser-layout", approval({ items: [WITNESS] })),
241
+ );
242
+ const page = await (
243
+ await sharedBrowser()
244
+ ).newPage({ viewport: { width: 400, height: 900 } });
245
+ try {
246
+ await page.addInitScript(STORE(PRACTICE_DONE, 20));
247
+ await page.setContent(html, { waitUntil: "domcontentloaded" });
248
+ const card = page.locator("article.item").last();
249
+ const boxes = await blocksOf(card);
250
+ const [line, rule, a, b, ask] = boxes;
251
+ assert.ok(line && rule && a && b && ask, "every block is on the card");
252
+ // Guards: each block below the one before, A and B in one column, a gap before the question.
253
+ assert.deepEqual(
254
+ boxes
255
+ .slice(1)
256
+ .map(
257
+ (x, i) =>
258
+ (x?.y ?? 0) >= (boxes[i]?.y ?? 0) + (boxes[i]?.height ?? 0),
259
+ ),
260
+ [true, true, true, true],
261
+ );
262
+ assert.equal(a.x, b.x);
263
+ assert.ok(ask.y - (b.y + b.height) >= 16, "space before the question");
264
+ assert.deepEqual(
265
+ await card
266
+ .locator(".reading")
267
+ .evaluateAll((els) =>
268
+ els.map((e) => getComputedStyle(e).borderLeftStyle),
269
+ ),
270
+ ["solid", "dashed"],
271
+ );
272
+ // Guards: a long URL or compact JSON wraps; the page itself never scrolls sideways.
273
+ assert.equal(
274
+ await page.evaluate(() => document.documentElement.scrollWidth),
275
+ 400,
276
+ );
277
+ } finally {
278
+ await page.context().close();
184
279
  }
185
280
  },
186
281
  BROWSER_TEST_MS,