@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.
Files changed (133) hide show
  1. package/AGENTS.md +180 -0
  2. package/LICENSE +21 -0
  3. package/README.md +297 -0
  4. package/dist/analysis/cluster.cjs +114 -0
  5. package/dist/analysis/cluster.d.cts +68 -0
  6. package/dist/analysis/cluster.d.ts +68 -0
  7. package/dist/analysis/cluster.js +109 -0
  8. package/dist/analysis/distances.cjs +124 -0
  9. package/dist/analysis/distances.d.cts +58 -0
  10. package/dist/analysis/distances.d.ts +58 -0
  11. package/dist/analysis/distances.js +119 -0
  12. package/dist/analysis/geometry.cjs +60 -0
  13. package/dist/analysis/geometry.d.cts +34 -0
  14. package/dist/analysis/geometry.d.ts +34 -0
  15. package/dist/analysis/geometry.js +53 -0
  16. package/dist/analysis/layout-tree.cjs +245 -0
  17. package/dist/analysis/layout-tree.d.cts +45 -0
  18. package/dist/analysis/layout-tree.d.ts +45 -0
  19. package/dist/analysis/layout-tree.js +242 -0
  20. package/dist/analysis/region-query.cjs +71 -0
  21. package/dist/analysis/region-query.d.cts +25 -0
  22. package/dist/analysis/region-query.d.ts +25 -0
  23. package/dist/analysis/region-query.js +67 -0
  24. package/dist/analysis/types.cjs +7 -0
  25. package/dist/analysis/types.d.cts +143 -0
  26. package/dist/analysis/types.d.ts +143 -0
  27. package/dist/analysis/types.js +6 -0
  28. package/dist/bundle.cjs +182 -0
  29. package/dist/bundle.d.cts +91 -0
  30. package/dist/bundle.d.ts +91 -0
  31. package/dist/bundle.js +175 -0
  32. package/dist/cli.cjs +755 -0
  33. package/dist/cli.d.cts +2 -0
  34. package/dist/cli.d.ts +2 -0
  35. package/dist/cli.js +719 -0
  36. package/dist/diff/changes.cjs +656 -0
  37. package/dist/diff/changes.d.cts +2 -0
  38. package/dist/diff/changes.d.ts +2 -0
  39. package/dist/diff/changes.js +653 -0
  40. package/dist/diff/identity.cjs +167 -0
  41. package/dist/diff/identity.d.cts +62 -0
  42. package/dist/diff/identity.d.ts +62 -0
  43. package/dist/diff/identity.js +159 -0
  44. package/dist/diff/index.cjs +84 -0
  45. package/dist/diff/index.d.cts +76 -0
  46. package/dist/diff/index.d.ts +76 -0
  47. package/dist/diff/index.js +68 -0
  48. package/dist/diff/match.cjs +156 -0
  49. package/dist/diff/match.d.cts +17 -0
  50. package/dist/diff/match.d.ts +17 -0
  51. package/dist/diff/match.js +152 -0
  52. package/dist/diff/render.cjs +540 -0
  53. package/dist/diff/render.d.cts +2 -0
  54. package/dist/diff/render.d.ts +2 -0
  55. package/dist/diff/render.js +537 -0
  56. package/dist/diff/types.cjs +2 -0
  57. package/dist/diff/types.d.cts +211 -0
  58. package/dist/diff/types.d.ts +211 -0
  59. package/dist/diff/types.js +1 -0
  60. package/dist/digest/index.cjs +8 -0
  61. package/dist/digest/index.d.cts +4 -0
  62. package/dist/digest/index.d.ts +4 -0
  63. package/dist/digest/index.js +2 -0
  64. package/dist/digest/parse.cjs +163 -0
  65. package/dist/digest/parse.d.cts +80 -0
  66. package/dist/digest/parse.d.ts +80 -0
  67. package/dist/digest/parse.js +160 -0
  68. package/dist/digest/render.cjs +1575 -0
  69. package/dist/digest/render.d.cts +41 -0
  70. package/dist/digest/render.d.ts +41 -0
  71. package/dist/digest/render.js +1571 -0
  72. package/dist/digest/types.cjs +10 -0
  73. package/dist/digest/types.d.cts +86 -0
  74. package/dist/digest/types.d.ts +86 -0
  75. package/dist/digest/types.js +9 -0
  76. package/dist/index.cjs +61 -0
  77. package/dist/index.d.cts +27 -0
  78. package/dist/index.d.ts +27 -0
  79. package/dist/index.js +19 -0
  80. package/dist/page.cjs +35 -0
  81. package/dist/page.d.cts +40 -0
  82. package/dist/page.d.ts +40 -0
  83. package/dist/page.js +31 -0
  84. package/dist/playwright.cjs +138 -0
  85. package/dist/playwright.d.cts +69 -0
  86. package/dist/playwright.d.ts +69 -0
  87. package/dist/playwright.js +131 -0
  88. package/dist/query.cjs +1152 -0
  89. package/dist/query.d.cts +47 -0
  90. package/dist/query.d.ts +47 -0
  91. package/dist/query.js +1146 -0
  92. package/dist/snapshot/capture.cjs +80 -0
  93. package/dist/snapshot/capture.d.cts +34 -0
  94. package/dist/snapshot/capture.d.ts +34 -0
  95. package/dist/snapshot/capture.js +77 -0
  96. package/dist/snapshot/cdp.cjs +450 -0
  97. package/dist/snapshot/cdp.d.cts +36 -0
  98. package/dist/snapshot/cdp.d.ts +36 -0
  99. package/dist/snapshot/cdp.js +447 -0
  100. package/dist/snapshot/clip.cjs +100 -0
  101. package/dist/snapshot/clip.d.cts +26 -0
  102. package/dist/snapshot/clip.d.ts +26 -0
  103. package/dist/snapshot/clip.js +96 -0
  104. package/dist/snapshot/collect.cjs +358 -0
  105. package/dist/snapshot/collect.d.cts +18 -0
  106. package/dist/snapshot/collect.d.ts +18 -0
  107. package/dist/snapshot/collect.js +355 -0
  108. package/dist/snapshot/color.cjs +1039 -0
  109. package/dist/snapshot/color.d.cts +316 -0
  110. package/dist/snapshot/color.d.ts +316 -0
  111. package/dist/snapshot/color.js +1026 -0
  112. package/dist/snapshot/facts.cjs +167 -0
  113. package/dist/snapshot/facts.d.cts +48 -0
  114. package/dist/snapshot/facts.d.ts +48 -0
  115. package/dist/snapshot/facts.js +161 -0
  116. package/dist/snapshot/limits.cjs +69 -0
  117. package/dist/snapshot/limits.d.cts +59 -0
  118. package/dist/snapshot/limits.d.ts +59 -0
  119. package/dist/snapshot/limits.js +65 -0
  120. package/dist/snapshot/not-measured.cjs +44 -0
  121. package/dist/snapshot/not-measured.d.cts +14 -0
  122. package/dist/snapshot/not-measured.d.ts +14 -0
  123. package/dist/snapshot/not-measured.js +41 -0
  124. package/dist/snapshot/types.cjs +2 -0
  125. package/dist/snapshot/types.d.cts +238 -0
  126. package/dist/snapshot/types.d.ts +238 -0
  127. package/dist/snapshot/types.js +1 -0
  128. package/dist/spec/index.cjs +9 -0
  129. package/dist/spec/index.d.cts +15 -0
  130. package/dist/spec/index.d.ts +15 -0
  131. package/dist/spec/index.js +6 -0
  132. package/package.json +141 -0
  133. 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
+ }