dsh-plugin-toolkit 0.1.1 → 0.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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ - `dsh-plugin-toolkit/testkit`: mount a single-file client bundle in jsdom with the real React and a recording host context — `click`, `type`, `press`, `must`, `attributes`, and normalised DOM baselines.
6
+ - `createFetch(routes)`: route stubs for the panel's own endpoints, with a request log; an undeclared route answers 404 rather than hanging.
7
+ - `dsh-plugin mount <client.js>`: render a bundle in Node and print the text a user would see, or the markup with `--html`.
8
+ - The host merges locale registrations per namespace and understands both a flat table and a `{en, zh}` language map, which is what real plugins register.
9
+
3
10
  ## 0.1.1
4
11
 
5
12
  - Ship a composite GitHub Action (`action.yml`), so a plugin repository can run the same checks with `uses: WJDdidi-1/dsh-plugin-toolkit@v1`.
package/README.md CHANGED
@@ -23,6 +23,48 @@ Releasing one plugin fifteen times taught me three things the ecosystem's toolin
23
23
  3. **Reading a manifest is not the same as checking it.** A plugin can have a valid `package.json` and still be uninstallable: `files` that omit the entry the manifest points at, a bundle patch that does not exist, a README that never states the host range it declares.
24
24
 
25
25
 
26
+ ## Test a panel without a browser
27
+
28
+ A plugin's client code is one file that calls `window.__ModuleLoader__.load({factory})` — there is no module to import, so the usual React test setup does not apply. `testkit` supplies the missing half: a DOM, the real React, and a host context that records what the panel registers.
29
+
30
+ ```sh
31
+ npm install --save-dev jsdom react react-dom
32
+ ```
33
+
34
+ ```js
35
+ import { mountClient, createFetch } from 'dsh-plugin-toolkit/testkit';
36
+
37
+ const panel = await mountClient({
38
+ file: 'client.js',
39
+ copy: { 'my-plugin': { title: 'My panel' } },
40
+ fetch: createFetch({
41
+ '/my-plugin/report': { body: { score: 71, noise: 3 } },
42
+ '/my-plugin/apply': () => ({ status: 201, body: { score: 92 } }),
43
+ }),
44
+ });
45
+
46
+ await panel.click('[data-testid="optimize"]');
47
+ console.assert(panel.text().includes('92'), 'the score on screen did not move');
48
+ console.assert(panel.must('[role="status"]').textContent.includes('92'));
49
+ await panel.unmount();
50
+ ```
51
+
52
+ What you get back: `text()`, `html()`, `query(selector)`, `must(selector)` (throws with the rendered HTML when it misses), `click(selector)`, `type(selector, value)`, `press(selector, key)`, `focus`, `attributes(selector)`, `snapshot(dir, name)` for a normalised DOM baseline, and `host` for assertions about what the bundle registered.
53
+
54
+ To look at a bundle by hand:
55
+
56
+ ```sh
57
+ npx dsh-plugin mount ./client.js # print the text a user would see
58
+ npx dsh-plugin mount ./client.js --html # print the markup
59
+ ```
60
+
61
+ That is how the AIignore panel was checked before release: the real bundle, mounted in Node, against a stubbed `/aiignore/*`:
62
+
63
+ ```
64
+ $ npx dsh-plugin mount ../ai-context-doctor/plugin/client.js
65
+ AIignore No workspace yet Could not read the report …
66
+ ```
67
+
26
68
  ## Use it in CI
27
69
 
28
70
  The same checks run as a GitHub Action, so a tag cannot go out on a plugin that would not install:
@@ -81,8 +123,7 @@ npm test
81
123
 
82
124
  ## Roadmap
83
125
 
84
- - `testkit`: load a single-file client bundle in a DOM with a fake host ctx, so panel behaviour can be asserted in CI instead of by hand.
85
- - `dev`: watch a client bundle and push it to a running app, instead of restarting the app for every change.
126
+ - `dev`: watch a client bundle and push it to a running app, instead of restarting the app for every change. Needs one probe first: whether the host exposes a channel that accepts a pushed bundle.
86
127
 
87
128
  ## License
88
129
 
