@zerotal/testing 1.7.4 → 1.8.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/api-surface.md CHANGED
@@ -302,8 +302,12 @@ class BrowserPage = {
302
302
  connection: () => Promise<string | null>
303
303
  count: (selector: string) => Promise<number>
304
304
  evaluate: <T = unknown>(expression: string) => Promise<T>
305
+ horizontalOverflow: () => Promise<number>
305
306
  html: (selector: string) => Promise<string | null>
307
+ overflowTrace: (selector: string) => Promise<string[]>
308
+ overflowingElements: (limit?: number) => Promise<string[]>
306
309
  press: (key: string) => Promise<BrowserPage>
310
+ resize: (width: number, height: number) => Promise<BrowserPage>
307
311
  socketUpgraded: () => boolean
308
312
  text: (selector: string) => Promise<string | null>
309
313
  transport: () => TransportReport
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/testing",
3
- "version": "1.7.4",
3
+ "version": "1.8.0",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -32,14 +32,14 @@
32
32
  "typecheck": "tsc --noEmit"
33
33
  },
34
34
  "dependencies": {
35
- "@zerotal/core": "1.7.4",
36
- "@zerotal/orm": "1.7.4",
37
- "@zerotal/queue": "1.7.4",
38
- "@zerotal/notifications": "1.7.4"
35
+ "@zerotal/core": "1.8.0",
36
+ "@zerotal/orm": "1.8.0",
37
+ "@zerotal/queue": "1.8.0",
38
+ "@zerotal/notifications": "1.8.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^5.8.0",
42
- "@zerotal/session": "1.7.4"
42
+ "@zerotal/session": "1.8.0"
43
43
  },
44
44
  "description": "Testing utilities for Zerotal — an in-process test app, HTTP helpers, and database refresh.",
