@pxsig/storybook-addon 0.0.0-stage → 0.2.2
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/README.md +32 -2
- package/manager.mjs +185 -0
- package/package.json +23 -4
- package/preset.cjs +51 -0
package/README.md
CHANGED
|
@@ -1,3 +1,33 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @pxsig/storybook-addon
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
pxsig inside Storybook: a "PXSIG: Visual regression" panel with the inspector for the current
|
|
4
|
+
story, and a status dot on every story in the sidebar. It is a client of the pxsig server; start
|
|
5
|
+
it with `npx pxsig serve --watch`.
|
|
6
|
+
|
|
7
|
+
> Not published to npm yet.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -D @pxsig/storybook-addon
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`.storybook/main.ts`:
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
const config = {
|
|
17
|
+
addons: ['@pxsig/storybook-addon'],
|
|
18
|
+
// or: [{ name: '@pxsig/storybook-addon', options: { server: 'http://127.0.0.1:6070' } }]
|
|
19
|
+
};
|
|
20
|
+
export default config;
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The addon finds the server on its own: the `server` option, else the address the running server
|
|
24
|
+
wrote for the workspace, else the workspace's configured port, else `http://127.0.0.1:6070`.
|
|
25
|
+
|
|
26
|
+
Requires Storybook 9 or later (tested on 10.6). It imports `storybook/manager-api` and
|
|
27
|
+
`storybook/internal/components`, and reports sidebar statuses through Storybook's status store
|
|
28
|
+
(`experimental_getStatusStore`); none of those exist in Storybook 8, so Storybook 8 is not
|
|
29
|
+
supported.
|
|
30
|
+
|
|
31
|
+
Full documentation: [docs/storybook.md](../../docs/storybook.md).
|
|
32
|
+
|
|
33
|
+
MIT. Copyright (c) 2026 Hydrotik LLC.
|
package/manager.mjs
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pxsig in Storybook: a "PXSIG: Visual regression" panel and sidebar status dots.
|
|
3
|
+
*
|
|
4
|
+
* Thin client of the pxsig server (`pxsig serve`). Where it is comes from the preset (see
|
|
5
|
+
* preset.cjs): the `server` option, the running server's address, or the configured port; a
|
|
6
|
+
* `pxsig-server` key in localStorage overrides them all.
|
|
7
|
+
* - the panel embeds the dashboard's inspector for the current story
|
|
8
|
+
* (`/report/?embed=1&story=<id>`) — one inspector, built on the design system;
|
|
9
|
+
* - Check story / Check all queue renders + Rust checks on the server's warm pool;
|
|
10
|
+
* - statuses come from `/__pxsig/status` and refresh on the server's event stream,
|
|
11
|
+
* so `pxsig serve --watch` re-checks an edited component and the dots update live.
|
|
12
|
+
* - one server can hold several projects; the server works out which one owns a story.
|
|
13
|
+
*
|
|
14
|
+
* Status mapping: pass / drift / approved → success · changed / flaky / new → warning (needs review:
|
|
15
|
+
* an expected change is not a failure) · rejected / error → error · unaffected → unknown.
|
|
16
|
+
* Statuses go to Storybook's status store (`experimental_getStatusStore`, Storybook 9+).
|
|
17
|
+
* Integrity (the server's ledger, lib/integrity.mjs) escalates a story: an error-severity code
|
|
18
|
+
* (unregistered, never-checked, missing-baseline-image, render-error) turns its dot red, and a
|
|
19
|
+
* story with no coverage at all gets one — so a gap shows without anyone opening the panel.
|
|
20
|
+
*/
|
|
21
|
+
import React from 'react';
|
|
22
|
+
import { addons, experimental_getStatusStore, types, useStorybookState } from 'storybook/manager-api';
|
|
23
|
+
import { AddonPanel } from 'storybook/internal/components';
|
|
24
|
+
|
|
25
|
+
const ADDON = 'pxsig';
|
|
26
|
+
const PANEL = `${ADDON}/panel`;
|
|
27
|
+
const CANDIDATES = [
|
|
28
|
+
...(typeof window !== 'undefined' && window.localStorage?.getItem('pxsig-server') ? [window.localStorage.getItem('pxsig-server')] : []),
|
|
29
|
+
...((typeof window !== 'undefined' && window.__PXSIG__?.servers) || []),
|
|
30
|
+
'http://127.0.0.1:6070',
|
|
31
|
+
];
|
|
32
|
+
let SERVER = CANDIDATES[0];
|
|
33
|
+
/** The first candidate that answers; kept until it stops answering. */
|
|
34
|
+
async function status() {
|
|
35
|
+
for (const url of [SERVER, ...CANDIDATES.filter((c) => c !== SERVER)]) {
|
|
36
|
+
try {
|
|
37
|
+
const r = await fetch(`${url}/__pxsig/status`, { signal: AbortSignal.timeout(1500) });
|
|
38
|
+
if (!r.ok) continue;
|
|
39
|
+
SERVER = url;
|
|
40
|
+
return await r.json();
|
|
41
|
+
} catch {
|
|
42
|
+
// try the next candidate
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
throw new Error('no pxsig server');
|
|
46
|
+
}
|
|
47
|
+
const STATUS = { pass: 'success', drift: 'success', approved: 'success', flaky: 'warning', changed: 'warning', new: 'warning', rejected: 'error', error: 'error', skipped: 'unknown' };
|
|
48
|
+
const SEV = { error: 'error', warn: 'warning', info: 'success' };
|
|
49
|
+
const statusStore = experimental_getStatusStore(ADDON);
|
|
50
|
+
const codesText = (codes) => Object.entries(codes ?? {}).filter(([c]) => !['drift', 'ok'].includes(c)).map(([c, n]) => `${n} ${c}`).join(', ');
|
|
51
|
+
|
|
52
|
+
// One shared connection: the panel subscribes to the same events the status updater uses.
|
|
53
|
+
const bus = new EventTarget();
|
|
54
|
+
let state = { up: false, running: false, stories: {}, integrity: {}, integritySummary: null };
|
|
55
|
+
const emit = () => bus.dispatchEvent(new Event('change'));
|
|
56
|
+
|
|
57
|
+
addons.register(ADDON, () => {
|
|
58
|
+
const refresh = async () => {
|
|
59
|
+
try {
|
|
60
|
+
const s = await status();
|
|
61
|
+
state = { ...state, up: true, running: s.running, stories: s.stories, integrity: s.integrity ?? {}, integritySummary: s.integritySummary ?? null };
|
|
62
|
+
const update = {};
|
|
63
|
+
for (const [id, st] of Object.entries(s.stories)) {
|
|
64
|
+
update[id] = {
|
|
65
|
+
status: STATUS[st.status] ?? 'unknown',
|
|
66
|
+
title: `pxsig: ${st.status}`,
|
|
67
|
+
description: st.entries.map((e) => `${e.mode}: ${e.state}${e.ssim != null ? ` · ssim ${e.ssim}` : ''}`).join('\n'),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
for (const [id, ig] of Object.entries(state.integrity)) {
|
|
71
|
+
if (ig.severity !== 'error' && update[id]) continue;
|
|
72
|
+
if (ig.severity === 'info' && !update[id]) continue;
|
|
73
|
+
update[id] = {
|
|
74
|
+
status: SEV[ig.severity] ?? 'unknown',
|
|
75
|
+
title: `pxsig integrity: ${ig.status}`,
|
|
76
|
+
description: `${codesText(ig.codes)}${update[id] ? `\n${update[id].description}` : ''}`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
// Replace, not merge: a story the server no longer reports loses its dot.
|
|
80
|
+
statusStore.unset();
|
|
81
|
+
statusStore.set(
|
|
82
|
+
Object.entries(update).map(([storyId, u]) => ({ storyId, typeId: ADDON, value: `status-value:${u.status}`, title: u.title, description: u.description }))
|
|
83
|
+
);
|
|
84
|
+
} catch {
|
|
85
|
+
state = { ...state, up: false };
|
|
86
|
+
}
|
|
87
|
+
emit();
|
|
88
|
+
};
|
|
89
|
+
let es = null;
|
|
90
|
+
const connect = () => {
|
|
91
|
+
try {
|
|
92
|
+
es = new EventSource(`${SERVER}/__pxsig/events`);
|
|
93
|
+
es.addEventListener('hello', refresh);
|
|
94
|
+
es.addEventListener('data', refresh);
|
|
95
|
+
es.addEventListener('review', refresh);
|
|
96
|
+
es.addEventListener('integrity', refresh);
|
|
97
|
+
es.addEventListener('check', (e) => {
|
|
98
|
+
const d = JSON.parse(e.data);
|
|
99
|
+
state = { ...state, running: d.state === 'running', last: d };
|
|
100
|
+
emit();
|
|
101
|
+
});
|
|
102
|
+
es.onerror = () => {
|
|
103
|
+
es.close();
|
|
104
|
+
state = { ...state, up: false };
|
|
105
|
+
emit();
|
|
106
|
+
setTimeout(connect, 5000);
|
|
107
|
+
};
|
|
108
|
+
} catch {
|
|
109
|
+
setTimeout(connect, 5000);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
refresh();
|
|
113
|
+
connect();
|
|
114
|
+
|
|
115
|
+
addons.add(PANEL, {
|
|
116
|
+
type: types.PANEL,
|
|
117
|
+
title: 'PXSIG: Visual regression',
|
|
118
|
+
paramKey: 'pxsig',
|
|
119
|
+
render: ({ active }) => React.createElement(AddonPanel, { active: !!active }, React.createElement(Panel)),
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const h = React.createElement;
|
|
124
|
+
const btn = (label, onClick, disabled) =>
|
|
125
|
+
h(
|
|
126
|
+
'button',
|
|
127
|
+
{
|
|
128
|
+
type: 'button',
|
|
129
|
+
onClick,
|
|
130
|
+
disabled,
|
|
131
|
+
style: { font: 'inherit', fontSize: 12, padding: '4px 10px', borderRadius: 4, border: '1px solid rgba(128,128,128,.4)', background: 'transparent', color: 'inherit', cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.5 : 1 },
|
|
132
|
+
},
|
|
133
|
+
label
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
function Panel() {
|
|
137
|
+
const { storyId } = useStorybookState();
|
|
138
|
+
const [, force] = React.useReducer((n) => n + 1, 0);
|
|
139
|
+
React.useEffect(() => {
|
|
140
|
+
bus.addEventListener('change', force);
|
|
141
|
+
return () => bus.removeEventListener('change', force);
|
|
142
|
+
}, []);
|
|
143
|
+
const post = (body) => fetch(`${SERVER}/__pxsig/check`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }).catch(() => {});
|
|
144
|
+
const st = state.stories[storyId];
|
|
145
|
+
const ig = state.integrity[storyId];
|
|
146
|
+
if (!state.up)
|
|
147
|
+
return h(
|
|
148
|
+
'div',
|
|
149
|
+
{ style: { padding: 16, fontSize: 13, lineHeight: 1.6 } },
|
|
150
|
+
h('strong', null, 'The pxsig server is not running.'),
|
|
151
|
+
h('div', null, 'Start it from the repo root, then this panel connects on its own:'),
|
|
152
|
+
h('code', { style: { display: 'block', marginTop: 8 } }, 'npx pxsig serve --watch')
|
|
153
|
+
);
|
|
154
|
+
return h(
|
|
155
|
+
'div',
|
|
156
|
+
{ style: { display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 } },
|
|
157
|
+
h(
|
|
158
|
+
'div',
|
|
159
|
+
{ style: { display: 'flex', gap: 8, alignItems: 'center', padding: '6px 10px', borderBottom: '1px solid rgba(128,128,128,.2)', fontSize: 12 } },
|
|
160
|
+
h('strong', null, st ? `pxsig: ${st.status}` : 'pxsig: no result yet'),
|
|
161
|
+
st ? h('span', { style: { opacity: 0.7 } }, st.entries.map((e) => `${e.mode} ${e.state}`).join(' · ')) : null,
|
|
162
|
+
ig && ig.status !== 'ok'
|
|
163
|
+
? h(
|
|
164
|
+
'span',
|
|
165
|
+
{
|
|
166
|
+
title: codesText(ig.codes),
|
|
167
|
+
style: { padding: '1px 6px', borderRadius: 3, fontSize: 11, color: ig.severity === 'error' ? '#fff' : 'inherit', background: ig.severity === 'error' ? '#c62828' : ig.severity === 'warn' ? 'rgba(240,160,0,.25)' : 'rgba(128,128,128,.2)' },
|
|
168
|
+
},
|
|
169
|
+
`integrity: ${ig.status}${codesText(ig.codes) ? ` (${codesText(ig.codes)})` : ''}`
|
|
170
|
+
)
|
|
171
|
+
: null,
|
|
172
|
+
h('span', { style: { flex: 1 } }),
|
|
173
|
+
state.running ? h('span', { style: { opacity: 0.7 } }, 'Checking…') : null,
|
|
174
|
+
btn('Check story', () => post({ story: storyId }), state.running),
|
|
175
|
+
btn('Check all', () => post({ all: true }), state.running),
|
|
176
|
+
btn('Open dashboard', () => window.open(`${SERVER}/report/?${st?.project ? `project=${encodeURIComponent(st.project)}&` : ''}story=${encodeURIComponent(storyId)}#inspect=${encodeURIComponent(st?.entries[0]?.id ?? storyId)}`, '_blank'))
|
|
177
|
+
),
|
|
178
|
+
h('iframe', {
|
|
179
|
+
key: `${storyId}`,
|
|
180
|
+
title: 'pxsig inspector',
|
|
181
|
+
src: `${SERVER}/report/?embed=1&story=${encodeURIComponent(storyId)}`,
|
|
182
|
+
style: { flex: 1, minHeight: 0, border: 0, width: '100%' },
|
|
183
|
+
})
|
|
184
|
+
);
|
|
185
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pxsig/storybook-addon",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "pxsig in Storybook: a visual-regression panel and sidebar statuses, fed by the pxsig server.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/hydrotik/pxsig.git",
|
|
9
|
+
"directory": "packages/storybook-addon"
|
|
10
|
+
},
|
|
11
|
+
"author": "Donovan Adams (Hydrotik LLC)",
|
|
12
|
+
"main": "preset.cjs",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./preset.cjs",
|
|
15
|
+
"./preset": "./preset.cjs"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"preset.cjs",
|
|
19
|
+
"manager.mjs",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"storybook": ">=9"
|
|
24
|
+
}
|
|
25
|
+
}
|
package/preset.cjs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pxsig Storybook addon preset. Register it in `.storybook/main` as `addons: ['@pxsig/storybook-addon']`,
|
|
3
|
+
* or with options: `{ name: '@pxsig/storybook-addon', options: { server: 'http://127.0.0.1:6070' } }`.
|
|
4
|
+
*
|
|
5
|
+
* The manager page learns where the pxsig server is: the `server` option, else the address the
|
|
6
|
+
* running server wrote for this workspace (node_modules/.cache/pxsig/server.json), else the
|
|
7
|
+
* workspace's configured port. The panel probes them in that order.
|
|
8
|
+
*/
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
/** The workspace root above Storybook's config dir: a pxsig.workspace.json, else a pxsig.config.json. */
|
|
13
|
+
function workspaceOf(start) {
|
|
14
|
+
let dir = path.resolve(start);
|
|
15
|
+
let config = null;
|
|
16
|
+
for (;;) {
|
|
17
|
+
if (fs.existsSync(path.join(dir, 'pxsig.workspace.json'))) return dir;
|
|
18
|
+
if (!config && fs.existsSync(path.join(dir, 'pxsig.config.json'))) config = dir;
|
|
19
|
+
if (fs.existsSync(path.join(dir, '.git'))) return config ?? dir;
|
|
20
|
+
const up = path.dirname(dir);
|
|
21
|
+
if (up === dir) return config;
|
|
22
|
+
dir = up;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const readJson = (p) => {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function servers(options) {
|
|
35
|
+
const out = [];
|
|
36
|
+
if (options?.server) out.push(String(options.server).replace(/\/$/, ''));
|
|
37
|
+
const ws = options?.configDir ? workspaceOf(options.configDir) : null;
|
|
38
|
+
if (ws) {
|
|
39
|
+
const running = readJson(path.join(ws, 'node_modules', '.cache', 'pxsig', 'server.json'));
|
|
40
|
+
if (running?.url) out.push(running.url);
|
|
41
|
+
const port = readJson(path.join(ws, 'pxsig.workspace.json'))?.server?.port;
|
|
42
|
+
if (port) out.push(`http://127.0.0.1:${port}`);
|
|
43
|
+
}
|
|
44
|
+
out.push('http://127.0.0.1:6070');
|
|
45
|
+
return [...new Set(out)];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = {
|
|
49
|
+
managerEntries: (entry = []) => [...entry, path.join(__dirname, 'manager.mjs')],
|
|
50
|
+
managerHead: (head, options) => `${head ?? ''}<script>window.__PXSIG__=${JSON.stringify({ servers: servers(options) })}</script>`,
|
|
51
|
+
};
|