package/lib/cli.mjs CHANGED
@@ -14,6 +14,7 @@ export const HELP = `dsh-plugin — preflight and release DeepSeek Harness plugi
14
14
  dsh-plugin doctor [dir] check the manifest, README and bundle
15
15
  dsh-plugin release [dir] [flags] tag, wait for npm, update the market index
16
16
  dsh-plugin index [dir] audit every version in market/index.json
17
+ dsh-plugin mount <client.js> render a client bundle in Node and print what a user would see
17
18
 
18
19
  Flags
19
20
  --dry-run show the steps without touching anything
@@ -30,6 +31,9 @@ function flags(argv) {
30
31
  else if (token === '--index') parsed.index = argv[++index];
31
32
  else if (token === '--tag-prefix') parsed.tagPrefix = argv[++index];
32
33
  else if (token === '--budget') parsed.budget = Number(argv[++index]);
34
+ else if (token === '--slot') parsed.slot = argv[++index];
35
+ else if (token === '--surface') parsed.surface = argv[++index];
36
+ else if (token === '--html') parsed.html = true;
33
37
  else if (token.startsWith('--') === true) throw new Error(`unknown flag ${token}`);
34
38
  else parsed.positional.push(token);
35
39
  }
@@ -82,6 +86,24 @@ export async function run(argv, io = console) {
82
86
  return problems === 0 ? 0 : 1;
83
87
  }
84
88
 
89
+ if (command === 'mount') {
90
+ const file = options.positional[0];
91
+ if (file === undefined) {
92
+ io.error('dsh-plugin mount: name the client bundle to render');
93
+ return 2;
94
+ }
95
+ const { mountClient } = await import('./testkit/index.mjs');
96
+ const panel = await mountClient({
97
+ file: path.resolve(file),
98
+ slot: options.slot ?? 'sidebar.right.pane.tab',
99
+ surface: options.surface ?? 'sidebar',
100
+ });
101
+ io.log(panel.text());
102
+ if (options.html === true) io.log(panel.html());
103
+ await panel.unmount();
104
+ return 0;
105
+ }
106
+
85
107
  if (command === 'release') {
86
108
  const report = doctor(dir);
87
109
  const name = report.manifest?.name ?? '(no name)';
@@ -0,0 +1,59 @@
1
+ /**
2
+ * A DOM for a plugin bundle that was written for a DOM.
3
+ *
4
+ * The bundle is one file that runs in the renderer: it calls
5
+ * `window.__ModuleLoader__.load({factory})` and expects React to already exist.
6
+ * jsdom plus the real React gives us the same arrangement in Node, which is what
7
+ * makes panel behaviour assertable in CI instead of by hand.
8
+ */
9
+ let JSDOM;
10
+ let VirtualConsole;
11
+ try {
12
+ ({ JSDOM, VirtualConsole } = await import('jsdom'));
13
+ } catch {
14
+ throw new Error('testkit needs a DOM: npm install --save-dev jsdom react react-dom');
15
+ }
16
+
17
+ export function createDom({ html = '<!doctype html><html><body><div id="root"></div></body></html>' } = {}) {
18
+ // jsdom's CSS parser predates nesting and `:has()`, so it complains about
19
+ // stylesheets it cannot parse. That is a jsdom limit, not a panel bug.
20
+ const virtualConsole = new VirtualConsole();
21
+ virtualConsole.on('jsdomError', (error) => {
22
+ if (/Could not parse CSS stylesheet/.test(error.message) === true) return;
23
+ console.error(error.message);
24
+ });
25
+ const dom = new JSDOM(html, { url: 'http://localhost/', pretendToBeVisual: true, virtualConsole });
26
+ return { dom, window: dom.window, document: dom.window.document };
27
+ }
28
+
29
+ /**
30
+ * Installs the globals a renderer-side bundle reaches for. `previous` is
31
+ * returned so a test file can put the process back the way it found it.
32
+ */
33
+ export function installGlobals(window, document) {
34
+ const names = ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'Event', 'CustomEvent', 'MouseEvent', 'KeyboardEvent', 'getComputedStyle', 'requestAnimationFrame', 'cancelAnimationFrame'];
35
+ const previous = new Map();
36
+ const define = (name, value) => {
37
+ const descriptor = Object.getOwnPropertyDescriptor(globalThis, name);
38
+ previous.set(name, descriptor);
39
+ // Node 24 ships `navigator` as a getter; assigning to it throws, so the
40
+ // global is replaced with defineProperty and put back the same way.
41
+ Object.defineProperty(globalThis, name, { value, configurable: true, writable: true, enumerable: descriptor?.enumerable ?? true });
42
+ };
43
+ for (const name of names) {
44
+ const value = name === 'window' ? window : window[name];
45
+ if (value === undefined) continue;
46
+ define(name, value);
47
+ }
48
+ define('self', window);
49
+ // React 19 only runs effects through act() when it is told it is in a test.
50
+ const previousAct = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
51
+ previous.set('IS_REACT_ACT_ENVIRONMENT', previousAct);
52
+ Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { value: true, configurable: true, writable: true });
53
+ return () => {
54
+ for (const [name, descriptor] of previous) {
55
+ if (descriptor === undefined) delete globalThis[name];
56
+ else Object.defineProperty(globalThis, name, descriptor);
57
+ }
58
+ };
59
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Route stubs for the plugin's own endpoints.
3
+ *
4
+ * The panel talks to itself over HTTP (`/aiignore/report` and friends), so a
5
+ * test states what each route answers and gets a log of what was asked. A route
6
+ * that is not declared answers 404 on purpose: silence hides missing calls.
7
+ */
8
+ export function createFetch(routes = {}) {
9
+ const requests = [];
10
+ const answer = (pathname, search) => {
11
+ const key = Object.keys(routes).find((route) => route === pathname || route === `${pathname}${search}`);
12
+ if (key === undefined) return null;
13
+ const value = routes[key];
14
+ const resolved = typeof value === 'function' ? value({ pathname, search, requests }) : value;
15
+ const status = resolved?.status ?? 200;
16
+ const body = resolved?.body ?? resolved ?? {};
17
+ return {
18
+ ok: status >= 200 && status < 300,
19
+ status,
20
+ headers: { get: (name) => (String(name).toLowerCase() === 'content-type' ? 'application/json' : null) },
21
+ json: async () => body,
22
+ text: async () => JSON.stringify(body),
23
+ };
24
+ };
25
+
26
+ const fetchStub = async (input, init = {}) => {
27
+ const raw = typeof input === 'string' ? input : input?.url ?? String(input);
28
+ const url = new URL(raw, 'http://localhost/');
29
+ const method = (init.method ?? 'GET').toUpperCase();
30
+ const entry = { method, path: url.pathname, search: url.search, body: init.body ?? null };
31
+ requests.push(entry);
32
+ const found = answer(url.pathname, url.search);
33
+ if (found !== null) return found;
34
+ return { ok: false, status: 404, headers: { get: () => null }, json: async () => ({}), text: async () => '' };
35
+ };
36
+
37
+ fetchStub.requests = requests;
38
+ fetchStub.calls = (pathname) => requests.filter((request) => request.path === pathname);
39
+ return fetchStub;
40
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The smallest host that a panel can be honest against.
3
+ *
4
+ * A plugin registers slots, a locale table and a sidebar tab; this records what
5
+ * it registered and calls the callbacks the way the host would. Nothing here
6
+ * pretends to be more than the surface the panel actually uses — if a panel
7
+ * needs something else, the missing service throws with its own name.
8
+ */
9
+ export function createHost({ locale = {}, fetch: fetchImpl, surface = 'sidebar' } = {}) {
10
+ const registrations = { slots: [], tabs: [], locales: [], effects: [] };
11
+ const opened = [];
12
+ const injected = [];
13
+
14
+ const slots = {
15
+ register(definition, component) {
16
+ registrations.slots.push({ definition, component });
17
+ return () => {};
18
+ },
19
+ inject(name, run) {
20
+ const result = run();
21
+ if (typeof result === 'function') return result;
22
+ return () => {};
23
+ },
24
+ };
25
+
26
+ const localeService = {
27
+ register(namespace, dictionary) {
28
+ // Two shapes reach this: a flat key → text table, and a language map
29
+ // ({en: {...}, zh: {...}}). Both are legal, so both are kept.
30
+ const entry = { namespace, dictionary: {}, languages: null };
31
+ const values = Object.values(dictionary ?? {});
32
+ const isLanguageMap = values.length > 0 && values.every((value) => value !== null && typeof value === 'object' && Array.isArray(value) === false);
33
+ if (isLanguageMap === true) entry.languages = { ...dictionary };
34
+ else Object.assign(entry.dictionary, dictionary);
35
+ const existing = registrations.locales.findIndex((item) => item.namespace === namespace);
36
+ if (existing === -1) registrations.locales.push(entry);
37
+ else {
38
+ const previous = registrations.locales[existing];
39
+ Object.assign(previous.dictionary, entry.dictionary);
40
+ if (entry.languages !== null) previous.languages = { ...(previous.languages ?? {}), ...entry.languages };
41
+ }
42
+ return () => {};
43
+ },
44
+ bind(namespace, language = 'en') {
45
+ const entry = registrations.locales.find((item) => item.namespace === namespace);
46
+ const table = entry?.languages?.[language] ?? entry?.languages?.[Object.keys(entry.languages ?? {})[0]] ?? entry?.dictionary;
47
+ return (key, values) => {
48
+ const template = table?.[key];
49
+ if (typeof template !== 'string') return key;
50
+ if (values === undefined) return template;
51
+ return template.replace(/\{(\w+)\}/g, (match, name) => (Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : match));
52
+ };
53
+ },
54
+ };
55
+
56
+ const sidebarRightTabs = {
57
+ register(definition) {
58
+ registrations.tabs.push(definition);
59
+ return () => {};
60
+ },
61
+ };
62
+
63
+ const sidebarRight = {
64
+ openTab(kind) {
65
+ opened.push(kind);
66
+ return undefined;
67
+ },
68
+ active: () => null,
69
+ isExpanded: () => true,
70
+ toggleExpanded() {},
71
+ };
72
+
73
+ const services = {
74
+ slots,
75
+ locale: localeService,
76
+ sidebarRightTabs,
77
+ sidebarRight,
78
+ // A relative URL is normal in the renderer and impossible for Node's fetch,
79
+ // so the default answers "nothing here" instead of throwing a parse error.
80
+ fetch:
81
+ fetchImpl ??
82
+ (async () => ({
83
+ ok: false,
84
+ status: 404,
85
+ headers: { get: () => null },
86
+ json: async () => ({}),
87
+ text: async () => '',
88
+ })),
89
+ // `ctx.inject(names, cb)` is how a plugin asks for services lazily.
90
+ inject(names, callback) {
91
+ injected.push(names);
92
+ // The real host hands back a ctx that can still resolve other services;
93
+ // a plugin that checks `tabCtx.slots` before registering must see it.
94
+ const subset = Object.assign({}, services);
95
+ for (const name of names) subset[name] = services[name];
96
+ return callback(subset);
97
+ },
98
+ effect(run) {
99
+ registrations.effects.push(run);
100
+ const dispose = run();
101
+ return () => {
102
+ if (typeof dispose === 'function') dispose();
103
+ };
104
+ },
105
+ on() {
106
+ return () => {};
107
+ },
108
+ logger: { info() {}, warn() {}, error() {}, debug() {} },
109
+ console,
110
+ };
111
+
112
+ if (surface === 'overlay') {
113
+ delete services.sidebarRightTabs;
114
+ delete services.sidebarRight;
115
+ }
116
+
117
+ services.registrations = registrations;
118
+ services.opened = opened;
119
+ services.injected = injected;
120
+ services.registeredLocale = (namespace) => registrations.locales.find((item) => item.namespace === namespace)?.dictionary ?? {};
121
+ services.slot = (name) =>
122
+ registrations.slots.find(({ definition }) => definition.name === name) ??
123
+ null;
124
+ services.tabDefinition = (id) => registrations.tabs.find((definition) => definition.id === id) ?? null;
125
+ return services;
126
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `dsh-plugin-toolkit/testkit` — the public surface.
3
+ *
4
+ * A plugin's own test file should be able to say: mount my bundle, click the
5
+ * button, expect this text — without inventing a DOM, a host, or a renderer.
6
+ */
7
+ export { createDom, installGlobals } from './env.mjs';
8
+ export { createHost } from './host.mjs';
9
+ export { createFetch } from './fetch.mjs';
10
+ export { createModuleLoader, loadBundle } from './load.mjs';
11
+ export { mountClient } from './mount.mjs';
12
+ export { compare, normalize, snapshotPath, write } from './snapshot.mjs';
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Loading a client bundle without a bundler.
3
+ *
4
+ * DSH ships plugin client code as a single file that registers itself with
5
+ * `window.__ModuleLoader__.load({id, factory})`. There is no ESM boundary to
6
+ * import, so the loader is replaced with one that hands the factory a `require`
7
+ * for the modules the host provides.
8
+ */
9
+ import fs from 'node:fs';
10
+
11
+ export function createModuleLoader({ require: provide, onLoad } = {}) {
12
+ const loaded = [];
13
+ const loader = {
14
+ load({ id, factory }) {
15
+ const module = { exports: {} };
16
+ const result = factory(provide ?? (() => ({})));
17
+ const exported = result ?? module.exports;
18
+ loaded.push({ id, exports: exported });
19
+ if (typeof onLoad === 'function') onLoad({ id, exports: exported });
20
+ return exported;
21
+ },
22
+ get loaded() {
23
+ return loaded;
24
+ },
25
+ };
26
+ return loader;
27
+ }
28
+
29
+ export function loadBundle(file, options = {}) {
30
+ const source = fs.readFileSync(file, 'utf8');
31
+ const loader = createModuleLoader(options);
32
+ const context = { window: { __ModuleLoader__: loader }, __ModuleLoader__: loader, ...(options.globals ?? {}) };
33
+ // The bundle is an IIFE or a top-level statement list; the browser would run it
34
+ // as a classic script, so a function body is the closest equivalent.
35
+ const run = new Function('window', '__ModuleLoader__', 'require', 'module', 'exports', source);
36
+ const module = { exports: {} };
37
+ run(context.window, loader, options.require ?? (() => ({})), module, module.exports);
38
+ if (loader.loaded.length === 0) {
39
+ throw new Error(`${file} never called window.__ModuleLoader__.load(), so it is not a client bundle`);
40
+ }
41
+ return loader.loaded[loader.loaded.length - 1].exports;
42
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Mounting a panel the way the host does, then poking at it the way a user does.
3
+ *
4
+ * The helpers are deliberately blunt — click, type, press — because that is the
5
+ * vocabulary a bug report uses. Anything cleverer hides which selector was hit.
6
+ */
7
+ import { createDom, installGlobals } from './env.mjs';
8
+ import { createHost } from './host.mjs';
9
+ import { loadBundle } from './load.mjs';
10
+ import { compare, write } from './snapshot.mjs';
11
+
12
+ function moduleShim(modules) {
13
+ return (name) => {
14
+ if (Object.prototype.hasOwnProperty.call(modules, name)) return modules[name];
15
+ // An unknown host module should be obviously fake, not silently empty.
16
+ return new Proxy(
17
+ {},
18
+ {
19
+ get: (target, key) => {
20
+ if (key === 'then') return undefined;
21
+ if (key === Symbol.toPrimitive) return () => `[unknown module ${name}]`;
22
+ return () => null;
23
+ },
24
+ },
25
+ );
26
+ };
27
+ }
28
+
29
+ export async function mountClient(options) {
30
+ const {
31
+ file,
32
+ copy = {},
33
+ surface = 'sidebar',
34
+ slot = 'sidebar.right.pane.tab',
35
+ props = {},
36
+ fetch: fetchImpl,
37
+ container = 'root',
38
+ } = options;
39
+
40
+ const { window, document } = createDom();
41
+ const restoreGlobals = installGlobals(window, document);
42
+
43
+ const React = await import('react');
44
+ const reactDom = await import('react-dom/client');
45
+ const jsxRuntime = await import('react/jsx-runtime');
46
+ const jsxDevRuntime = await import('react/jsx-dev-runtime').catch(() => jsxRuntime);
47
+
48
+ const modules = {
49
+ react: React.default ?? React,
50
+ 'react-dom': await import('react-dom'),
51
+ 'react-dom/client': reactDom.default ?? reactDom,
52
+ 'react/jsx-runtime': jsxRuntime,
53
+ 'react/jsx-dev-runtime': jsxDevRuntime,
54
+ };
55
+
56
+ const host = createHost({ locale: copy, fetch: fetchImpl, surface });
57
+ // The panel calls the browser's fetch, not a service, so the stub has to be
58
+ // the global one for the duration of the mount.
59
+ const previousFetch = globalThis.fetch;
60
+ globalThis.fetch = host.fetch;
61
+ try {
62
+ const exported = loadBundle(file, { require: moduleShim(modules) });
63
+ const apply = exported.apply ?? exported.default ?? exported.install ?? exported;
64
+ if (typeof apply !== 'function') {
65
+ throw new Error(`${file} exports no apply(ctx) function, so there is nothing to mount`);
66
+ }
67
+ await apply(host);
68
+ } catch (error) {
69
+ globalThis.fetch = previousFetch;
70
+ restoreGlobals();
71
+ throw error;
72
+ }
73
+
74
+ const registered = host.slot(slot);
75
+ if (registered === null) {
76
+ globalThis.fetch = previousFetch;
77
+ restoreGlobals();
78
+ throw new Error(`the bundle registered no "${slot}" slot; it registered: ${[...new Set(host.registrations.slots.map((entry) => entry.definition.name))].join(', ') || 'nothing'}`);
79
+ }
80
+
81
+ const root = document.getElementById(container);
82
+ const clientRoot = (reactDom.default ?? reactDom).createRoot(root);
83
+ const act = React.act ?? ((run) => run());
84
+
85
+ await act(async () => {
86
+ clientRoot.render(React.createElement(registered.component, props));
87
+ });
88
+
89
+ const settle = async (run) => {
90
+ await act(async () => {
91
+ if (run !== undefined) await run();
92
+ await new Promise((resolve) => setTimeout(resolve, 0));
93
+ });
94
+ };
95
+
96
+ const query = (selector) => root.querySelector(selector);
97
+ const queryAll = (selector) => [...root.querySelectorAll(selector)];
98
+ const must = (selector) => {
99
+ const element = query(selector);
100
+ if (element === null) throw new Error(`no element matched "${selector}" in:\n${root.innerHTML.slice(0, 600)}`);
101
+ return element;
102
+ };
103
+
104
+ const helpers = {
105
+ window,
106
+ document,
107
+ root,
108
+ host,
109
+ react: React,
110
+ // textContent glues siblings together ("Demo panelCount 0"); a reader of the
111
+ // screen does not, so leaves are joined with a space.
112
+ text: () => {
113
+ const leaves = [];
114
+ const walk = (node) => {
115
+ if (node.nodeType === 3) {
116
+ const value = node.nodeValue.replace(/\s+/g, ' ').trim();
117
+ if (value !== '') leaves.push(value);
118
+ return;
119
+ }
120
+ if (node.nodeType !== 1) return;
121
+ const tag = node.tagName.toLowerCase();
122
+ if (tag === 'script' || tag === 'style') return;
123
+ for (const child of node.childNodes) walk(child);
124
+ };
125
+ walk(root);
126
+ return leaves.join(' ');
127
+ },
128
+ html: () => root.innerHTML,
129
+ query,
130
+ queryAll,
131
+ must,
132
+ click: async (selector) => {
133
+ const element = must(selector);
134
+ await settle(() => {
135
+ element.dispatchEvent(new window.MouseEvent('click', { bubbles: true, cancelable: true }));
136
+ });
137
+ return element;
138
+ },
139
+ press: async (selector, key, init = {}) => {
140
+ const element = selector === null ? root : must(selector);
141
+ await settle(() => {
142
+ element.dispatchEvent(new window.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }));
143
+ });
144
+ return element;
145
+ },
146
+ type: async (selector, value) => {
147
+ const element = must(selector);
148
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
149
+ ?? Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
150
+ await settle(() => {
151
+ setter?.call(element, value);
152
+ element.dispatchEvent(new window.Event('input', { bubbles: true }));
153
+ });
154
+ return element;
155
+ },
156
+ focus: (selector) => {
157
+ const element = must(selector);
158
+ element.focus();
159
+ return element;
160
+ },
161
+ attributes: (selector) => {
162
+ const element = must(selector);
163
+ return Object.fromEntries([...element.attributes].map((attribute) => [attribute.name, attribute.value]));
164
+ },
165
+ snapshot: (dir, name) => {
166
+ const result = compare(dir, name, root.innerHTML);
167
+ if (result.status === 'changed') {
168
+ throw new Error(`${name} changed at <${result.actual.split(/(?=<)/)[result.firstDifference]}>`);
169
+ }
170
+ if (result.status === 'missing') {
171
+ throw new Error(`${name} has no baseline at ${result.file}; run with --update to create it`);
172
+ }
173
+ return result;
174
+ },
175
+ writeSnapshot: (dir, name) => write(dir, name, root.innerHTML),
176
+ unmount: async () => {
177
+ await act(async () => clientRoot.unmount());
178
+ globalThis.fetch = previousFetch;
179
+ restoreGlobals();
180
+ },
181
+ };
182
+
183
+ return helpers;
184
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * DOM snapshots, not pixels.
3
+ *
4
+ * A rasterised baseline needs a browser; the DOM does not. Normalising the
5
+ * markup (class order, whitespace, generated ids) gives a baseline that fails
6
+ * for the same reasons a screenshot would — a missing class, a lost label, a
7
+ * control that stopped rendering — and can be reviewed in a diff.
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+
12
+ const NOISE = /\s+/g;
13
+
14
+ export function normalize(html) {
15
+ return html
16
+ .replace(/<!--.*?-->/g, '')
17
+ .replace(/ data-react[a-z-]*="[^"]*"/g, '')
18
+ .replace(/ aria-describedby="[^"]*"/g, '')
19
+ .replace(/\s+class="([^"]*)"/g, (match, value) => ` class="${value.split(NOISE).filter(Boolean).sort().join(' ')}"`)
20
+ .replace(/>\s+</g, '><')
21
+ .replace(NOISE, ' ')
22
+ .trim();
23
+ }
24
+
25
+ export function snapshotPath(dir, name) {
26
+ return path.join(dir, `${name}.html`);
27
+ }
28
+
29
+ /** Compares, and on mismatch says which line moved instead of dumping two walls. */
30
+ export function compare(dir, name, html) {
31
+ const file = snapshotPath(dir, name);
32
+ const actual = normalize(html);
33
+ if (fs.existsSync(file) === false) return { file, status: 'missing', actual };
34
+ const expected = fs.readFileSync(file, 'utf8').trim();
35
+ if (expected === actual) return { file, status: 'same', actual };
36
+ const expectedLines = expected.split(/(?=<)/);
37
+ const actualLines = actual.split(/(?=<)/);
38
+ const index = expectedLines.findIndex((line, position) => line !== actualLines[position]);
39
+ return { file, status: 'changed', expected, actual, firstDifference: Math.max(0, index) };
40
+ }
41
+
42
+ export function write(dir, name, html) {
43
+ const file = snapshotPath(dir, name);
44
+ fs.mkdirSync(dir, { recursive: true });
45
+ fs.writeFileSync(file, `${normalize(html)}\n`);
46
+ return file;
47
+ }
package/package.json CHANGED
@@ -1,11 +1,15 @@
1
1
  {
2
2
  "name": "dsh-plugin-toolkit",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Preflight, release and test DeepSeek Harness plugins: checks what the host needs, waits for npm to actually serve the version, and keeps the market index honest.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "dsh-plugin": "bin/dsh-plugin.mjs"
8
8
  },
9
+ "exports": {
10
+ ".": "./lib/cli.mjs",
11
+ "./testkit": "./lib/testkit/index.mjs"
12
+ },
9
13
  "files": [
10
14
  "bin/",
11
15
  "lib/",
@@ -38,6 +42,28 @@
38
42
  },
39
43
  "scripts": {
40
44
  "test": "node --test",
41
- "lint": "node --check bin/dsh-plugin.mjs"
45
+ "lint": "node --check bin/dsh-plugin.mjs",
46
+ "snapshots": "UPDATE_SNAPSHOTS=1 node --test test/testkit.test.mjs"
47
+ },
48
+ "peerDependencies": {
49
+ "jsdom": ">=26",
50
+ "react": ">=18",
51
+ "react-dom": ">=18"
52
+ },
53
+ "peerDependenciesMeta": {
54
+ "jsdom": {
55
+ "optional": true
56
+ },
57
+ "react": {
58
+ "optional": true
59
+ },
60
+ "react-dom": {
61
+ "optional": true
62
+ }
63
+ },
64
+ "devDependencies": {
65
+ "jsdom": "^26.1.0",
66
+ "react": "^19.3.1",
67
+ "react-dom": "^19.3.1"
42
68
  }
43
69
  }