45
45
  "keywords": [
@@ -1,6 +1,7 @@
1
1
  import { createTestApp, type TestApp } from "../TestApp.ts";
2
2
  import { backendOption, browserAvailability } from "./chrome.ts";
3
3
  import type { Application } from "@zerotal/core";
4
+ import { Router } from "@zerotal/core";
4
5
 
5
6
  /**
6
7
  * Read a CDP event's params.
@@ -88,6 +89,96 @@ export class BrowserPage {
88
89
  return this._view.evaluate<T>(expression);
89
90
  }
90
91
 
92
+ /**
93
+ * Resize the viewport, for the failures that only exist at a particular width.
94
+ *
95
+ * Horizontal overflow is the obvious one: a page can be flawless at 1280 and
96
+ * scroll sideways at 375 because one element inside a `not-prose` block has no
97
+ * width constraint. Nothing server-side can see it, and neither can a browser
98
+ * test that never leaves the default window size.
99
+ */
100
+ async resize(width: number, height: number): Promise<this> {
101
+ this._assertOpen();
102
+ await this._view.resize(width, height);
103
+ return this;
104
+ }
105
+
106
+ /**
107
+ * How far the document can scroll sideways beyond the viewport, in pixels.
108
+ *
109
+ * `0` is the only healthy answer. `documentElement.scrollWidth` rather than
110
+ * `body`'s, because an overflowing child can push the scrollable area wider
111
+ * than the body box without the body itself ever being wide.
112
+ */
113
+ async horizontalOverflow(): Promise<number> {
114
+ return this.evaluate<number>(
115
+ "Math.max(0, document.documentElement.scrollWidth - window.innerWidth)",
116
+ );
117
+ }
118
+
119
+ /**
120
+ * The elements sticking out past the right edge, widest first.
121
+ *
122
+ * A number alone says a page overflows by 204 pixels and leaves whoever reads
123
+ * the failure to work out by what — which on a page of six thousand nodes is
124
+ * the whole of the job. This names them.
125
+ */
126
+ async overflowingElements(limit = 5): Promise<string[]> {
127
+ return this.evaluate<string[]>(
128
+ "(() => {" +
129
+ "const edge = window.innerWidth; const out = [];" +
130
+ 'for (const el of document.querySelectorAll("body *")) {' +
131
+ "const r = el.getBoundingClientRect();" +
132
+ "if (r.right <= edge + 1 || r.width === 0) continue;" +
133
+ 'const id = el.id ? "#" + el.id : "";' +
134
+ 'const cls = typeof el.className === "string" && el.className' +
135
+ ' ? "." + el.className.trim().split(" ").filter(Boolean).slice(0, 3).join(".") : "";' +
136
+ "out.push({ s: el.tagName.toLowerCase() + id + cls, o: Math.round(r.right - edge) });" +
137
+ "}" +
138
+ "return out.sort((a, b) => b.o - a.o).slice(0, " +
139
+ limit +
140
+ ")" +
141
+ '.map((e) => e.s + " (+" + e.o + "px)");' +
142
+ "})()",
143
+ );
144
+ }
145
+
146
+ /**
147
+ * Why an element overflows: its ancestors, and which of them fails to contain it.
148
+ *
149
+ * An element inside an `overflow-x: auto` box is *allowed* to measure wider than
150
+ * the viewport — the box scrolls and the page does not. So "this element sticks
151
+ * out" is not yet a bug report; the question is which ancestor was supposed to
152
+ * clip it and did not, which is what this walks up and answers.
153
+ */
154
+ async overflowTrace(selector: string): Promise<string[]> {
155
+ return this.evaluate<string[]>(
156
+ "(() => {" +
157
+ "let el = document.querySelector(" +
158
+ quote(selector) +
159
+ ");" +
160
+ 'if (!el) return ["not found: " + ' +
161
+ quote(selector) +
162
+ "];" +
163
+ "const out = [];" +
164
+ "while (el && el !== document.documentElement) {" +
165
+ "const cs = getComputedStyle(el);" +
166
+ "out.push(" +
167
+ "el.tagName.toLowerCase() +" +
168
+ '(el.id ? "#" + el.id : "") +' +
169
+ '" client=" + el.clientWidth +' +
170
+ '" scroll=" + el.scrollWidth +' +
171
+ '" overflowX=" + cs.overflowX +' +
172
+ '" minWidth=" + cs.minWidth +' +
173
+ '" display=" + cs.display' +
174
+ ");" +
175
+ "el = el.parentElement;" +
176
+ "}" +
177
+ "return out;" +
178
+ "})()",
179
+ );
180
+ }
181
+
91
182
  /** `textContent` of the first match, trimmed. `null` when nothing matches. */
92
183
  async text(selector: string): Promise<string | null> {
93
184
  return this.evaluate<string | null>(
@@ -323,12 +414,35 @@ export class FlowBrowser {
323
414
  // `boot()`, inside `start()`. So a `setup` that calls `Router.flow(...)`
324
415
  // would find it undefined. `boot()` is idempotent and `start()` skips it
325
416
  // when already booted, so pulling it forward changes only the ordering.
326
- const app = await createTestApp(async () => {
327
- const application = await bootstrap();
328
- application.adoptAsCurrent();
329
- await application.boot();
330
- return application;
331
- }, options.setup);
417
+ const app = await createTestApp(
418
+ async () => {
419
+ const application = await bootstrap();
420
+ application.adoptAsCurrent();
421
+ await application.boot();
422
+ return application;
423
+ },
424
+ () => {
425
+ // Serve `public/`, which the framework mounts only for the `web`
426
+ // environment — and a test app is not one.
427
+ //
428
+ // Without this a browser suite drives an unstyled site: the stylesheet is
429
+ // built and present on disk and simply never served, so every `<pre>`
430
+ // loses its `overflow-x: auto` and the pages scroll sideways. A layout
431
+ // assertion then measures a page no user will ever see. It cost four CI
432
+ // round trips to find, because `zt serve` runs as `web` and every local
433
+ // reproduction served the file correctly.
434
+ //
435
+ // Registered before the caller's own `setup`, so a suite can still
436
+ // override the mount if it means to.
437
+ // `process.cwd()`, matching what the framework's own `_bootConventions`
438
+ // does — which means a suite invoked from the repo root rather than from
439
+ // the app directory mounts a `public/` that is not there, and every page
440
+ // arrives unstyled. It fails as a layout assertion rather than as a
441
+ // missing file, so it is worth knowing before spending an hour on it.
442
+ Router.static("/", `${process.cwd()}/public`);
443
+ options.setup?.();
444
+ },
445
+ );
332
446
  return new FlowBrowser(app, options.timeout ?? DEFAULT_TIMEOUT);
333
447
  }
334
448