@michaelmagan/dnumb 0.1.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/AGENTS.md +180 -0
- package/LICENSE +21 -0
- package/README.md +297 -0
- package/dist/analysis/cluster.cjs +114 -0
- package/dist/analysis/cluster.d.cts +68 -0
- package/dist/analysis/cluster.d.ts +68 -0
- package/dist/analysis/cluster.js +109 -0
- package/dist/analysis/distances.cjs +124 -0
- package/dist/analysis/distances.d.cts +58 -0
- package/dist/analysis/distances.d.ts +58 -0
- package/dist/analysis/distances.js +119 -0
- package/dist/analysis/geometry.cjs +60 -0
- package/dist/analysis/geometry.d.cts +34 -0
- package/dist/analysis/geometry.d.ts +34 -0
- package/dist/analysis/geometry.js +53 -0
- package/dist/analysis/layout-tree.cjs +245 -0
- package/dist/analysis/layout-tree.d.cts +45 -0
- package/dist/analysis/layout-tree.d.ts +45 -0
- package/dist/analysis/layout-tree.js +242 -0
- package/dist/analysis/region-query.cjs +71 -0
- package/dist/analysis/region-query.d.cts +25 -0
- package/dist/analysis/region-query.d.ts +25 -0
- package/dist/analysis/region-query.js +67 -0
- package/dist/analysis/types.cjs +7 -0
- package/dist/analysis/types.d.cts +143 -0
- package/dist/analysis/types.d.ts +143 -0
- package/dist/analysis/types.js +6 -0
- package/dist/bundle.cjs +182 -0
- package/dist/bundle.d.cts +91 -0
- package/dist/bundle.d.ts +91 -0
- package/dist/bundle.js +175 -0
- package/dist/cli.cjs +755 -0
- package/dist/cli.d.cts +2 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +719 -0
- package/dist/diff/changes.cjs +656 -0
- package/dist/diff/changes.d.cts +2 -0
- package/dist/diff/changes.d.ts +2 -0
- package/dist/diff/changes.js +653 -0
- package/dist/diff/identity.cjs +167 -0
- package/dist/diff/identity.d.cts +62 -0
- package/dist/diff/identity.d.ts +62 -0
- package/dist/diff/identity.js +159 -0
- package/dist/diff/index.cjs +84 -0
- package/dist/diff/index.d.cts +76 -0
- package/dist/diff/index.d.ts +76 -0
- package/dist/diff/index.js +68 -0
- package/dist/diff/match.cjs +156 -0
- package/dist/diff/match.d.cts +17 -0
- package/dist/diff/match.d.ts +17 -0
- package/dist/diff/match.js +152 -0
- package/dist/diff/render.cjs +540 -0
- package/dist/diff/render.d.cts +2 -0
- package/dist/diff/render.d.ts +2 -0
- package/dist/diff/render.js +537 -0
- package/dist/diff/types.cjs +2 -0
- package/dist/diff/types.d.cts +211 -0
- package/dist/diff/types.d.ts +211 -0
- package/dist/diff/types.js +1 -0
- package/dist/digest/index.cjs +8 -0
- package/dist/digest/index.d.cts +4 -0
- package/dist/digest/index.d.ts +4 -0
- package/dist/digest/index.js +2 -0
- package/dist/digest/parse.cjs +163 -0
- package/dist/digest/parse.d.cts +80 -0
- package/dist/digest/parse.d.ts +80 -0
- package/dist/digest/parse.js +160 -0
- package/dist/digest/render.cjs +1575 -0
- package/dist/digest/render.d.cts +41 -0
- package/dist/digest/render.d.ts +41 -0
- package/dist/digest/render.js +1571 -0
- package/dist/digest/types.cjs +10 -0
- package/dist/digest/types.d.cts +86 -0
- package/dist/digest/types.d.ts +86 -0
- package/dist/digest/types.js +9 -0
- package/dist/index.cjs +61 -0
- package/dist/index.d.cts +27 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +19 -0
- package/dist/page.cjs +35 -0
- package/dist/page.d.cts +40 -0
- package/dist/page.d.ts +40 -0
- package/dist/page.js +31 -0
- package/dist/playwright.cjs +138 -0
- package/dist/playwright.d.cts +69 -0
- package/dist/playwright.d.ts +69 -0
- package/dist/playwright.js +131 -0
- package/dist/query.cjs +1152 -0
- package/dist/query.d.cts +47 -0
- package/dist/query.d.ts +47 -0
- package/dist/query.js +1146 -0
- package/dist/snapshot/capture.cjs +80 -0
- package/dist/snapshot/capture.d.cts +34 -0
- package/dist/snapshot/capture.d.ts +34 -0
- package/dist/snapshot/capture.js +77 -0
- package/dist/snapshot/cdp.cjs +450 -0
- package/dist/snapshot/cdp.d.cts +36 -0
- package/dist/snapshot/cdp.d.ts +36 -0
- package/dist/snapshot/cdp.js +447 -0
- package/dist/snapshot/clip.cjs +100 -0
- package/dist/snapshot/clip.d.cts +26 -0
- package/dist/snapshot/clip.d.ts +26 -0
- package/dist/snapshot/clip.js +96 -0
- package/dist/snapshot/collect.cjs +358 -0
- package/dist/snapshot/collect.d.cts +18 -0
- package/dist/snapshot/collect.d.ts +18 -0
- package/dist/snapshot/collect.js +355 -0
- package/dist/snapshot/color.cjs +1039 -0
- package/dist/snapshot/color.d.cts +316 -0
- package/dist/snapshot/color.d.ts +316 -0
- package/dist/snapshot/color.js +1026 -0
- package/dist/snapshot/facts.cjs +167 -0
- package/dist/snapshot/facts.d.cts +48 -0
- package/dist/snapshot/facts.d.ts +48 -0
- package/dist/snapshot/facts.js +161 -0
- package/dist/snapshot/limits.cjs +69 -0
- package/dist/snapshot/limits.d.cts +59 -0
- package/dist/snapshot/limits.d.ts +59 -0
- package/dist/snapshot/limits.js +65 -0
- package/dist/snapshot/not-measured.cjs +44 -0
- package/dist/snapshot/not-measured.d.cts +14 -0
- package/dist/snapshot/not-measured.d.ts +14 -0
- package/dist/snapshot/not-measured.js +41 -0
- package/dist/snapshot/types.cjs +2 -0
- package/dist/snapshot/types.d.cts +238 -0
- package/dist/snapshot/types.d.ts +238 -0
- package/dist/snapshot/types.js +1 -0
- package/dist/spec/index.cjs +9 -0
- package/dist/spec/index.d.cts +15 -0
- package/dist/spec/index.d.ts +15 -0
- package/dist/spec/index.js +6 -0
- package/package.json +141 -0
- package/skills/dnumb/SKILL.md +136 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { type Bundle } from "./bundle.cjs";
|
|
2
|
+
import type { PageLike } from "./page.cjs";
|
|
3
|
+
import { type CaptureOptions } from "./snapshot/capture.cjs";
|
|
4
|
+
/** Mirrors Playwright's own `.playwright-artifacts` habit: hidden, gitignorable, per-repo. */
|
|
5
|
+
export declare const DEFAULT_OUTPUT_DIR = ".dnumb";
|
|
6
|
+
/**
|
|
7
|
+
* The part of Playwright's `TestInfo` a capture touches. Optional in the
|
|
8
|
+
* options because `capture` must work in a bare script with no test runner.
|
|
9
|
+
*/
|
|
10
|
+
export interface TestInfoLike {
|
|
11
|
+
attach(name: string, options: {
|
|
12
|
+
body?: string;
|
|
13
|
+
contentType?: string;
|
|
14
|
+
path?: string;
|
|
15
|
+
}): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
export interface CapturePageOptions {
|
|
18
|
+
/** Directory the bundle is written to. Relative paths resolve from `cwd`. */
|
|
19
|
+
outputDir?: string;
|
|
20
|
+
/** Passed straight through to the collector. */
|
|
21
|
+
strategy?: CaptureOptions['strategy'];
|
|
22
|
+
/** A label for the viewport (`desktop`, `iphone-15`), recorded in the bundle. */
|
|
23
|
+
viewportName?: string;
|
|
24
|
+
labels?: Record<string, string>;
|
|
25
|
+
/** When present, the bundle also rides Playwright's report pipeline. */
|
|
26
|
+
testInfo?: TestInfoLike;
|
|
27
|
+
}
|
|
28
|
+
export interface PageCapture {
|
|
29
|
+
bundle: Bundle;
|
|
30
|
+
/** Where the bundle was written. Absolute if `outputDir` was. */
|
|
31
|
+
path: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* A capture name is caller-chosen text that becomes a path segment, so it is
|
|
35
|
+
* the one untrusted input here. Separators collapse into `-` rather than being
|
|
36
|
+
* rejected, because `settings/desktop` is a reasonable thing to type and
|
|
37
|
+
* failing a test over it would be hostile — but a leading run of dots or dashes
|
|
38
|
+
* is stripped outright, which is what turns `../../etc/passwd` into
|
|
39
|
+
* `etc-passwd` instead of a traversal.
|
|
40
|
+
*/
|
|
41
|
+
export declare function safeName(name: string): string;
|
|
42
|
+
/** Where `writeBundle` puts a bundle of this name. Exported so a caller can predict it. */
|
|
43
|
+
export declare function bundlePath(dir: string, name: string): string;
|
|
44
|
+
/**
|
|
45
|
+
* The bundle keeps the caller's name verbatim — it is the state label the
|
|
46
|
+
* digest header prints — and only the *path* is sanitized. Two names that
|
|
47
|
+
* differ solely in punctuation therefore land on one file: `settings/desktop`
|
|
48
|
+
* and `settings-desktop` both write `settings-desktop.json`.
|
|
49
|
+
*
|
|
50
|
+
* Re-capturing the same name overwrites, which is what a test re-run should do.
|
|
51
|
+
* A *different* name landing on an existing file is a collision, and silently
|
|
52
|
+
* discarding one of two captures a suite meant to keep is the kind of data loss
|
|
53
|
+
* that gets noticed weeks later, on the run that mattered. So that case throws,
|
|
54
|
+
* and the message names both captures and the fix.
|
|
55
|
+
*/
|
|
56
|
+
export declare function writeBundle(bundle: Bundle, dir?: string): string;
|
|
57
|
+
/**
|
|
58
|
+
* Capture `page` as a bundle on disk.
|
|
59
|
+
*
|
|
60
|
+
* ```ts
|
|
61
|
+
* import { capture } from '@michaelmagan/dnumb/playwright';
|
|
62
|
+
*
|
|
63
|
+
* test('settings page', async ({ page }, testInfo) => {
|
|
64
|
+
* await page.goto('/settings');
|
|
65
|
+
* await capture(page, 'settings-desktop', { testInfo });
|
|
66
|
+
* });
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
export declare function capture(page: PageLike, name: string, options?: CapturePageOptions): Promise<PageCapture>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { type Bundle } from "./bundle.js";
|
|
2
|
+
import type { PageLike } from "./page.js";
|
|
3
|
+
import { type CaptureOptions } from "./snapshot/capture.js";
|
|
4
|
+
/** Mirrors Playwright's own `.playwright-artifacts` habit: hidden, gitignorable, per-repo. */
|
|
5
|
+
export declare const DEFAULT_OUTPUT_DIR = ".dnumb";
|
|
6
|
+
/**
|
|
7
|
+
* The part of Playwright's `TestInfo` a capture touches. Optional in the
|
|
8
|
+
* options because `capture` must work in a bare script with no test runner.
|
|
9
|
+
*/
|
|
10
|
+
export interface TestInfoLike {
|
|
11
|
+
attach(name: string, options: {
|
|
12
|
+
body?: string;
|
|
13
|
+
contentType?: string;
|
|
14
|
+
path?: string;
|
|
15
|
+
}): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
export interface CapturePageOptions {
|
|
18
|
+
/** Directory the bundle is written to. Relative paths resolve from `cwd`. */
|
|
19
|
+
outputDir?: string;
|
|
20
|
+
/** Passed straight through to the collector. */
|
|
21
|
+
strategy?: CaptureOptions['strategy'];
|
|
22
|
+
/** A label for the viewport (`desktop`, `iphone-15`), recorded in the bundle. */
|
|
23
|
+
viewportName?: string;
|
|
24
|
+
labels?: Record<string, string>;
|
|
25
|
+
/** When present, the bundle also rides Playwright's report pipeline. */
|
|
26
|
+
testInfo?: TestInfoLike;
|
|
27
|
+
}
|
|
28
|
+
export interface PageCapture {
|
|
29
|
+
bundle: Bundle;
|
|
30
|
+
/** Where the bundle was written. Absolute if `outputDir` was. */
|
|
31
|
+
path: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* A capture name is caller-chosen text that becomes a path segment, so it is
|
|
35
|
+
* the one untrusted input here. Separators collapse into `-` rather than being
|
|
36
|
+
* rejected, because `settings/desktop` is a reasonable thing to type and
|
|
37
|
+
* failing a test over it would be hostile — but a leading run of dots or dashes
|
|
38
|
+
* is stripped outright, which is what turns `../../etc/passwd` into
|
|
39
|
+
* `etc-passwd` instead of a traversal.
|
|
40
|
+
*/
|
|
41
|
+
export declare function safeName(name: string): string;
|
|
42
|
+
/** Where `writeBundle` puts a bundle of this name. Exported so a caller can predict it. */
|
|
43
|
+
export declare function bundlePath(dir: string, name: string): string;
|
|
44
|
+
/**
|
|
45
|
+
* The bundle keeps the caller's name verbatim — it is the state label the
|
|
46
|
+
* digest header prints — and only the *path* is sanitized. Two names that
|
|
47
|
+
* differ solely in punctuation therefore land on one file: `settings/desktop`
|
|
48
|
+
* and `settings-desktop` both write `settings-desktop.json`.
|
|
49
|
+
*
|
|
50
|
+
* Re-capturing the same name overwrites, which is what a test re-run should do.
|
|
51
|
+
* A *different* name landing on an existing file is a collision, and silently
|
|
52
|
+
* discarding one of two captures a suite meant to keep is the kind of data loss
|
|
53
|
+
* that gets noticed weeks later, on the run that mattered. So that case throws,
|
|
54
|
+
* and the message names both captures and the fix.
|
|
55
|
+
*/
|
|
56
|
+
export declare function writeBundle(bundle: Bundle, dir?: string): string;
|
|
57
|
+
/**
|
|
58
|
+
* Capture `page` as a bundle on disk.
|
|
59
|
+
*
|
|
60
|
+
* ```ts
|
|
61
|
+
* import { capture } from '@michaelmagan/dnumb/playwright';
|
|
62
|
+
*
|
|
63
|
+
* test('settings page', async ({ page }, testInfo) => {
|
|
64
|
+
* await page.goto('/settings');
|
|
65
|
+
* await capture(page, 'settings-desktop', { testInfo });
|
|
66
|
+
* });
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
export declare function capture(page: PageLike, name: string, options?: CapturePageOptions): Promise<PageCapture>;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `dnumb/playwright` — one import and one call inside a suite that already has
|
|
3
|
+
* a running app, an authenticated session, and a `page` in scope.
|
|
4
|
+
*
|
|
5
|
+
* This is the wedge: the cost of a capture has to be a single line in a test
|
|
6
|
+
* someone already wrote, or the bundle never gets produced and nothing
|
|
7
|
+
* downstream of it matters.
|
|
8
|
+
*
|
|
9
|
+
* Nothing here imports Playwright, not even as a type. `playwright-core` is an
|
|
10
|
+
* *optional* peer, so a `import type { Page } from 'playwright-core'` in this
|
|
11
|
+
* file would break typechecking for a consumer who installed dnumb for the
|
|
12
|
+
* offline `--from <bundle>` path alone. The interfaces are structural instead,
|
|
13
|
+
* and a real `Page` / `TestInfo` satisfies them by shape.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { dirname, join } from 'node:path';
|
|
17
|
+
import { buildBundle, serializeBundle } from "./bundle.js";
|
|
18
|
+
import { capture as captureSnapshot } from "./snapshot/capture.js";
|
|
19
|
+
/** Mirrors Playwright's own `.playwright-artifacts` habit: hidden, gitignorable, per-repo. */
|
|
20
|
+
export const DEFAULT_OUTPUT_DIR = '.dnumb';
|
|
21
|
+
/** Everything a filename can carry without quoting on any of the three platforms. */
|
|
22
|
+
const UNSAFE = /[^a-zA-Z0-9._-]+/g;
|
|
23
|
+
/** ext4 and APFS both cap a name at 255 bytes; leave room for the extension. */
|
|
24
|
+
const MAX_NAME = 120;
|
|
25
|
+
/**
|
|
26
|
+
* A capture name is caller-chosen text that becomes a path segment, so it is
|
|
27
|
+
* the one untrusted input here. Separators collapse into `-` rather than being
|
|
28
|
+
* rejected, because `settings/desktop` is a reasonable thing to type and
|
|
29
|
+
* failing a test over it would be hostile — but a leading run of dots or dashes
|
|
30
|
+
* is stripped outright, which is what turns `../../etc/passwd` into
|
|
31
|
+
* `etc-passwd` instead of a traversal.
|
|
32
|
+
*/
|
|
33
|
+
export function safeName(name) {
|
|
34
|
+
const flat = name
|
|
35
|
+
.trim()
|
|
36
|
+
.replace(UNSAFE, '-')
|
|
37
|
+
.replace(/^[-.]+/, '')
|
|
38
|
+
.replace(/[-.]+$/, '');
|
|
39
|
+
if (!flat) {
|
|
40
|
+
throw new Error(`dnumb: capture name ${JSON.stringify(name)} has no filesystem-safe characters in it. ` +
|
|
41
|
+
'Names become filenames; use letters, digits, "." "_" or "-".');
|
|
42
|
+
}
|
|
43
|
+
return flat.slice(0, MAX_NAME);
|
|
44
|
+
}
|
|
45
|
+
/** Where `writeBundle` puts a bundle of this name. Exported so a caller can predict it. */
|
|
46
|
+
export function bundlePath(dir, name) {
|
|
47
|
+
return join(dir, `${safeName(name)}.json`);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The bundle keeps the caller's name verbatim — it is the state label the
|
|
51
|
+
* digest header prints — and only the *path* is sanitized. Two names that
|
|
52
|
+
* differ solely in punctuation therefore land on one file: `settings/desktop`
|
|
53
|
+
* and `settings-desktop` both write `settings-desktop.json`.
|
|
54
|
+
*
|
|
55
|
+
* Re-capturing the same name overwrites, which is what a test re-run should do.
|
|
56
|
+
* A *different* name landing on an existing file is a collision, and silently
|
|
57
|
+
* discarding one of two captures a suite meant to keep is the kind of data loss
|
|
58
|
+
* that gets noticed weeks later, on the run that mattered. So that case throws,
|
|
59
|
+
* and the message names both captures and the fix.
|
|
60
|
+
*/
|
|
61
|
+
export function writeBundle(bundle, dir = DEFAULT_OUTPUT_DIR) {
|
|
62
|
+
const path = bundlePath(dir, bundle.name);
|
|
63
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
64
|
+
if (existsSync(path)) {
|
|
65
|
+
const existing = readExistingName(path);
|
|
66
|
+
if (existing !== null && existing !== bundle.name) {
|
|
67
|
+
throw new Error(`dnumb: capture ${JSON.stringify(bundle.name)} and capture ${JSON.stringify(existing)} ` +
|
|
68
|
+
`both write to ${path}, because names become filenames. Rename one of them.`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
writeFileSync(path, serializeBundle(bundle), 'utf8');
|
|
72
|
+
return path;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The `name` of the bundle already at `path`, or `null` if it cannot be read as
|
|
76
|
+
* one. Unreadable is not a collision — an unrelated file in the output
|
|
77
|
+
* directory should not stop a capture — so it falls through to the overwrite.
|
|
78
|
+
*/
|
|
79
|
+
function readExistingName(path) {
|
|
80
|
+
try {
|
|
81
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
82
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
83
|
+
return null;
|
|
84
|
+
const name = parsed.name;
|
|
85
|
+
return typeof name === 'string' ? name : null;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Capture `page` as a bundle on disk.
|
|
93
|
+
*
|
|
94
|
+
* ```ts
|
|
95
|
+
* import { capture } from '@michaelmagan/dnumb/playwright';
|
|
96
|
+
*
|
|
97
|
+
* test('settings page', async ({ page }, testInfo) => {
|
|
98
|
+
* await page.goto('/settings');
|
|
99
|
+
* await capture(page, 'settings-desktop', { testInfo });
|
|
100
|
+
* });
|
|
101
|
+
* ```
|
|
102
|
+
*/
|
|
103
|
+
export async function capture(page, name, options = {}) {
|
|
104
|
+
const dir = options.outputDir ?? DEFAULT_OUTPUT_DIR;
|
|
105
|
+
// Fail on an unusable name before spending a capture on it.
|
|
106
|
+
safeName(name);
|
|
107
|
+
const result = await captureSnapshot(page, options.strategy ? { strategy: options.strategy } : {});
|
|
108
|
+
// The URL is read from inside the page rather than taken from the caller for
|
|
109
|
+
// the same reason the viewport is (`page.viewportSize()` is null over
|
|
110
|
+
// `connectOverCDP`): the page is the only thing that knows where it ended up
|
|
111
|
+
// after redirects, and a bundle that names the URL the test *asked* for is a
|
|
112
|
+
// bundle that lies six months later.
|
|
113
|
+
const url = await page.evaluate(() => location.href);
|
|
114
|
+
const bundle = buildBundle(result, {
|
|
115
|
+
name,
|
|
116
|
+
url,
|
|
117
|
+
capturedAt: new Date().toISOString(),
|
|
118
|
+
...(options.viewportName ? { viewportName: options.viewportName } : {}),
|
|
119
|
+
...(options.labels ? { labels: options.labels } : {}),
|
|
120
|
+
});
|
|
121
|
+
const path = writeBundle(bundle, dir);
|
|
122
|
+
if (options.testInfo) {
|
|
123
|
+
// `body`, not `path`: the attachment has to survive the output directory
|
|
124
|
+
// being cleaned between the run and the report being read in CI.
|
|
125
|
+
await options.testInfo.attach(`${safeName(name)}.dnumb.json`, {
|
|
126
|
+
body: serializeBundle(bundle),
|
|
127
|
+
contentType: 'application/json',
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return { bundle, path };
|
|
131
|
+
}
|