@harnessed-ts/storybook 0.0.0-stage → 0.6.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 ADDED
@@ -0,0 +1,40 @@
1
+ # @harnessed-ts/storybook
2
+
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - e324e94: ember-test-selectors style test ids: `configure({ testIdAttribute: 'data-test-*' })` makes `testId('x')` match any element that has the attribute `data-test-x`, whatever its value, with the same scoping, strictness, `{ global: true }`, frame and absence rules as before under every driver. A plain attribute name keeps matching by value. A presence id is held to letters, digits, `-`, `_`, `.` and `:`, and anything else (or a `RegExp`) is refused rather than spliced into a selector.
8
+
9
+ `Query.boundingBox()` resolves to `{ x, y, width, height }`: the target's border box in CSS pixels, relative to the top-level viewport, offset by every frame crossed. Strict, and on an absent target it rejects after the timeout, as `text()` does. A node that draws no box reads as all zeros, and so does every node under the dom driver: jsdom computes no layout.
10
+
11
+ `Query.upload(files)` sets the files of an `<input type="file">`, or of the input a `<label>` is for, and fires `input` and `change`, replacing any earlier selection. It refuses any other element, and several files for an input that is not `multiple`. Puppeteer, WebdriverIO and TestCafe take only paths, so there each `File` is written to a temporary directory first: its name and bytes arrive, and the browser infers its type from the extension.
12
+
13
+ `Query` gains two abstract members, so a driver outside this repo implements both to keep compiling; the conformance catalog pins all three changes.
14
+
15
+ `@harnessed-ts/eslint-plugin` supports ESLint 8.57 and legacy `.eslintrc`: the peer range is now `>=8.57`, `legacy-recommended` and `legacy-strict` name the rules under `@harnessed-ts/`, and the CJS build exposes `rules` and `configs` at the top level, where eslintrc reads them.
16
+
17
+ `npx @harnessed-ts/claude install` detects ember-test-selectors attributes (in `.gts`, `.gjs` and `.hbs` files too) and writes `testIdAttribute: 'data-test-*'`.
18
+
19
+ `@harnessed-ts/storybook` writes and finds its story-ready marker under a `data-test-*` config, where `StoryPage` waits for it.
20
+
21
+ ### Patch Changes
22
+
23
+ - Updated dependencies [e324e94]
24
+ - @harnessed-ts/core@0.6.0
25
+ - @harnessed-ts/dom@0.6.0
26
+ - @harnessed-ts/page@0.6.0
27
+
28
+ ## 0.5.0
29
+
30
+ ### Minor Changes
31
+
32
+ - ad20e8c: Add `@harnessed-ts/storybook`. Spread `harnessed()` into `.storybook/preview.ts`, and a story that names its harness in `parameters.harness` gets it in `play` as `context.harness`. The harness drives the canvas through Storybook's instrumented `userEvent`, each public method is a step in the Interactions panel (secret-looking arguments are masked by parameter name in development, and by object key in a minified build too), and the harness matchers are registered on `storybook/test`'s `expect`. `StoryPage` from `@harnessed-ts/storybook/page` opens a story by URL from any runner that can navigate, with typed `args` and `globals`, `play: false` to skip the play function, and a play function's own error when it throws. React only: `@storybook/ember` builds with webpack, and Storybook's Vitest addon needs Vite.
33
+
34
+ `no-raw-locator-in-test` now treats `*.stories.*` files as tests, flagging `canvas.getBy*`, `within` from `storybook/test`, and `canvasElement.querySelector`. `npx @harnessed-ts/claude install` detects Storybook and documents the preview setup, `play` with a harness, and `StoryPage`.
35
+
36
+ ### Patch Changes
37
+
38
+ - @harnessed-ts/core@0.5.0
39
+ - @harnessed-ts/dom@0.5.0
40
+ - @harnessed-ts/page@0.5.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Joe Gaudet, Jay Seo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,40 @@
1
- # Temporary Holding Version
1
+ # @harnessed-ts/storybook
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Harnesses in Storybook. A harness in every play function, with each of its
4
+ methods a step in the Interactions panel, and stories opened as fixtures from
5
+ any runner that can navigate.
6
+
7
+ ```ts
8
+ // .storybook/preview.ts
9
+ import { harnessed } from '@harnessed-ts/storybook'
10
+ export default { ...harnessed() } satisfies Preview
11
+ ```
12
+
13
+ ```tsx
14
+ const meta = {
15
+ title: 'Forms/Login Form',
16
+ component: LoginForm,
17
+ parameters: { harness: LoginFormHarness },
18
+ } satisfies Meta<typeof LoginForm>
19
+ export default meta
20
+
21
+ type Story = WithHarness<StoryObj<typeof meta>, LoginFormHarness>
22
+
23
+ export const WrongPassword: Story = {
24
+ play: async ({ harness }) => {
25
+ await harness.signInAs('ada@example.com', 'guess')
26
+ await expect(await harness.error()).toBe('Wrong password')
27
+ },
28
+ }
29
+ ```
30
+
31
+ ```ts
32
+ // Playwright, or any runner whose driver can navigate
33
+ import { StoryPage } from '@harnessed-ts/storybook/page'
34
+ import * as LoginStories from './LoginForm.stories'
35
+
36
+ const story = await StoryPage.goto(pw(page), LoginStories, 'WrongPassword', { play: false })
37
+ await story.harness(LoginFormHarness).signInAs('ada@example.com', 'hunter2')
38
+ ```
39
+
40
+ Storybook 9 or later. See [Using with Storybook](https://github.com/joegaudet/harnessed#using-with-storybook).
@@ -0,0 +1,55 @@
1
+ var __create = Object.create;
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __knownSymbol = (name, symbol) => (symbol = Symbol[name]) ? symbol : /* @__PURE__ */ Symbol.for("Symbol." + name);
5
+ var __typeError = (msg) => {
6
+ throw TypeError(msg);
7
+ };
8
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
9
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
10
+ var __decoratorStart = (base) => [, , , __create(base?.[__knownSymbol("metadata")] ?? null)];
11
+ var __decoratorStrings = ["class", "method", "getter", "setter", "accessor", "field", "value", "get", "set"];
12
+ var __expectFn = (fn) => fn !== void 0 && typeof fn !== "function" ? __typeError("Function expected") : fn;
13
+ var __decoratorContext = (kind, name, done, metadata, fns) => ({ kind: __decoratorStrings[kind], name, metadata, addInitializer: (fn) => done._ ? __typeError("Already initialized") : fns.push(__expectFn(fn || null)) });
14
+ var __decoratorMetadata = (array, target) => __defNormalProp(target, __knownSymbol("metadata"), array[3]);
15
+ var __runInitializers = (array, flags, self, value) => {
16
+ for (var i = 0, fns = array[flags >> 1], n = fns && fns.length; i < n; i++) flags & 1 ? fns[i].call(self) : value = fns[i].call(self, value);
17
+ return value;
18
+ };
19
+ var __decorateElement = (array, flags, name, decorators, target, extra) => {
20
+ var fn, it, done, ctx, access, k = flags & 7, s = !!(flags & 8), p = !!(flags & 16);
21
+ var j = k > 3 ? array.length + 1 : k ? s ? 1 : 2 : 0, key = __decoratorStrings[k + 5];
22
+ var initializers = k > 3 && (array[j - 1] = []), extraInitializers = array[j] || (array[j] = []);
23
+ var desc = k && (!p && !s && (target = target.prototype), k < 5 && (k > 3 || !p) && __getOwnPropDesc(k < 4 ? target : { get [name]() {
24
+ return __privateGet(this, extra);
25
+ }, set [name](x) {
26
+ return __privateSet(this, extra, x);
27
+ } }, name));
28
+ k ? p && k < 4 && __name(extra, (k > 2 ? "set " : k > 1 ? "get " : "") + name) : __name(target, name);
29
+ for (var i = decorators.length - 1; i >= 0; i--) {
30
+ ctx = __decoratorContext(k, name, done = {}, array[3], extraInitializers);
31
+ if (k) {
32
+ ctx.static = s, ctx.private = p, access = ctx.access = { has: p ? (x) => __privateIn(target, x) : (x) => name in x };
33
+ if (k ^ 3) access.get = p ? (x) => (k ^ 1 ? __privateGet : __privateMethod)(x, target, k ^ 4 ? extra : desc.get) : (x) => x[name];
34
+ if (k > 2) access.set = p ? (x, y) => __privateSet(x, target, y, k ^ 4 ? extra : desc.set) : (x, y) => x[name] = y;
35
+ }
36
+ it = (0, decorators[i])(k ? k < 4 ? p ? extra : desc[key] : k > 4 ? void 0 : { get: desc.get, set: desc.set } : target, ctx), done._ = 1;
37
+ if (k ^ 4 || it === void 0) __expectFn(it) && (k > 4 ? initializers.unshift(it) : k ? p ? extra = it : desc[key] = it : target = it);
38
+ else if (typeof it !== "object" || it === null) __typeError("Object expected");
39
+ else __expectFn(fn = it.get) && (desc.get = fn), __expectFn(fn = it.set) && (desc.set = fn), __expectFn(fn = it.init) && initializers.unshift(fn);
40
+ }
41
+ return k || __decoratorMetadata(array, target), desc && __defProp(target, name, desc), p ? k ^ 4 ? extra : desc : target;
42
+ };
43
+ var __accessCheck = (obj, member, msg) => member.has(obj) || __typeError("Cannot " + msg);
44
+ var __privateIn = (member, obj) => Object(obj) !== obj ? __typeError('Cannot use the "in" operator on this value') : member.has(obj);
45
+ var __privateGet = (obj, member, getter) => (__accessCheck(obj, member, "read from private field"), getter ? getter.call(obj) : member.get(obj));
46
+ var __privateSet = (obj, member, value, setter) => (__accessCheck(obj, member, "write to private field"), setter ? setter.call(obj, value) : member.set(obj, value), value);
47
+ var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "access private method"), method);
48
+
49
+ // src/ready.ts
50
+ var STORY_READY_TEST_ID = "harnessed-story-ready";
51
+ var PLAY_ERROR_ATTRIBUTE = "data-harnessed-play-error";
52
+
53
+ export { PLAY_ERROR_ATTRIBUTE, STORY_READY_TEST_ID, __decorateElement, __decoratorStart, __runInitializers };
54
+ //# sourceMappingURL=chunk-J6DS34J4.js.map
55
+ //# sourceMappingURL=chunk-J6DS34J4.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/ready.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAMO,IAAM,mBAAA,GAAsB;AAG5B,IAAM,oBAAA,GAAuB","file":"chunk-J6DS34J4.js","sourcesContent":["/**\n * The test id of the node `harnessed()` adds once a story has rendered and its\n * play function has finished. `StoryPage` waits for it: Storybook's own\n * \"rendered\" signal is a channel event, which a runner outside the iframe's\n * realm cannot listen for, while a node is something every driver can count.\n */\nexport const STORY_READY_TEST_ID = 'harnessed-story-ready'\n\n/** Set on that node when the play function threw, holding the error's message. */\nexport const PLAY_ERROR_ATTRIBUTE = 'data-harnessed-play-error'\n"]}
package/dist/index.cjs ADDED
@@ -0,0 +1,143 @@
1
+ 'use strict';
2
+
3
+ var core = require('@harnessed-ts/core');
4
+ var dom = require('@harnessed-ts/dom');
5
+ var coreEvents = require('storybook/internal/core-events');
6
+ var previewApi = require('storybook/preview-api');
7
+ var test = require('storybook/test');
8
+
9
+ // src/index.ts
10
+
11
+ // src/ready.ts
12
+ var STORY_READY_TEST_ID = "harnessed-story-ready";
13
+ var PLAY_ERROR_ATTRIBUTE = "data-harnessed-play-error";
14
+ var SECRET = /pass|secret|token|credential/i;
15
+ var SECRET_WORD = /^(key|pin|otp|auth)$/i;
16
+ var WORD_BREAK = /[^a-zA-Z0-9]+|(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/;
17
+ var REDACTED = "\u2022\u2022\u2022";
18
+ var MAX_ARG = 40;
19
+ var AsyncFunction = (async () => {
20
+ }).constructor;
21
+ var proxies = /* @__PURE__ */ new WeakMap();
22
+ function withSteps(harness, step) {
23
+ const cached = proxies.get(harness);
24
+ if (cached !== void 0) return cached;
25
+ const className = harness.constructor.name;
26
+ const wrap = (value) => {
27
+ if (value instanceof core.ScopedHarness) return withSteps(value, step);
28
+ if (Array.isArray(value) && value.some((item) => item instanceof core.ScopedHarness)) {
29
+ return value.map(wrap);
30
+ }
31
+ return value;
32
+ };
33
+ const proxy = new Proxy(harness, {
34
+ get(target, prop) {
35
+ const value = Reflect.get(target, prop, target);
36
+ if (typeof value !== "function") return wrap(value);
37
+ if (typeof prop !== "string" || prop === "constructor" || prop.startsWith("_")) {
38
+ return value.bind(target);
39
+ }
40
+ const method = value;
41
+ if (method instanceof AsyncFunction) {
42
+ return async (...args) => {
43
+ let result;
44
+ await step(stepLabel(className, method, args, prop), async () => {
45
+ result = await method.apply(target, args);
46
+ });
47
+ return wrap(result);
48
+ };
49
+ }
50
+ return (...args) => wrap(method.apply(target, args));
51
+ },
52
+ // An accessor's setter writes a private slot only the real instance has.
53
+ set: (target, prop, value) => Reflect.set(target, prop, value, target)
54
+ });
55
+ proxies.set(harness, proxy);
56
+ return proxy;
57
+ }
58
+ function stepLabel(className, method, args, name = method.name) {
59
+ const params = parameterNames(method);
60
+ const shown = args.map(
61
+ (arg, index) => isSecret(params[index] ?? "") ? JSON.stringify(REDACTED) : formatArg(arg)
62
+ );
63
+ return `${className}.${name}(${shown.join(", ")})`;
64
+ }
65
+ function isSecret(name) {
66
+ return SECRET.test(name) || name.split(WORD_BREAK).some((word) => SECRET_WORD.test(word));
67
+ }
68
+ function formatArg(arg) {
69
+ if (typeof arg === "function") return "\u0192";
70
+ if (arg instanceof core.ScopedHarness) return arg.constructor.name;
71
+ if (typeof arg === "string") {
72
+ return JSON.stringify(arg.length > MAX_ARG ? `${arg.slice(0, MAX_ARG)}\u2026` : arg);
73
+ }
74
+ const json = JSON.stringify(arg, (key, value) => isSecret(key) ? REDACTED : value) ?? String(arg);
75
+ return json.length > MAX_ARG ? `${json.slice(0, MAX_ARG)}\u2026` : json;
76
+ }
77
+ function parameterNames(method) {
78
+ const list = /\(([^)]*)\)/.exec(Function.prototype.toString.call(method))?.[1] ?? "";
79
+ if (/[{[]/.test(list)) return [];
80
+ return list.split(",").map((param) => param.replace(/[=:].*$/s, "").trim());
81
+ }
82
+
83
+ // src/index.ts
84
+ var matchersRegistered = false;
85
+ var listening = false;
86
+ var playError;
87
+ function listenForPlayErrors() {
88
+ if (listening || !previewApi.addons.hasChannel()) return;
89
+ previewApi.addons.getChannel().on(coreEvents.PLAY_FUNCTION_THREW_EXCEPTION, (error) => {
90
+ playError = error?.message ?? "the play function threw";
91
+ });
92
+ listening = true;
93
+ }
94
+ function harnessed() {
95
+ if (!matchersRegistered) {
96
+ test.expect.extend(core.harnessMatchers);
97
+ matchersRegistered = true;
98
+ }
99
+ return {
100
+ async beforeEach(context) {
101
+ readyMarker(context.canvasElement.ownerDocument)?.remove();
102
+ listenForPlayErrors();
103
+ playError = void 0;
104
+ const HarnessClass = context.parameters.harness;
105
+ if (HarnessClass === void 0) return;
106
+ const env = dom.dom({
107
+ // Storybook's own instance, so each keystroke shows up in the panel. It
108
+ // is only set where a clipboard exists; fall back to a plain one.
109
+ user: hasClick(context.userEvent) ? context.userEvent : test.userEvent.setup(),
110
+ container: context.canvasElement
111
+ });
112
+ context.harness = withSteps(new HarnessClass(env), context.step);
113
+ },
114
+ async afterEach(context) {
115
+ const document = context.canvasElement.ownerDocument;
116
+ if (readyMarker(document) !== null) return;
117
+ const marker = document.createElement("div");
118
+ const attribute = core.getConfig().testIdAttribute;
119
+ if (attribute.endsWith("*"))
120
+ marker.setAttribute(attribute.slice(0, -1) + STORY_READY_TEST_ID, "");
121
+ else marker.setAttribute(attribute, STORY_READY_TEST_ID);
122
+ marker.hidden = true;
123
+ if (playError !== void 0) marker.setAttribute(PLAY_ERROR_ATTRIBUTE, playError);
124
+ document.body.append(marker);
125
+ }
126
+ };
127
+ }
128
+ function readyMarker(document) {
129
+ const attribute = core.getConfig().testIdAttribute;
130
+ const presence = core.testIdPresenceSelector(attribute, STORY_READY_TEST_ID);
131
+ return document.querySelector(presence ?? `[${attribute}="${STORY_READY_TEST_ID}"]`);
132
+ }
133
+ function hasClick(value) {
134
+ return typeof value?.click === "function";
135
+ }
136
+
137
+ exports.PLAY_ERROR_ATTRIBUTE = PLAY_ERROR_ATTRIBUTE;
138
+ exports.STORY_READY_TEST_ID = STORY_READY_TEST_ID;
139
+ exports.harnessed = harnessed;
140
+ exports.stepLabel = stepLabel;
141
+ exports.withSteps = withSteps;
142
+ //# sourceMappingURL=index.cjs.map
143
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/ready.ts","../src/steps.ts","../src/index.ts"],"names":["ScopedHarness","addons","PLAY_FUNCTION_THREW_EXCEPTION","expect","harnessMatchers","dom","userEvent","getConfig","testIdPresenceSelector"],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * The test id of the node `harnessed()` adds once a story has rendered and its\n * play function has finished. `StoryPage` waits for it: Storybook's own\n * \"rendered\" signal is a channel event, which a runner outside the iframe's\n * realm cannot listen for, while a node is something every driver can count.\n */\nexport const STORY_READY_TEST_ID = 'harnessed-story-ready'\n\n/** Set on that node when the play function threw, holding the error's message. */\nexport const PLAY_ERROR_ATTRIBUTE = 'data-harnessed-play-error'\n","import { ScopedHarness } from '@harnessed-ts/core'\n\n/** Storybook's `context.step`, narrowed to what the proxy needs. */\nexport type StepRunner = (label: string, play: () => Promise<void>) => Promise<unknown> | unknown\n\nconst SECRET = /pass|secret|token|credential/i\n// Short names match whole words only, so `author`, `keyboard` and `typing` stay visible.\nconst SECRET_WORD = /^(key|pin|otp|auth)$/i\n// camelCase, PascalCase acronyms (`APIKey`) and snake/kebab-case word breaks.\nconst WORD_BREAK = /[^a-zA-Z0-9]+|(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/\nconst REDACTED = '•••'\nconst MAX_ARG = 40\n\nconst AsyncFunction = (async () => {}).constructor\n\nconst proxies = new WeakMap<object, unknown>()\n\n/**\n * The harness, with every public async method run inside `step()` — so the\n * Interactions panel reads `LoginFormHarness.signInAs(\"ada@…\", \"•••\")` with the\n * raw clicks and keystrokes nested under it, rather than a flat list of events.\n *\n * Methods run against the real instance, not the proxy: a harness calling its\n * own methods does not log a step per call, and decorated `accessor` fields\n * (backed by private slots a proxy cannot reach) keep working. Sync methods stay\n * sync; a harness they return — `nth()`, a child — comes back wrapped too.\n *\n * shortcut: async is detected by `AsyncFunction`, so a plain method that returns\n * a promise, or one down-levelled to a generator, logs no step. Upgrade if a\n * harness or a build ever needs that.\n */\nexport function withSteps<T extends ScopedHarness>(harness: T, step: StepRunner): T {\n const cached = proxies.get(harness)\n if (cached !== undefined) return cached as T\n const className = harness.constructor.name\n\n const wrap = (value: unknown): unknown => {\n if (value instanceof ScopedHarness) return withSteps(value, step)\n if (Array.isArray(value) && value.some(item => item instanceof ScopedHarness)) {\n return value.map(wrap)\n }\n return value\n }\n\n const proxy = new Proxy(harness, {\n get(target, prop) {\n const value: unknown = Reflect.get(target, prop, target)\n if (typeof value !== 'function') return wrap(value)\n if (typeof prop !== 'string' || prop === 'constructor' || prop.startsWith('_')) {\n return (value as (...args: unknown[]) => unknown).bind(target)\n }\n const method = value as (...args: unknown[]) => unknown\n if (method instanceof AsyncFunction) {\n return async (...args: unknown[]) => {\n let result: unknown\n await step(stepLabel(className, method, args, prop), async () => {\n result = await method.apply(target, args)\n })\n return wrap(result)\n }\n }\n return (...args: unknown[]) => wrap(method.apply(target, args))\n },\n // An accessor's setter writes a private slot only the real instance has.\n set: (target, prop, value) => Reflect.set(target, prop, value, target),\n })\n proxies.set(harness, proxy)\n return proxy\n}\n\n/**\n * `Harness.method(args…)`, each argument shortened, and any argument whose\n * parameter name — or object key — looks like a secret replaced with `•••`.\n *\n * Names are read from the source, so a minified `storybook build` shows mangled\n * class names and masks only object keys, not positional arguments.\n */\nexport function stepLabel(\n className: string,\n method: (...args: never[]) => unknown,\n args: readonly unknown[],\n name: string = method.name,\n): string {\n const params = parameterNames(method)\n const shown = args.map((arg, index) =>\n isSecret(params[index] ?? '') ? JSON.stringify(REDACTED) : formatArg(arg),\n )\n return `${className}.${name}(${shown.join(', ')})`\n}\n\nfunction isSecret(name: string): boolean {\n return SECRET.test(name) || name.split(WORD_BREAK).some(word => SECRET_WORD.test(word))\n}\n\nfunction formatArg(arg: unknown): string {\n if (typeof arg === 'function') return 'ƒ'\n if (arg instanceof ScopedHarness) return arg.constructor.name\n if (typeof arg === 'string') {\n return JSON.stringify(arg.length > MAX_ARG ? `${arg.slice(0, MAX_ARG)}…` : arg)\n }\n const json =\n JSON.stringify(arg, (key, value: unknown) => (isSecret(key) ? REDACTED : value)) ?? String(arg)\n return json.length > MAX_ARG ? `${json.slice(0, MAX_ARG)}…` : json\n}\n\n/** Plain parameter names from the source; none when they are destructured. */\nfunction parameterNames(method: (...args: never[]) => unknown): string[] {\n const list = /\\(([^)]*)\\)/.exec(Function.prototype.toString.call(method))?.[1] ?? ''\n if (/[{[]/.test(list)) return []\n return list.split(',').map(param => param.replace(/[=:].*$/s, '').trim())\n}\n","import { getConfig, harnessMatchers, testIdPresenceSelector } from '@harnessed-ts/core'\nimport type { Assertable, ScopedHarness, ScopedHarnessConstructor } from '@harnessed-ts/core'\nimport { dom } from '@harnessed-ts/dom'\nimport type { UserEvent } from '@testing-library/user-event'\nimport { PLAY_FUNCTION_THREW_EXCEPTION } from 'storybook/internal/core-events'\nimport { addons } from 'storybook/preview-api'\nimport { expect, userEvent } from 'storybook/test'\nimport { PLAY_ERROR_ATTRIBUTE, STORY_READY_TEST_ID } from './ready'\nimport { withSteps } from './steps'\nimport type { StepRunner } from './steps'\n\nexport { PLAY_ERROR_ATTRIBUTE, STORY_READY_TEST_ID } from './ready'\nexport { stepLabel, withSteps } from './steps'\nexport type { StepRunner } from './steps'\n\n/** What `harnessed()` reads from, and adds to, Storybook's story context. */\nexport interface HarnessedContext {\n canvasElement: HTMLElement\n parameters: { harness?: ScopedHarnessConstructor }\n userEvent?: unknown\n step: StepRunner\n harness?: ScopedHarness\n}\n\nlet matchersRegistered = false\nlet listening = false\n/** The message of the current story's play error, if it threw. */\nlet playError: string | undefined\n\n/**\n * Storybook catches a play function's error, reports it on its channel, and\n * then finishes the render (running `afterEach`) as if it had passed, so the\n * error has to be caught here to reach the ready mark.\n */\nfunction listenForPlayErrors(): void {\n if (listening || !addons.hasChannel()) return\n addons.getChannel().on(PLAY_FUNCTION_THREW_EXCEPTION, (error?: { message?: string }) => {\n playError = error?.message ?? 'the play function threw'\n })\n listening = true\n}\n\n/**\n * Preview annotations that put a harness in every play function. Spread them\n * into `.storybook/preview.ts`:\n *\n * ```ts\n * import { harnessed } from '@harnessed-ts/storybook'\n * export default { ...harnessed() } satisfies Preview\n * ```\n *\n * A story (or its meta) names its harness in `parameters.harness`, and `play`\n * receives it as `context.harness`, driving the canvas through Storybook's\n * instrumented `userEvent` with each method logged as a step. The harness\n * matchers are added to `storybook/test`'s `expect`.\n */\nexport function harnessed(): {\n beforeEach: (context: HarnessedContext) => Promise<void>\n afterEach: (context: HarnessedContext) => Promise<void>\n} {\n if (!matchersRegistered) {\n expect.extend(harnessMatchers)\n matchersRegistered = true\n }\n return {\n async beforeEach(context: HarnessedContext) {\n readyMarker(context.canvasElement.ownerDocument)?.remove()\n listenForPlayErrors()\n playError = undefined\n const HarnessClass = context.parameters.harness\n if (HarnessClass === undefined) return\n const env = dom({\n // Storybook's own instance, so each keystroke shows up in the panel. It\n // is only set where a clipboard exists; fall back to a plain one.\n user: (hasClick(context.userEvent) ? context.userEvent : userEvent.setup()) as UserEvent,\n container: context.canvasElement,\n })\n context.harness = withSteps(new HarnessClass(env), context.step)\n },\n async afterEach(context: HarnessedContext) {\n const document = context.canvasElement.ownerDocument\n if (readyMarker(document) !== null) return\n const marker = document.createElement('div')\n const attribute = getConfig().testIdAttribute\n // Under a presence pattern the id is part of the name: `data-test-<id>`.\n if (attribute.endsWith('*'))\n marker.setAttribute(attribute.slice(0, -1) + STORY_READY_TEST_ID, '')\n else marker.setAttribute(attribute, STORY_READY_TEST_ID)\n marker.hidden = true\n if (playError !== undefined) marker.setAttribute(PLAY_ERROR_ATTRIBUTE, playError)\n document.body.append(marker)\n },\n }\n}\n\n/** Found the way `testId(STORY_READY_TEST_ID)` finds it, which is what `StoryPage` waits on. */\nfunction readyMarker(document: Document): Element | null {\n const attribute = getConfig().testIdAttribute\n const presence = testIdPresenceSelector(attribute, STORY_READY_TEST_ID)\n return document.querySelector(presence ?? `[${attribute}=\"${STORY_READY_TEST_ID}\"]`)\n}\n\nfunction hasClick(value: unknown): boolean {\n return typeof (value as { click?: unknown } | undefined)?.click === 'function'\n}\n\n/**\n * A story type whose `play` receives `harness` typed as `H`.\n *\n * ```ts\n * type Story = WithHarness<StoryObj<typeof meta>, LoginFormHarness>\n * ```\n */\nexport type WithHarness<S, H extends ScopedHarness> = Omit<S, 'play'> & {\n play?: S extends { play?: infer P }\n ? P extends (context: infer C) => infer R\n ? (context: C & { harness: H }) => R\n : never\n : never\n}\n\ndeclare module 'storybook/test' {\n // The type parameter must keep Storybook's own name. Gating on `T` makes calling\n // these on something that is not a target or a harness a type error.\n interface Assertion<T> {\n toBeSelected(): T extends Assertable ? Promise<void> : never\n toBeAbsent(): T extends Assertable ? Promise<void> : never\n toReadAs(expected: string | RegExp): T extends Assertable ? Promise<void> : never\n }\n}\n"]}
@@ -0,0 +1,55 @@
1
+ import type { Assertable, ScopedHarness, ScopedHarnessConstructor } from '@harnessed-ts/core';
2
+ import type { StepRunner } from './steps';
3
+ export { PLAY_ERROR_ATTRIBUTE, STORY_READY_TEST_ID } from './ready';
4
+ export { stepLabel, withSteps } from './steps';
5
+ export type { StepRunner } from './steps';
6
+ /** What `harnessed()` reads from, and adds to, Storybook's story context. */
7
+ export interface HarnessedContext {
8
+ canvasElement: HTMLElement;
9
+ parameters: {
10
+ harness?: ScopedHarnessConstructor;
11
+ };
12
+ userEvent?: unknown;
13
+ step: StepRunner;
14
+ harness?: ScopedHarness;
15
+ }
16
+ /**
17
+ * Preview annotations that put a harness in every play function. Spread them
18
+ * into `.storybook/preview.ts`:
19
+ *
20
+ * ```ts
21
+ * import { harnessed } from '@harnessed-ts/storybook'
22
+ * export default { ...harnessed() } satisfies Preview
23
+ * ```
24
+ *
25
+ * A story (or its meta) names its harness in `parameters.harness`, and `play`
26
+ * receives it as `context.harness`, driving the canvas through Storybook's
27
+ * instrumented `userEvent` with each method logged as a step. The harness
28
+ * matchers are added to `storybook/test`'s `expect`.
29
+ */
30
+ export declare function harnessed(): {
31
+ beforeEach: (context: HarnessedContext) => Promise<void>;
32
+ afterEach: (context: HarnessedContext) => Promise<void>;
33
+ };
34
+ /**
35
+ * A story type whose `play` receives `harness` typed as `H`.
36
+ *
37
+ * ```ts
38
+ * type Story = WithHarness<StoryObj<typeof meta>, LoginFormHarness>
39
+ * ```
40
+ */
41
+ export type WithHarness<S, H extends ScopedHarness> = Omit<S, 'play'> & {
42
+ play?: S extends {
43
+ play?: infer P;
44
+ } ? P extends (context: infer C) => infer R ? (context: C & {
45
+ harness: H;
46
+ }) => R : never : never;
47
+ };
48
+ declare module 'storybook/test' {
49
+ interface Assertion<T> {
50
+ toBeSelected(): T extends Assertable ? Promise<void> : never;
51
+ toBeAbsent(): T extends Assertable ? Promise<void> : never;
52
+ toReadAs(expected: string | RegExp): T extends Assertable ? Promise<void> : never;
53
+ }
54
+ }
55
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,aAAa,EAAE,wBAAwB,EAAE,MAAM,oBAAoB,CAAA;AAQ7F,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,SAAS,CAAA;AAEzC,OAAO,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,MAAM,SAAS,CAAA;AACnE,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,SAAS,CAAA;AAC9C,YAAY,EAAE,UAAU,EAAE,MAAM,SAAS,CAAA;AAEzC,6EAA6E;AAC7E,MAAM,WAAW,gBAAgB;IAC/B,aAAa,EAAE,WAAW,CAAA;IAC1B,UAAU,EAAE;QAAE,OAAO,CAAC,EAAE,wBAAwB,CAAA;KAAE,CAAA;IAClD,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,IAAI,EAAE,UAAU,CAAA;IAChB,OAAO,CAAC,EAAE,aAAa,CAAA;CACxB;AAoBD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,SAAS,IAAI;IAC3B,UAAU,EAAE,CAAC,OAAO,EAAE,gBAAgB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IACxD,SAAS,EAAE,CAAC,OAAO,EAAE,gBAAgB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;CACxD,CAkCA;AAaD;;;;;;GAMG;AACH,MAAM,MAAM,WAAW,CAAC,CAAC,EAAE,CAAC,SAAS,aAAa,IAAI,IAAI,CAAC,CAAC,EAAE,MAAM,CAAC,GAAG;IACtE,IAAI,CAAC,EAAE,CAAC,SAAS;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC,CAAA;KAAE,GAC/B,CAAC,SAAS,CAAC,OAAO,EAAE,MAAM,CAAC,KAAK,MAAM,CAAC,GACrC,CAAC,OAAO,EAAE,CAAC,GAAG;QAAE,OAAO,EAAE,CAAC,CAAA;KAAE,KAAK,CAAC,GAClC,KAAK,GACP,KAAK,CAAA;CACV,CAAA;AAED,OAAO,QAAQ,gBAAgB,CAAC;IAG9B,UAAU,SAAS,CAAC,CAAC;QACnB,YAAY,IAAI,CAAC,SAAS,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,GAAG,KAAK,CAAA;QAC5D,UAAU,IAAI,CAAC,SAAS,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,GAAG,KAAK,CAAA;QAC1D,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,CAAC,SAAS,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,GAAG,KAAK,CAAA;KAClF;CACF"}
package/dist/index.js ADDED
@@ -0,0 +1,134 @@
1
+ import { STORY_READY_TEST_ID, PLAY_ERROR_ATTRIBUTE } from './chunk-J6DS34J4.js';
2
+ export { PLAY_ERROR_ATTRIBUTE, STORY_READY_TEST_ID } from './chunk-J6DS34J4.js';
3
+ import { ScopedHarness, harnessMatchers, getConfig, testIdPresenceSelector } from '@harnessed-ts/core';
4
+ import { dom } from '@harnessed-ts/dom';
5
+ import { PLAY_FUNCTION_THREW_EXCEPTION } from 'storybook/internal/core-events';
6
+ import { addons } from 'storybook/preview-api';
7
+ import { expect, userEvent } from 'storybook/test';
8
+
9
+ var SECRET = /pass|secret|token|credential/i;
10
+ var SECRET_WORD = /^(key|pin|otp|auth)$/i;
11
+ var WORD_BREAK = /[^a-zA-Z0-9]+|(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/;
12
+ var REDACTED = "\u2022\u2022\u2022";
13
+ var MAX_ARG = 40;
14
+ var AsyncFunction = (async () => {
15
+ }).constructor;
16
+ var proxies = /* @__PURE__ */ new WeakMap();
17
+ function withSteps(harness, step) {
18
+ const cached = proxies.get(harness);
19
+ if (cached !== void 0) return cached;
20
+ const className = harness.constructor.name;
21
+ const wrap = (value) => {
22
+ if (value instanceof ScopedHarness) return withSteps(value, step);
23
+ if (Array.isArray(value) && value.some((item) => item instanceof ScopedHarness)) {
24
+ return value.map(wrap);
25
+ }
26
+ return value;
27
+ };
28
+ const proxy = new Proxy(harness, {
29
+ get(target, prop) {
30
+ const value = Reflect.get(target, prop, target);
31
+ if (typeof value !== "function") return wrap(value);
32
+ if (typeof prop !== "string" || prop === "constructor" || prop.startsWith("_")) {
33
+ return value.bind(target);
34
+ }
35
+ const method = value;
36
+ if (method instanceof AsyncFunction) {
37
+ return async (...args) => {
38
+ let result;
39
+ await step(stepLabel(className, method, args, prop), async () => {
40
+ result = await method.apply(target, args);
41
+ });
42
+ return wrap(result);
43
+ };
44
+ }
45
+ return (...args) => wrap(method.apply(target, args));
46
+ },
47
+ // An accessor's setter writes a private slot only the real instance has.
48
+ set: (target, prop, value) => Reflect.set(target, prop, value, target)
49
+ });
50
+ proxies.set(harness, proxy);
51
+ return proxy;
52
+ }
53
+ function stepLabel(className, method, args, name = method.name) {
54
+ const params = parameterNames(method);
55
+ const shown = args.map(
56
+ (arg, index) => isSecret(params[index] ?? "") ? JSON.stringify(REDACTED) : formatArg(arg)
57
+ );
58
+ return `${className}.${name}(${shown.join(", ")})`;
59
+ }
60
+ function isSecret(name) {
61
+ return SECRET.test(name) || name.split(WORD_BREAK).some((word) => SECRET_WORD.test(word));
62
+ }
63
+ function formatArg(arg) {
64
+ if (typeof arg === "function") return "\u0192";
65
+ if (arg instanceof ScopedHarness) return arg.constructor.name;
66
+ if (typeof arg === "string") {
67
+ return JSON.stringify(arg.length > MAX_ARG ? `${arg.slice(0, MAX_ARG)}\u2026` : arg);
68
+ }
69
+ const json = JSON.stringify(arg, (key, value) => isSecret(key) ? REDACTED : value) ?? String(arg);
70
+ return json.length > MAX_ARG ? `${json.slice(0, MAX_ARG)}\u2026` : json;
71
+ }
72
+ function parameterNames(method) {
73
+ const list = /\(([^)]*)\)/.exec(Function.prototype.toString.call(method))?.[1] ?? "";
74
+ if (/[{[]/.test(list)) return [];
75
+ return list.split(",").map((param) => param.replace(/[=:].*$/s, "").trim());
76
+ }
77
+
78
+ // src/index.ts
79
+ var matchersRegistered = false;
80
+ var listening = false;
81
+ var playError;
82
+ function listenForPlayErrors() {
83
+ if (listening || !addons.hasChannel()) return;
84
+ addons.getChannel().on(PLAY_FUNCTION_THREW_EXCEPTION, (error) => {
85
+ playError = error?.message ?? "the play function threw";
86
+ });
87
+ listening = true;
88
+ }
89
+ function harnessed() {
90
+ if (!matchersRegistered) {
91
+ expect.extend(harnessMatchers);
92
+ matchersRegistered = true;
93
+ }
94
+ return {
95
+ async beforeEach(context) {
96
+ readyMarker(context.canvasElement.ownerDocument)?.remove();
97
+ listenForPlayErrors();
98
+ playError = void 0;
99
+ const HarnessClass = context.parameters.harness;
100
+ if (HarnessClass === void 0) return;
101
+ const env = dom({
102
+ // Storybook's own instance, so each keystroke shows up in the panel. It
103
+ // is only set where a clipboard exists; fall back to a plain one.
104
+ user: hasClick(context.userEvent) ? context.userEvent : userEvent.setup(),
105
+ container: context.canvasElement
106
+ });
107
+ context.harness = withSteps(new HarnessClass(env), context.step);
108
+ },
109
+ async afterEach(context) {
110
+ const document = context.canvasElement.ownerDocument;
111
+ if (readyMarker(document) !== null) return;
112
+ const marker = document.createElement("div");
113
+ const attribute = getConfig().testIdAttribute;
114
+ if (attribute.endsWith("*"))
115
+ marker.setAttribute(attribute.slice(0, -1) + STORY_READY_TEST_ID, "");
116
+ else marker.setAttribute(attribute, STORY_READY_TEST_ID);
117
+ marker.hidden = true;
118
+ if (playError !== void 0) marker.setAttribute(PLAY_ERROR_ATTRIBUTE, playError);
119
+ document.body.append(marker);
120
+ }
121
+ };
122
+ }
123
+ function readyMarker(document) {
124
+ const attribute = getConfig().testIdAttribute;
125
+ const presence = testIdPresenceSelector(attribute, STORY_READY_TEST_ID);
126
+ return document.querySelector(presence ?? `[${attribute}="${STORY_READY_TEST_ID}"]`);
127
+ }
128
+ function hasClick(value) {
129
+ return typeof value?.click === "function";
130
+ }
131
+
132
+ export { harnessed, stepLabel, withSteps };
133
+ //# sourceMappingURL=index.js.map
134
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/steps.ts","../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["import { ScopedHarness } from '@harnessed-ts/core'\n\n/** Storybook's `context.step`, narrowed to what the proxy needs. */\nexport type StepRunner = (label: string, play: () => Promise<void>) => Promise<unknown> | unknown\n\nconst SECRET = /pass|secret|token|credential/i\n// Short names match whole words only, so `author`, `keyboard` and `typing` stay visible.\nconst SECRET_WORD = /^(key|pin|otp|auth)$/i\n// camelCase, PascalCase acronyms (`APIKey`) and snake/kebab-case word breaks.\nconst WORD_BREAK = /[^a-zA-Z0-9]+|(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/\nconst REDACTED = '•••'\nconst MAX_ARG = 40\n\nconst AsyncFunction = (async () => {}).constructor\n\nconst proxies = new WeakMap<object, unknown>()\n\n/**\n * The harness, with every public async method run inside `step()` — so the\n * Interactions panel reads `LoginFormHarness.signInAs(\"ada@…\", \"•••\")` with the\n * raw clicks and keystrokes nested under it, rather than a flat list of events.\n *\n * Methods run against the real instance, not the proxy: a harness calling its\n * own methods does not log a step per call, and decorated `accessor` fields\n * (backed by private slots a proxy cannot reach) keep working. Sync methods stay\n * sync; a harness they return — `nth()`, a child — comes back wrapped too.\n *\n * shortcut: async is detected by `AsyncFunction`, so a plain method that returns\n * a promise, or one down-levelled to a generator, logs no step. Upgrade if a\n * harness or a build ever needs that.\n */\nexport function withSteps<T extends ScopedHarness>(harness: T, step: StepRunner): T {\n const cached = proxies.get(harness)\n if (cached !== undefined) return cached as T\n const className = harness.constructor.name\n\n const wrap = (value: unknown): unknown => {\n if (value instanceof ScopedHarness) return withSteps(value, step)\n if (Array.isArray(value) && value.some(item => item instanceof ScopedHarness)) {\n return value.map(wrap)\n }\n return value\n }\n\n const proxy = new Proxy(harness, {\n get(target, prop) {\n const value: unknown = Reflect.get(target, prop, target)\n if (typeof value !== 'function') return wrap(value)\n if (typeof prop !== 'string' || prop === 'constructor' || prop.startsWith('_')) {\n return (value as (...args: unknown[]) => unknown).bind(target)\n }\n const method = value as (...args: unknown[]) => unknown\n if (method instanceof AsyncFunction) {\n return async (...args: unknown[]) => {\n let result: unknown\n await step(stepLabel(className, method, args, prop), async () => {\n result = await method.apply(target, args)\n })\n return wrap(result)\n }\n }\n return (...args: unknown[]) => wrap(method.apply(target, args))\n },\n // An accessor's setter writes a private slot only the real instance has.\n set: (target, prop, value) => Reflect.set(target, prop, value, target),\n })\n proxies.set(harness, proxy)\n return proxy\n}\n\n/**\n * `Harness.method(args…)`, each argument shortened, and any argument whose\n * parameter name — or object key — looks like a secret replaced with `•••`.\n *\n * Names are read from the source, so a minified `storybook build` shows mangled\n * class names and masks only object keys, not positional arguments.\n */\nexport function stepLabel(\n className: string,\n method: (...args: never[]) => unknown,\n args: readonly unknown[],\n name: string = method.name,\n): string {\n const params = parameterNames(method)\n const shown = args.map((arg, index) =>\n isSecret(params[index] ?? '') ? JSON.stringify(REDACTED) : formatArg(arg),\n )\n return `${className}.${name}(${shown.join(', ')})`\n}\n\nfunction isSecret(name: string): boolean {\n return SECRET.test(name) || name.split(WORD_BREAK).some(word => SECRET_WORD.test(word))\n}\n\nfunction formatArg(arg: unknown): string {\n if (typeof arg === 'function') return 'ƒ'\n if (arg instanceof ScopedHarness) return arg.constructor.name\n if (typeof arg === 'string') {\n return JSON.stringify(arg.length > MAX_ARG ? `${arg.slice(0, MAX_ARG)}…` : arg)\n }\n const json =\n JSON.stringify(arg, (key, value: unknown) => (isSecret(key) ? REDACTED : value)) ?? String(arg)\n return json.length > MAX_ARG ? `${json.slice(0, MAX_ARG)}…` : json\n}\n\n/** Plain parameter names from the source; none when they are destructured. */\nfunction parameterNames(method: (...args: never[]) => unknown): string[] {\n const list = /\\(([^)]*)\\)/.exec(Function.prototype.toString.call(method))?.[1] ?? ''\n if (/[{[]/.test(list)) return []\n return list.split(',').map(param => param.replace(/[=:].*$/s, '').trim())\n}\n","import { getConfig, harnessMatchers, testIdPresenceSelector } from '@harnessed-ts/core'\nimport type { Assertable, ScopedHarness, ScopedHarnessConstructor } from '@harnessed-ts/core'\nimport { dom } from '@harnessed-ts/dom'\nimport type { UserEvent } from '@testing-library/user-event'\nimport { PLAY_FUNCTION_THREW_EXCEPTION } from 'storybook/internal/core-events'\nimport { addons } from 'storybook/preview-api'\nimport { expect, userEvent } from 'storybook/test'\nimport { PLAY_ERROR_ATTRIBUTE, STORY_READY_TEST_ID } from './ready'\nimport { withSteps } from './steps'\nimport type { StepRunner } from './steps'\n\nexport { PLAY_ERROR_ATTRIBUTE, STORY_READY_TEST_ID } from './ready'\nexport { stepLabel, withSteps } from './steps'\nexport type { StepRunner } from './steps'\n\n/** What `harnessed()` reads from, and adds to, Storybook's story context. */\nexport interface HarnessedContext {\n canvasElement: HTMLElement\n parameters: { harness?: ScopedHarnessConstructor }\n userEvent?: unknown\n step: StepRunner\n harness?: ScopedHarness\n}\n\nlet matchersRegistered = false\nlet listening = false\n/** The message of the current story's play error, if it threw. */\nlet playError: string | undefined\n\n/**\n * Storybook catches a play function's error, reports it on its channel, and\n * then finishes the render (running `afterEach`) as if it had passed, so the\n * error has to be caught here to reach the ready mark.\n */\nfunction listenForPlayErrors(): void {\n if (listening || !addons.hasChannel()) return\n addons.getChannel().on(PLAY_FUNCTION_THREW_EXCEPTION, (error?: { message?: string }) => {\n playError = error?.message ?? 'the play function threw'\n })\n listening = true\n}\n\n/**\n * Preview annotations that put a harness in every play function. Spread them\n * into `.storybook/preview.ts`:\n *\n * ```ts\n * import { harnessed } from '@harnessed-ts/storybook'\n * export default { ...harnessed() } satisfies Preview\n * ```\n *\n * A story (or its meta) names its harness in `parameters.harness`, and `play`\n * receives it as `context.harness`, driving the canvas through Storybook's\n * instrumented `userEvent` with each method logged as a step. The harness\n * matchers are added to `storybook/test`'s `expect`.\n */\nexport function harnessed(): {\n beforeEach: (context: HarnessedContext) => Promise<void>\n afterEach: (context: HarnessedContext) => Promise<void>\n} {\n if (!matchersRegistered) {\n expect.extend(harnessMatchers)\n matchersRegistered = true\n }\n return {\n async beforeEach(context: HarnessedContext) {\n readyMarker(context.canvasElement.ownerDocument)?.remove()\n listenForPlayErrors()\n playError = undefined\n const HarnessClass = context.parameters.harness\n if (HarnessClass === undefined) return\n const env = dom({\n // Storybook's own instance, so each keystroke shows up in the panel. It\n // is only set where a clipboard exists; fall back to a plain one.\n user: (hasClick(context.userEvent) ? context.userEvent : userEvent.setup()) as UserEvent,\n container: context.canvasElement,\n })\n context.harness = withSteps(new HarnessClass(env), context.step)\n },\n async afterEach(context: HarnessedContext) {\n const document = context.canvasElement.ownerDocument\n if (readyMarker(document) !== null) return\n const marker = document.createElement('div')\n const attribute = getConfig().testIdAttribute\n // Under a presence pattern the id is part of the name: `data-test-<id>`.\n if (attribute.endsWith('*'))\n marker.setAttribute(attribute.slice(0, -1) + STORY_READY_TEST_ID, '')\n else marker.setAttribute(attribute, STORY_READY_TEST_ID)\n marker.hidden = true\n if (playError !== undefined) marker.setAttribute(PLAY_ERROR_ATTRIBUTE, playError)\n document.body.append(marker)\n },\n }\n}\n\n/** Found the way `testId(STORY_READY_TEST_ID)` finds it, which is what `StoryPage` waits on. */\nfunction readyMarker(document: Document): Element | null {\n const attribute = getConfig().testIdAttribute\n const presence = testIdPresenceSelector(attribute, STORY_READY_TEST_ID)\n return document.querySelector(presence ?? `[${attribute}=\"${STORY_READY_TEST_ID}\"]`)\n}\n\nfunction hasClick(value: unknown): boolean {\n return typeof (value as { click?: unknown } | undefined)?.click === 'function'\n}\n\n/**\n * A story type whose `play` receives `harness` typed as `H`.\n *\n * ```ts\n * type Story = WithHarness<StoryObj<typeof meta>, LoginFormHarness>\n * ```\n */\nexport type WithHarness<S, H extends ScopedHarness> = Omit<S, 'play'> & {\n play?: S extends { play?: infer P }\n ? P extends (context: infer C) => infer R\n ? (context: C & { harness: H }) => R\n : never\n : never\n}\n\ndeclare module 'storybook/test' {\n // The type parameter must keep Storybook's own name. Gating on `T` makes calling\n // these on something that is not a target or a harness a type error.\n interface Assertion<T> {\n toBeSelected(): T extends Assertable ? Promise<void> : never\n toBeAbsent(): T extends Assertable ? Promise<void> : never\n toReadAs(expected: string | RegExp): T extends Assertable ? Promise<void> : never\n }\n}\n"]}
package/dist/page.cjs ADDED
@@ -0,0 +1,120 @@
1
+ 'use strict';
2
+
3
+ var core = require('@harnessed-ts/core');
4
+ var page = require('@harnessed-ts/page');
5
+ var csf = require('storybook/internal/csf');
6
+
7
+ var __create = Object.create;
8
+ var __defProp = Object.defineProperty;
9
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
10
+ var __knownSymbol = (name, symbol) => (symbol = Symbol[name]) ? symbol : /* @__PURE__ */ Symbol.for("Symbol." + name);
11
+ var __typeError = (msg) => {
12
+ throw TypeError(msg);
13
+ };
14
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
15
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
16
+ var __decoratorStart = (base) => [, , , __create(base?.[__knownSymbol("metadata")] ?? null)];
17
+ var __decoratorStrings = ["class", "method", "getter", "setter", "accessor", "field", "value", "get", "set"];
18
+ var __expectFn = (fn) => fn !== void 0 && typeof fn !== "function" ? __typeError("Function expected") : fn;
19
+ var __decoratorContext = (kind, name, done, metadata, fns) => ({ kind: __decoratorStrings[kind], name, metadata, addInitializer: (fn) => done._ ? __typeError("Already initialized") : fns.push(__expectFn(fn || null)) });
20
+ var __decoratorMetadata = (array, target) => __defNormalProp(target, __knownSymbol("metadata"), array[3]);
21
+ var __runInitializers = (array, flags, self, value) => {
22
+ for (var i = 0, fns = array[flags >> 1], n = fns && fns.length; i < n; i++) fns[i].call(self) ;
23
+ return value;
24
+ };
25
+ var __decorateElement = (array, flags, name, decorators, target, extra) => {
26
+ var it, done, ctx, k = flags & 7, p = false;
27
+ var j = 0;
28
+ var extraInitializers = array[j] || (array[j] = []);
29
+ var desc = k && ((target = target.prototype), k < 5 && (k > 3 || !p) && __getOwnPropDesc(target , name));
30
+ __name(target, name);
31
+ for (var i = decorators.length - 1; i >= 0; i--) {
32
+ ctx = __decoratorContext(k, name, done = {}, array[3], extraInitializers);
33
+ it = (0, decorators[i])(target, ctx), done._ = 1;
34
+ __expectFn(it) && (target = it);
35
+ }
36
+ return __decoratorMetadata(array, target), desc && __defProp(target, name, desc), p ? k ^ 4 ? extra : desc : target;
37
+ };
38
+
39
+ // src/ready.ts
40
+ var STORY_READY_TEST_ID = "harnessed-story-ready";
41
+ var PLAY_ERROR_ATTRIBUTE = "data-harnessed-play-error";
42
+
43
+ // src/page.ts
44
+ var SAFE_KEY = /^[a-zA-Z0-9 _-]+$/;
45
+ var SAFE_STRING = /^[a-zA-Z0-9 _-]*$/;
46
+ var NUMERIC = /^-?[0-9]+(\.[0-9]+)?$/;
47
+ function storyUrl(stories, name, options = {}) {
48
+ const title = stories.default.id ?? stories.default.title;
49
+ if (title === void 0) {
50
+ throw new Error(
51
+ `harnessed: the stories module for "${name}" has no \`title\` on its default export, so its story id cannot be derived outside Storybook. Fix: set \`title\` on the meta (or \`id\`, if the story id must not follow the title).`
52
+ );
53
+ }
54
+ const params = [`id=${csf.toId(title, csf.storyNameFromExport(name))}`, "viewMode=story"];
55
+ const args = encode(options.args ?? {});
56
+ if (args !== "") params.push(`args=${args}`);
57
+ const globals = encode(options.globals ?? {});
58
+ if (globals !== "") params.push(`globals=${globals}`);
59
+ if (options.play === false) params.push("embed=true");
60
+ return `/iframe.html?${params.join("&")}`;
61
+ }
62
+ function encode(values) {
63
+ return Object.entries(values).map(([key, value]) => `${key}:${encodeValue(key, value)}`).join(";");
64
+ }
65
+ function encodeValue(key, value) {
66
+ if (SAFE_KEY.test(key)) {
67
+ if (value === void 0) return "!undefined";
68
+ if (value === null) return "!null";
69
+ if (typeof value === "boolean") return `!${value}`;
70
+ if (typeof value === "number" && NUMERIC.test(String(value))) return String(value);
71
+ if (typeof value === "string" && SAFE_STRING.test(value) && !NUMERIC.test(value)) {
72
+ return value.replaceAll(" ", "+");
73
+ }
74
+ }
75
+ throw new Error(
76
+ `harnessed: arg \`${key}\` = ${typeof value === "number" ? value : JSON.stringify(value)} cannot travel in a Storybook URL \u2014 Storybook drops anything but letters, digits, spaces, \`_\`, \`-\`, numbers and booleans (and reads a numeric string back as a number), so the story would render without it. Fix: add a story with that state in its \`args\`.`
77
+ );
78
+ }
79
+ var _StoryPage_decorators, _init, _a;
80
+ _StoryPage_decorators = [core.Harness({ host: core.testId(STORY_READY_TEST_ID) })];
81
+ var _StoryPage = class _StoryPage extends (_a = page.PageHarness) {
82
+ #url = "";
83
+ static async goto(env, stories, name, options) {
84
+ const story = new _StoryPage(env);
85
+ story.#url = storyUrl(stories, name, options);
86
+ await story.goto();
87
+ return story;
88
+ }
89
+ get path() {
90
+ return this.#url;
91
+ }
92
+ /** A harness for something in the story, scoped to the whole iframe. */
93
+ harness(HarnessClass) {
94
+ return new HarnessClass(this._env);
95
+ }
96
+ async waitForReady() {
97
+ const deadline = Date.now() + core.timeoutFor();
98
+ while (await this.self.count() === 0) {
99
+ if (Date.now() > deadline) {
100
+ throw new Error(
101
+ `harnessed: story ${this.#url} did not finish rendering. Either it threw while rendering (open it in Storybook to see why), or \`.storybook/preview.ts\` does not spread \`harnessed()\`.`
102
+ );
103
+ }
104
+ await new Promise((resolve) => setTimeout(resolve, 50));
105
+ }
106
+ const playError = await this.self.attribute(PLAY_ERROR_ATTRIBUTE);
107
+ if (playError !== null) {
108
+ throw new Error(`harnessed: the play function of story ${this.#url} threw: ${playError}`);
109
+ }
110
+ }
111
+ };
112
+ _init = __decoratorStart(_a);
113
+ _StoryPage = __decorateElement(_init, 0, "StoryPage", _StoryPage_decorators, _StoryPage);
114
+ __runInitializers(_init, 1, _StoryPage);
115
+ var StoryPage = _StoryPage;
116
+
117
+ exports.StoryPage = StoryPage;
118
+ exports.storyUrl = storyUrl;
119
+ //# sourceMappingURL=page.cjs.map
120
+ //# sourceMappingURL=page.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/ready.ts","../src/page.ts"],"names":["toId","storyNameFromExport","Harness","testId","PageHarness","timeoutFor"],"mappings":"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","file":"page.cjs","sourcesContent":["/**\n * The test id of the node `harnessed()` adds once a story has rendered and its\n * play function has finished. `StoryPage` waits for it: Storybook's own\n * \"rendered\" signal is a channel event, which a runner outside the iframe's\n * realm cannot listen for, while a node is something every driver can count.\n */\nexport const STORY_READY_TEST_ID = 'harnessed-story-ready'\n\n/** Set on that node when the play function threw, holding the error's message. */\nexport const PLAY_ERROR_ATTRIBUTE = 'data-harnessed-play-error'\n","import { Harness, testId, timeoutFor } from '@harnessed-ts/core'\nimport type { EnvConfig, ScopedHarness, ScopedHarnessConstructor } from '@harnessed-ts/core'\nimport { PageHarness } from '@harnessed-ts/page'\nimport { storyNameFromExport, toId } from 'storybook/internal/csf'\nimport { PLAY_ERROR_ATTRIBUTE, STORY_READY_TEST_ID } from './ready'\n\n/** A CSF module as imported with `import * as Stories from './X.stories'`. */\nexport interface StoriesModule {\n default: { title?: string; id?: string }\n}\n\n/** The export names in a CSF module that are stories, not the meta. */\nexport type StoryName<M extends StoriesModule> = Exclude<keyof M, 'default'> & string\n\n/** The args a story declares, all optional: a URL overrides some, never must set all. */\nexport type StoryArgs<M extends StoriesModule, K extends StoryName<M>> = Partial<\n ArgsOf<M[K]> & ArgsOf<M['default']>\n>\n\n// An empty record rather than `never` when there are none: a meta without\n// `args` must not erase the story's own.\ntype ArgsOf<T> = T extends { args?: infer A } ? NonNullable<A> : Record<never, never>\n\nexport interface StoryOptions<Args> {\n /**\n * Overrides for the story's args, encoded in Storybook's own URL syntax.\n * Storybook applies one only when the arg has an argType — inferred from the\n * component's props, or declared in `argTypes` — and drops it otherwise.\n */\n args?: Args\n /** Toolbar globals — theme, locale, viewport. */\n globals?: Record<string, Scalar>\n /**\n * Whether the story's play function runs. Defaults to `true`, as it does in\n * Storybook; `false` opens the story in its initial state for the test to drive.\n */\n play?: boolean\n}\n\ntype Scalar = string | number | boolean | null | undefined\n\n// Storybook's own rules (storybook/internal/router): it silently drops any arg\n// that fails them, so a test would run against the wrong state. Refuse instead.\nconst SAFE_KEY = /^[a-zA-Z0-9 _-]+$/\n// Storybook reads a numeric-looking string back as a number, so '02134' would\n// arrive as 2134: letters, digits, spaces, `_` and `-`, but not a number.\nconst SAFE_STRING = /^[a-zA-Z0-9 _-]*$/\nconst NUMERIC = /^-?[0-9]+(\\.[0-9]+)?$/\n\n/**\n * The iframe URL of one story — id from the meta and export name, exactly as\n * Storybook derives it — relative to the Storybook root.\n */\nexport function storyUrl<M extends StoriesModule, K extends StoryName<M>>(\n stories: M,\n name: K,\n options: StoryOptions<StoryArgs<M, K>> = {},\n): string {\n const title = stories.default.id ?? stories.default.title\n if (title === undefined) {\n throw new Error(\n `harnessed: the stories module for \"${name}\" has no \\`title\\` on its default export, ` +\n 'so its story id cannot be derived outside Storybook. Fix: set `title` on the meta ' +\n '(or `id`, if the story id must not follow the title).',\n )\n }\n const params = [`id=${toId(title, storyNameFromExport(name))}`, 'viewMode=story']\n const args = encode(options.args ?? {})\n if (args !== '') params.push(`args=${args}`)\n const globals = encode(options.globals ?? {})\n if (globals !== '') params.push(`globals=${globals}`)\n // The switch Storybook itself reads: an embedded story renders without autoplay.\n if (options.play === false) params.push('embed=true')\n return `/iframe.html?${params.join('&')}`\n}\n\nfunction encode(values: Record<string, unknown>): string {\n return Object.entries(values)\n .map(([key, value]) => `${key}:${encodeValue(key, value)}`)\n .join(';')\n}\n\nfunction encodeValue(key: string, value: unknown): string {\n if (SAFE_KEY.test(key)) {\n if (value === undefined) return '!undefined'\n if (value === null) return '!null'\n if (typeof value === 'boolean') return `!${value}`\n // NaN, Infinity and exponents (`1e+21`, whose `+` reads back as a space) do not survive.\n if (typeof value === 'number' && NUMERIC.test(String(value))) return String(value)\n if (typeof value === 'string' && SAFE_STRING.test(value) && !NUMERIC.test(value)) {\n return value.replaceAll(' ', '+')\n }\n }\n throw new Error(\n `harnessed: arg \\`${key}\\` = ${typeof value === 'number' ? value : JSON.stringify(value)} cannot travel in a Storybook URL — ` +\n 'Storybook drops anything but letters, digits, spaces, `_`, `-`, numbers and booleans ' +\n '(and reads a numeric string back as a number), so the story would render without it. ' +\n 'Fix: add a story with that state in its `args`.',\n )\n}\n\n/**\n * One Storybook story, opened as a fixture by any runner that can navigate.\n *\n * ```ts\n * import * as LoginStories from './LoginForm.stories'\n * const story = await StoryPage.goto(pw(page), LoginStories, 'Default', { play: false })\n * const form = story.harness(LoginFormHarness)\n * ```\n *\n * Ready means rendered *and* played: it waits for the node `harnessed()` adds\n * after the story's play function finishes, so `.storybook/preview.ts` must\n * spread `harnessed()`.\n */\n@Harness({ host: testId(STORY_READY_TEST_ID) })\nexport class StoryPage extends PageHarness {\n #url = ''\n\n static async goto<M extends StoriesModule, K extends StoryName<M>>(\n env: EnvConfig,\n stories: M,\n name: K,\n options?: StoryOptions<StoryArgs<M, K>>,\n ): Promise<StoryPage> {\n const story = new StoryPage(env)\n story.#url = storyUrl(stories, name, options)\n await story.goto()\n return story\n }\n\n override get path(): string {\n return this.#url\n }\n\n /** A harness for something in the story, scoped to the whole iframe. */\n harness<T extends ScopedHarness>(HarnessClass: ScopedHarnessConstructor<T>): T {\n return new HarnessClass(this._env)\n }\n\n protected async waitForReady(): Promise<void> {\n // The marker is an empty node, so \"visible\" means nothing; count it instead.\n const deadline = Date.now() + timeoutFor()\n while ((await this.self.count()) === 0) {\n if (Date.now() > deadline) {\n throw new Error(\n `harnessed: story ${this.#url} did not finish rendering. Either it threw ` +\n 'while rendering (open it in Storybook to see why), or ' +\n '`.storybook/preview.ts` does not spread `harnessed()`.',\n )\n }\n await new Promise(resolve => setTimeout(resolve, 50))\n }\n const playError = await this.self.attribute(PLAY_ERROR_ATTRIBUTE)\n if (playError !== null) {\n throw new Error(`harnessed: the play function of story ${this.#url} threw: ${playError}`)\n }\n }\n}\n"]}
package/dist/page.d.ts ADDED
@@ -0,0 +1,60 @@
1
+ import type { EnvConfig, ScopedHarness, ScopedHarnessConstructor } from '@harnessed-ts/core';
2
+ import { PageHarness } from '@harnessed-ts/page';
3
+ /** A CSF module as imported with `import * as Stories from './X.stories'`. */
4
+ export interface StoriesModule {
5
+ default: {
6
+ title?: string;
7
+ id?: string;
8
+ };
9
+ }
10
+ /** The export names in a CSF module that are stories, not the meta. */
11
+ export type StoryName<M extends StoriesModule> = Exclude<keyof M, 'default'> & string;
12
+ /** The args a story declares, all optional: a URL overrides some, never must set all. */
13
+ export type StoryArgs<M extends StoriesModule, K extends StoryName<M>> = Partial<ArgsOf<M[K]> & ArgsOf<M['default']>>;
14
+ type ArgsOf<T> = T extends {
15
+ args?: infer A;
16
+ } ? NonNullable<A> : Record<never, never>;
17
+ export interface StoryOptions<Args> {
18
+ /**
19
+ * Overrides for the story's args, encoded in Storybook's own URL syntax.
20
+ * Storybook applies one only when the arg has an argType — inferred from the
21
+ * component's props, or declared in `argTypes` — and drops it otherwise.
22
+ */
23
+ args?: Args;
24
+ /** Toolbar globals — theme, locale, viewport. */
25
+ globals?: Record<string, Scalar>;
26
+ /**
27
+ * Whether the story's play function runs. Defaults to `true`, as it does in
28
+ * Storybook; `false` opens the story in its initial state for the test to drive.
29
+ */
30
+ play?: boolean;
31
+ }
32
+ type Scalar = string | number | boolean | null | undefined;
33
+ /**
34
+ * The iframe URL of one story — id from the meta and export name, exactly as
35
+ * Storybook derives it — relative to the Storybook root.
36
+ */
37
+ export declare function storyUrl<M extends StoriesModule, K extends StoryName<M>>(stories: M, name: K, options?: StoryOptions<StoryArgs<M, K>>): string;
38
+ /**
39
+ * One Storybook story, opened as a fixture by any runner that can navigate.
40
+ *
41
+ * ```ts
42
+ * import * as LoginStories from './LoginForm.stories'
43
+ * const story = await StoryPage.goto(pw(page), LoginStories, 'Default', { play: false })
44
+ * const form = story.harness(LoginFormHarness)
45
+ * ```
46
+ *
47
+ * Ready means rendered *and* played: it waits for the node `harnessed()` adds
48
+ * after the story's play function finishes, so `.storybook/preview.ts` must
49
+ * spread `harnessed()`.
50
+ */
51
+ export declare class StoryPage extends PageHarness {
52
+ #private;
53
+ static goto<M extends StoriesModule, K extends StoryName<M>>(env: EnvConfig, stories: M, name: K, options?: StoryOptions<StoryArgs<M, K>>): Promise<StoryPage>;
54
+ get path(): string;
55
+ /** A harness for something in the story, scoped to the whole iframe. */
56
+ harness<T extends ScopedHarness>(HarnessClass: ScopedHarnessConstructor<T>): T;
57
+ protected waitForReady(): Promise<void>;
58
+ }
59
+ export {};
60
+ //# sourceMappingURL=page.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"page.d.ts","sourceRoot":"","sources":["../src/page.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,aAAa,EAAE,wBAAwB,EAAE,MAAM,oBAAoB,CAAA;AAC5F,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AAIhD,8EAA8E;AAC9E,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,EAAE,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CACzC;AAED,uEAAuE;AACvE,MAAM,MAAM,SAAS,CAAC,CAAC,SAAS,aAAa,IAAI,OAAO,CAAC,MAAM,CAAC,EAAE,SAAS,CAAC,GAAG,MAAM,CAAA;AAErF,yFAAyF;AACzF,MAAM,MAAM,SAAS,CAAC,CAAC,SAAS,aAAa,EAAE,CAAC,SAAS,SAAS,CAAC,CAAC,CAAC,IAAI,OAAO,CAC9E,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CACpC,CAAA;AAID,KAAK,MAAM,CAAC,CAAC,IAAI,CAAC,SAAS;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC,CAAA;CAAE,GAAG,WAAW,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,CAAA;AAErF,MAAM,WAAW,YAAY,CAAC,IAAI;IAChC;;;;OAIG;IACH,IAAI,CAAC,EAAE,IAAI,CAAA;IACX,iDAAiD;IACjD,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAChC;;;OAGG;IACH,IAAI,CAAC,EAAE,OAAO,CAAA;CACf;AAED,KAAK,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,IAAI,GAAG,SAAS,CAAA;AAU1D;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,CAAC,SAAS,aAAa,EAAE,CAAC,SAAS,SAAS,CAAC,CAAC,CAAC,EACtE,OAAO,EAAE,CAAC,EACV,IAAI,EAAE,CAAC,EACP,OAAO,GAAE,YAAY,CAAC,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,CAAM,GAC1C,MAAM,CAiBR;AA2BD;;;;;;;;;;;;GAYG;AACH,qBACa,SAAU,SAAQ,WAAW;;WAG3B,IAAI,CAAC,CAAC,SAAS,aAAa,EAAE,CAAC,SAAS,SAAS,CAAC,CAAC,CAAC,EAC/D,GAAG,EAAE,SAAS,EACd,OAAO,EAAE,CAAC,EACV,IAAI,EAAE,CAAC,EACP,OAAO,CAAC,EAAE,YAAY,CAAC,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,GACtC,OAAO,CAAC,SAAS,CAAC;IAOrB,IAAa,IAAI,IAAI,MAAM,CAE1B;IAED,wEAAwE;IACxE,OAAO,CAAC,CAAC,SAAS,aAAa,EAAE,YAAY,EAAE,wBAAwB,CAAC,CAAC,CAAC,GAAG,CAAC;cAI9D,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC;CAkB9C"}
package/dist/page.js ADDED
@@ -0,0 +1,81 @@
1
+ import { STORY_READY_TEST_ID, __decoratorStart, __decorateElement, __runInitializers, PLAY_ERROR_ATTRIBUTE } from './chunk-J6DS34J4.js';
2
+ import { Harness, testId, timeoutFor } from '@harnessed-ts/core';
3
+ import { PageHarness } from '@harnessed-ts/page';
4
+ import { toId, storyNameFromExport } from 'storybook/internal/csf';
5
+
6
+ var SAFE_KEY = /^[a-zA-Z0-9 _-]+$/;
7
+ var SAFE_STRING = /^[a-zA-Z0-9 _-]*$/;
8
+ var NUMERIC = /^-?[0-9]+(\.[0-9]+)?$/;
9
+ function storyUrl(stories, name, options = {}) {
10
+ const title = stories.default.id ?? stories.default.title;
11
+ if (title === void 0) {
12
+ throw new Error(
13
+ `harnessed: the stories module for "${name}" has no \`title\` on its default export, so its story id cannot be derived outside Storybook. Fix: set \`title\` on the meta (or \`id\`, if the story id must not follow the title).`
14
+ );
15
+ }
16
+ const params = [`id=${toId(title, storyNameFromExport(name))}`, "viewMode=story"];
17
+ const args = encode(options.args ?? {});
18
+ if (args !== "") params.push(`args=${args}`);
19
+ const globals = encode(options.globals ?? {});
20
+ if (globals !== "") params.push(`globals=${globals}`);
21
+ if (options.play === false) params.push("embed=true");
22
+ return `/iframe.html?${params.join("&")}`;
23
+ }
24
+ function encode(values) {
25
+ return Object.entries(values).map(([key, value]) => `${key}:${encodeValue(key, value)}`).join(";");
26
+ }
27
+ function encodeValue(key, value) {
28
+ if (SAFE_KEY.test(key)) {
29
+ if (value === void 0) return "!undefined";
30
+ if (value === null) return "!null";
31
+ if (typeof value === "boolean") return `!${value}`;
32
+ if (typeof value === "number" && NUMERIC.test(String(value))) return String(value);
33
+ if (typeof value === "string" && SAFE_STRING.test(value) && !NUMERIC.test(value)) {
34
+ return value.replaceAll(" ", "+");
35
+ }
36
+ }
37
+ throw new Error(
38
+ `harnessed: arg \`${key}\` = ${typeof value === "number" ? value : JSON.stringify(value)} cannot travel in a Storybook URL \u2014 Storybook drops anything but letters, digits, spaces, \`_\`, \`-\`, numbers and booleans (and reads a numeric string back as a number), so the story would render without it. Fix: add a story with that state in its \`args\`.`
39
+ );
40
+ }
41
+ var _StoryPage_decorators, _init, _a;
42
+ _StoryPage_decorators = [Harness({ host: testId(STORY_READY_TEST_ID) })];
43
+ var _StoryPage = class _StoryPage extends (_a = PageHarness) {
44
+ #url = "";
45
+ static async goto(env, stories, name, options) {
46
+ const story = new _StoryPage(env);
47
+ story.#url = storyUrl(stories, name, options);
48
+ await story.goto();
49
+ return story;
50
+ }
51
+ get path() {
52
+ return this.#url;
53
+ }
54
+ /** A harness for something in the story, scoped to the whole iframe. */
55
+ harness(HarnessClass) {
56
+ return new HarnessClass(this._env);
57
+ }
58
+ async waitForReady() {
59
+ const deadline = Date.now() + timeoutFor();
60
+ while (await this.self.count() === 0) {
61
+ if (Date.now() > deadline) {
62
+ throw new Error(
63
+ `harnessed: story ${this.#url} did not finish rendering. Either it threw while rendering (open it in Storybook to see why), or \`.storybook/preview.ts\` does not spread \`harnessed()\`.`
64
+ );
65
+ }
66
+ await new Promise((resolve) => setTimeout(resolve, 50));
67
+ }
68
+ const playError = await this.self.attribute(PLAY_ERROR_ATTRIBUTE);
69
+ if (playError !== null) {
70
+ throw new Error(`harnessed: the play function of story ${this.#url} threw: ${playError}`);
71
+ }
72
+ }
73
+ };
74
+ _init = __decoratorStart(_a);
75
+ _StoryPage = __decorateElement(_init, 0, "StoryPage", _StoryPage_decorators, _StoryPage);
76
+ __runInitializers(_init, 1, _StoryPage);
77
+ var StoryPage = _StoryPage;
78
+
79
+ export { StoryPage, storyUrl };
80
+ //# sourceMappingURL=page.js.map
81
+ //# sourceMappingURL=page.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/page.ts"],"names":[],"mappings":"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","file":"page.js","sourcesContent":["import { Harness, testId, timeoutFor } from '@harnessed-ts/core'\nimport type { EnvConfig, ScopedHarness, ScopedHarnessConstructor } from '@harnessed-ts/core'\nimport { PageHarness } from '@harnessed-ts/page'\nimport { storyNameFromExport, toId } from 'storybook/internal/csf'\nimport { PLAY_ERROR_ATTRIBUTE, STORY_READY_TEST_ID } from './ready'\n\n/** A CSF module as imported with `import * as Stories from './X.stories'`. */\nexport interface StoriesModule {\n default: { title?: string; id?: string }\n}\n\n/** The export names in a CSF module that are stories, not the meta. */\nexport type StoryName<M extends StoriesModule> = Exclude<keyof M, 'default'> & string\n\n/** The args a story declares, all optional: a URL overrides some, never must set all. */\nexport type StoryArgs<M extends StoriesModule, K extends StoryName<M>> = Partial<\n ArgsOf<M[K]> & ArgsOf<M['default']>\n>\n\n// An empty record rather than `never` when there are none: a meta without\n// `args` must not erase the story's own.\ntype ArgsOf<T> = T extends { args?: infer A } ? NonNullable<A> : Record<never, never>\n\nexport interface StoryOptions<Args> {\n /**\n * Overrides for the story's args, encoded in Storybook's own URL syntax.\n * Storybook applies one only when the arg has an argType — inferred from the\n * component's props, or declared in `argTypes` — and drops it otherwise.\n */\n args?: Args\n /** Toolbar globals — theme, locale, viewport. */\n globals?: Record<string, Scalar>\n /**\n * Whether the story's play function runs. Defaults to `true`, as it does in\n * Storybook; `false` opens the story in its initial state for the test to drive.\n */\n play?: boolean\n}\n\ntype Scalar = string | number | boolean | null | undefined\n\n// Storybook's own rules (storybook/internal/router): it silently drops any arg\n// that fails them, so a test would run against the wrong state. Refuse instead.\nconst SAFE_KEY = /^[a-zA-Z0-9 _-]+$/\n// Storybook reads a numeric-looking string back as a number, so '02134' would\n// arrive as 2134: letters, digits, spaces, `_` and `-`, but not a number.\nconst SAFE_STRING = /^[a-zA-Z0-9 _-]*$/\nconst NUMERIC = /^-?[0-9]+(\\.[0-9]+)?$/\n\n/**\n * The iframe URL of one story — id from the meta and export name, exactly as\n * Storybook derives it — relative to the Storybook root.\n */\nexport function storyUrl<M extends StoriesModule, K extends StoryName<M>>(\n stories: M,\n name: K,\n options: StoryOptions<StoryArgs<M, K>> = {},\n): string {\n const title = stories.default.id ?? stories.default.title\n if (title === undefined) {\n throw new Error(\n `harnessed: the stories module for \"${name}\" has no \\`title\\` on its default export, ` +\n 'so its story id cannot be derived outside Storybook. Fix: set `title` on the meta ' +\n '(or `id`, if the story id must not follow the title).',\n )\n }\n const params = [`id=${toId(title, storyNameFromExport(name))}`, 'viewMode=story']\n const args = encode(options.args ?? {})\n if (args !== '') params.push(`args=${args}`)\n const globals = encode(options.globals ?? {})\n if (globals !== '') params.push(`globals=${globals}`)\n // The switch Storybook itself reads: an embedded story renders without autoplay.\n if (options.play === false) params.push('embed=true')\n return `/iframe.html?${params.join('&')}`\n}\n\nfunction encode(values: Record<string, unknown>): string {\n return Object.entries(values)\n .map(([key, value]) => `${key}:${encodeValue(key, value)}`)\n .join(';')\n}\n\nfunction encodeValue(key: string, value: unknown): string {\n if (SAFE_KEY.test(key)) {\n if (value === undefined) return '!undefined'\n if (value === null) return '!null'\n if (typeof value === 'boolean') return `!${value}`\n // NaN, Infinity and exponents (`1e+21`, whose `+` reads back as a space) do not survive.\n if (typeof value === 'number' && NUMERIC.test(String(value))) return String(value)\n if (typeof value === 'string' && SAFE_STRING.test(value) && !NUMERIC.test(value)) {\n return value.replaceAll(' ', '+')\n }\n }\n throw new Error(\n `harnessed: arg \\`${key}\\` = ${typeof value === 'number' ? value : JSON.stringify(value)} cannot travel in a Storybook URL — ` +\n 'Storybook drops anything but letters, digits, spaces, `_`, `-`, numbers and booleans ' +\n '(and reads a numeric string back as a number), so the story would render without it. ' +\n 'Fix: add a story with that state in its `args`.',\n )\n}\n\n/**\n * One Storybook story, opened as a fixture by any runner that can navigate.\n *\n * ```ts\n * import * as LoginStories from './LoginForm.stories'\n * const story = await StoryPage.goto(pw(page), LoginStories, 'Default', { play: false })\n * const form = story.harness(LoginFormHarness)\n * ```\n *\n * Ready means rendered *and* played: it waits for the node `harnessed()` adds\n * after the story's play function finishes, so `.storybook/preview.ts` must\n * spread `harnessed()`.\n */\n@Harness({ host: testId(STORY_READY_TEST_ID) })\nexport class StoryPage extends PageHarness {\n #url = ''\n\n static async goto<M extends StoriesModule, K extends StoryName<M>>(\n env: EnvConfig,\n stories: M,\n name: K,\n options?: StoryOptions<StoryArgs<M, K>>,\n ): Promise<StoryPage> {\n const story = new StoryPage(env)\n story.#url = storyUrl(stories, name, options)\n await story.goto()\n return story\n }\n\n override get path(): string {\n return this.#url\n }\n\n /** A harness for something in the story, scoped to the whole iframe. */\n harness<T extends ScopedHarness>(HarnessClass: ScopedHarnessConstructor<T>): T {\n return new HarnessClass(this._env)\n }\n\n protected async waitForReady(): Promise<void> {\n // The marker is an empty node, so \"visible\" means nothing; count it instead.\n const deadline = Date.now() + timeoutFor()\n while ((await this.self.count()) === 0) {\n if (Date.now() > deadline) {\n throw new Error(\n `harnessed: story ${this.#url} did not finish rendering. Either it threw ` +\n 'while rendering (open it in Storybook to see why), or ' +\n '`.storybook/preview.ts` does not spread `harnessed()`.',\n )\n }\n await new Promise(resolve => setTimeout(resolve, 50))\n }\n const playError = await this.self.attribute(PLAY_ERROR_ATTRIBUTE)\n if (playError !== null) {\n throw new Error(`harnessed: the play function of story ${this.#url} threw: ${playError}`)\n }\n }\n}\n"]}
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The test id of the node `harnessed()` adds once a story has rendered and its
3
+ * play function has finished. `StoryPage` waits for it: Storybook's own
4
+ * "rendered" signal is a channel event, which a runner outside the iframe's
5
+ * realm cannot listen for, while a node is something every driver can count.
6
+ */
7
+ export declare const STORY_READY_TEST_ID = "harnessed-story-ready";
8
+ /** Set on that node when the play function threw, holding the error's message. */
9
+ export declare const PLAY_ERROR_ATTRIBUTE = "data-harnessed-play-error";
10
+ //# sourceMappingURL=ready.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ready.d.ts","sourceRoot":"","sources":["../src/ready.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,0BAA0B,CAAA;AAE1D,kFAAkF;AAClF,eAAO,MAAM,oBAAoB,8BAA8B,CAAA"}
@@ -0,0 +1,27 @@
1
+ import { ScopedHarness } from '@harnessed-ts/core';
2
+ /** Storybook's `context.step`, narrowed to what the proxy needs. */
3
+ export type StepRunner = (label: string, play: () => Promise<void>) => Promise<unknown> | unknown;
4
+ /**
5
+ * The harness, with every public async method run inside `step()` — so the
6
+ * Interactions panel reads `LoginFormHarness.signInAs("ada@…", "•••")` with the
7
+ * raw clicks and keystrokes nested under it, rather than a flat list of events.
8
+ *
9
+ * Methods run against the real instance, not the proxy: a harness calling its
10
+ * own methods does not log a step per call, and decorated `accessor` fields
11
+ * (backed by private slots a proxy cannot reach) keep working. Sync methods stay
12
+ * sync; a harness they return — `nth()`, a child — comes back wrapped too.
13
+ *
14
+ * shortcut: async is detected by `AsyncFunction`, so a plain method that returns
15
+ * a promise, or one down-levelled to a generator, logs no step. Upgrade if a
16
+ * harness or a build ever needs that.
17
+ */
18
+ export declare function withSteps<T extends ScopedHarness>(harness: T, step: StepRunner): T;
19
+ /**
20
+ * `Harness.method(args…)`, each argument shortened, and any argument whose
21
+ * parameter name — or object key — looks like a secret replaced with `•••`.
22
+ *
23
+ * Names are read from the source, so a minified `storybook build` shows mangled
24
+ * class names and masks only object keys, not positional arguments.
25
+ */
26
+ export declare function stepLabel(className: string, method: (...args: never[]) => unknown, args: readonly unknown[], name?: string): string;
27
+ //# sourceMappingURL=steps.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"steps.d.ts","sourceRoot":"","sources":["../src/steps.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAElD,oEAAoE;AACpE,MAAM,MAAM,UAAU,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,KAAK,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,CAAA;AAcjG;;;;;;;;;;;;;GAaG;AACH,wBAAgB,SAAS,CAAC,CAAC,SAAS,aAAa,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,UAAU,GAAG,CAAC,CAqClF;AAED;;;;;;GAMG;AACH,wBAAgB,SAAS,CACvB,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,CAAC,GAAG,IAAI,EAAE,KAAK,EAAE,KAAK,OAAO,EACrC,IAAI,EAAE,SAAS,OAAO,EAAE,EACxB,IAAI,GAAE,MAAoB,GACzB,MAAM,CAMR"}
package/package.json CHANGED
@@ -1,6 +1,81 @@
1
1
  {
2
2
  "name": "@harnessed-ts/storybook",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.6.0",
4
+ "description": "Harnesses in Storybook: drive play() functions through a harness, and open stories as fixtures from any runner that can navigate.",
5
+ "keywords": [
6
+ "testing",
7
+ "harness",
8
+ "storybook",
9
+ "play-function",
10
+ "interactions"
11
+ ],
12
+ "license": "MIT",
13
+ "contributors": [
14
+ {
15
+ "name": "Joe Gaudet",
16
+ "url": "https://github.com/joegaudet"
17
+ },
18
+ {
19
+ "name": "Jay Seo",
20
+ "url": "https://github.com/jayseo5953"
21
+ }
22
+ ],
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/joegaudet/harnessed.git",
26
+ "directory": "packages/storybook"
27
+ },
28
+ "homepage": "https://github.com/joegaudet/harnessed#readme",
29
+ "bugs": "https://github.com/joegaudet/harnessed/issues",
30
+ "type": "module",
31
+ "main": "./dist/index.cjs",
32
+ "module": "./dist/index.js",
33
+ "types": "./dist/index.d.ts",
34
+ "exports": {
35
+ ".": {
36
+ "types": "./dist/index.d.ts",
37
+ "import": "./dist/index.js",
38
+ "require": "./dist/index.cjs"
39
+ },
40
+ "./page": {
41
+ "types": "./dist/page.d.ts",
42
+ "import": "./dist/page.js",
43
+ "require": "./dist/page.cjs"
44
+ },
45
+ "./package.json": "./package.json"
46
+ },
47
+ "files": [
48
+ "dist",
49
+ "CHANGELOG.md",
50
+ "README.md",
51
+ "LICENSE"
52
+ ],
53
+ "engines": {
54
+ "node": ">=22.12.0"
55
+ },
56
+ "dependencies": {
57
+ "@harnessed-ts/core": "^0.6.0",
58
+ "@harnessed-ts/dom": "^0.6.0",
59
+ "@harnessed-ts/page": "^0.6.0"
60
+ },
61
+ "peerDependencies": {
62
+ "@testing-library/dom": ">=10",
63
+ "@testing-library/user-event": ">=14",
64
+ "storybook": ">=9"
65
+ },
66
+ "publishConfig": {
67
+ "access": "public"
68
+ },
69
+ "devDependencies": {
70
+ "@testing-library/dom": "^10.4.2",
71
+ "@testing-library/user-event": "^14.6.7",
72
+ "jsdom": "^30.1.2",
73
+ "storybook": "10.6.1",
74
+ "vitest": "^5.0.3"
75
+ },
76
+ "scripts": {
77
+ "build": "tsup",
78
+ "clean": "rm -rf dist *.tsbuildinfo",
79
+ "test": "vitest run"
80
+ }
6
81
  }