@harnessed-ts/storybook 0.6.0 → 0.7.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.
@@ -0,0 +1,77 @@
1
+ import type { Args, ComponentAnnotations, ComposedStoryFn, NamedOrDefaultProjectAnnotations, NormalizedProjectAnnotations, ProjectAnnotations, RenderContext, Store_CSFExports, StoriesWithPartialProps, StoryAnnotations, StoryAnnotationsOrFn, StoryContext, WebRenderer } from 'storybook/internal/types';
2
+ import type { HarnessedContext } from './index';
3
+ /**
4
+ * Storybook's renderer type for Ember: a story's `component`, and what its
5
+ * `render` returns, is a component — a Glimmer class or a `<template>`.
6
+ */
7
+ export interface EmberRenderer extends WebRenderer {
8
+ component: object;
9
+ storyResult: object;
10
+ }
11
+ /** A CSF meta for an Ember component. */
12
+ export type Meta<TArgs = Args> = ComponentAnnotations<EmberRenderer, TArgs>;
13
+ /** A CSF story for an Ember component. */
14
+ export type StoryObj<TArgs = Args> = StoryAnnotations<EmberRenderer, TArgs>;
15
+ /** The `.storybook/preview.ts` default export. */
16
+ export type Preview = ProjectAnnotations<EmberRenderer>;
17
+ /** What a story can set under `parameters.ember`. */
18
+ export interface EmberParameters {
19
+ /**
20
+ * The owner the story renders with, for components that inject services. A
21
+ * function is called once per render. Without one, Ember's default applies:
22
+ * an empty object, enough for components that look nothing up.
23
+ */
24
+ owner?: object | (() => object);
25
+ }
26
+ /**
27
+ * Resolves once Ember is settled: rendering done, no run loop or timers left,
28
+ * and no test waiter pending. Rejects if it is still busy after `timeout`.
29
+ */
30
+ export declare function settled(timeout?: number): Promise<void>;
31
+ /**
32
+ * `harnessed()` for Ember stories: every interaction a harness makes resolves
33
+ * once Ember has settled, as it does under `@harnessed-ts/ember`, so the next
34
+ * read sees the re-rendered canvas.
35
+ *
36
+ * ```ts
37
+ * import { emberRenderer, harnessed } from '@harnessed-ts/storybook/ember'
38
+ * export default { ...emberRenderer, ...harnessed() } satisfies Preview
39
+ * ```
40
+ */
41
+ export declare function harnessed(): {
42
+ beforeEach: (context: HarnessedContext) => Promise<void>;
43
+ afterEach: (context: HarnessedContext) => Promise<void>;
44
+ };
45
+ /** The default `render`: the meta's `component`, with the story's args. */
46
+ export declare function render(_args: Args, context: StoryContext<EmberRenderer>): object;
47
+ /**
48
+ * Renders the story's component into the canvas with Ember's public
49
+ * `renderComponent`, passing the story's args. Every render starts fresh, so
50
+ * changing an arg in the Controls panel remounts the component.
51
+ */
52
+ export declare function renderToCanvas({ storyContext, unboundStoryFn, showMain }: RenderContext<EmberRenderer>, canvasElement: HTMLElement): Promise<() => void>;
53
+ /** Storybook's `mount`: renders the story, and resolves with its canvas. */
54
+ export declare function mount(context: StoryContext<EmberRenderer>): () => Promise<StoryContext<EmberRenderer>["canvas"]>;
55
+ /**
56
+ * The renderer, as preview annotations. A Storybook built with a renderer
57
+ * that is not Ember's — `@storybook/html-vite` — spreads these into
58
+ * `.storybook/preview.ts`, and its stories render as Ember components.
59
+ */
60
+ export declare const emberRenderer: {
61
+ render: typeof render;
62
+ renderToCanvas: typeof renderToCanvas;
63
+ mount: typeof mount;
64
+ parameters: {
65
+ renderer: string;
66
+ };
67
+ };
68
+ /**
69
+ * Portable stories: applies `.storybook/preview.ts` to every story composed
70
+ * after it. Call once, in a Vitest setup file or at the top of a test.
71
+ */
72
+ export declare function setProjectAnnotations(projectAnnotations: NamedOrDefaultProjectAnnotations<EmberRenderer> | NamedOrDefaultProjectAnnotations<EmberRenderer>[]): NormalizedProjectAnnotations<EmberRenderer>;
73
+ /** One story, composed with its meta and the project's annotations: `await story.run()` renders it. */
74
+ export declare function composeStory<TArgs extends Args = Args>(story: StoryAnnotationsOrFn<EmberRenderer, TArgs>, componentAnnotations: Meta<TArgs>, projectAnnotations?: ProjectAnnotations<EmberRenderer>, exportsName?: string): ComposedStoryFn<EmberRenderer, Partial<TArgs>>;
75
+ /** Every story in a CSF module, composed: `const stories = composeStories(LoginStories)`. */
76
+ export declare function composeStories<TModule extends Store_CSFExports<EmberRenderer, any>>(csfExports: TModule, projectAnnotations?: ProjectAnnotations<EmberRenderer>): Omit<StoriesWithPartialProps<EmberRenderer, TModule>, keyof Store_CSFExports>;
77
+ //# sourceMappingURL=ember.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ember.d.ts","sourceRoot":"","sources":["../src/ember.ts"],"names":[],"mappings":"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"}
package/dist/ember.js ADDED
@@ -0,0 +1,99 @@
1
+ import { harnessed } from './chunk-XYTGUTWH.js';
2
+ import './chunk-EBSULTWQ.js';
3
+ import './chunk-CGZBV6BD.js';
4
+ import { renderComponent, renderSettled } from '@ember/renderer';
5
+ import { _backburner } from '@ember/runloop';
6
+ import { hasPendingWaiters } from '@ember/test-waiters';
7
+ import { timeoutFor } from '@harnessed-ts/core';
8
+ import { composeConfigs, setDefaultProjectAnnotations, setProjectAnnotations as setProjectAnnotations$1, composeStory as composeStory$1, composeStories as composeStories$1 } from 'storybook/preview-api';
9
+
10
+ function busy() {
11
+ return Boolean(_backburner.currentInstance) || _backburner.hasTimers() || hasPendingWaiters();
12
+ }
13
+ async function settled(timeout = timeoutFor()) {
14
+ const deadline = Date.now() + timeout;
15
+ for (; ; ) {
16
+ await renderSettled();
17
+ if (!busy()) return;
18
+ if (Date.now() > deadline) {
19
+ throw new Error(
20
+ `Ember did not settle within ${timeout}ms: a timer or test waiter is still pending`
21
+ );
22
+ }
23
+ await new Promise((resolve) => setTimeout(resolve, 10));
24
+ }
25
+ }
26
+ function harnessed2() {
27
+ return harnessed({ afterAction: () => settled() });
28
+ }
29
+ var mounted = /* @__PURE__ */ new WeakMap();
30
+ function unmount(canvasElement) {
31
+ mounted.get(canvasElement)?.destroy();
32
+ mounted.delete(canvasElement);
33
+ }
34
+ function ownerFor(parameters) {
35
+ const owner = parameters.ember?.owner;
36
+ return typeof owner === "function" ? owner() : owner;
37
+ }
38
+ function render(_args, context) {
39
+ if (context.component === void 0) {
40
+ throw new Error(
41
+ `Unable to render story ${context.id}: the meta names no \`component\` and the story has no \`render\``
42
+ );
43
+ }
44
+ return context.component;
45
+ }
46
+ async function renderToCanvas({ storyContext, unboundStoryFn, showMain }, canvasElement) {
47
+ const component = unboundStoryFn(storyContext);
48
+ unmount(canvasElement);
49
+ canvasElement.replaceChildren();
50
+ const owner = ownerFor(storyContext.parameters);
51
+ mounted.set(
52
+ canvasElement,
53
+ renderComponent(component, {
54
+ into: canvasElement,
55
+ args: { ...storyContext.args },
56
+ ...owner === void 0 ? {} : { owner }
57
+ })
58
+ );
59
+ await renderSettled();
60
+ showMain();
61
+ return () => unmount(canvasElement);
62
+ }
63
+ function mount(context) {
64
+ return async () => {
65
+ await context.renderToCanvas();
66
+ return context.canvas;
67
+ };
68
+ }
69
+ var emberRenderer = {
70
+ render,
71
+ renderToCanvas,
72
+ mount,
73
+ parameters: { renderer: "ember" }
74
+ };
75
+ var DEFAULT_ANNOTATIONS = composeConfigs([emberRenderer]);
76
+ function setProjectAnnotations(projectAnnotations) {
77
+ setDefaultProjectAnnotations(DEFAULT_ANNOTATIONS);
78
+ return setProjectAnnotations$1(
79
+ projectAnnotations
80
+ );
81
+ }
82
+ function composeStory(story, componentAnnotations, projectAnnotations, exportsName) {
83
+ return composeStory$1(
84
+ // Storybook's own signature takes untyped args; the typed ones are a subtype.
85
+ story,
86
+ componentAnnotations,
87
+ projectAnnotations,
88
+ globalThis.globalProjectAnnotations ?? DEFAULT_ANNOTATIONS,
89
+ exportsName
90
+ );
91
+ }
92
+ function composeStories(csfExports, projectAnnotations) {
93
+ const project = projectAnnotations;
94
+ return composeStories$1(csfExports, project, composeStory);
95
+ }
96
+
97
+ export { composeStories, composeStory, emberRenderer, harnessed2 as harnessed, mount, render, renderToCanvas, setProjectAnnotations, settled };
98
+ //# sourceMappingURL=ember.js.map
99
+ //# sourceMappingURL=ember.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/ember.ts"],"names":["harnessed","originalSetProjectAnnotations","originalComposeStory","originalComposeStories"],"mappings":"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","file":"ember.js","sourcesContent":["/// <reference types=\"ember-source/types\" />\nimport { renderComponent, renderSettled } from '@ember/renderer'\nimport { _backburner } from '@ember/runloop'\nimport { hasPendingWaiters } from '@ember/test-waiters'\nimport { timeoutFor } from '@harnessed-ts/core'\nimport {\n composeConfigs,\n composeStories as originalComposeStories,\n composeStory as originalComposeStory,\n setDefaultProjectAnnotations,\n setProjectAnnotations as originalSetProjectAnnotations,\n} from 'storybook/preview-api'\nimport type {\n Args,\n ComponentAnnotations,\n ComposedStoryFn,\n NamedOrDefaultProjectAnnotations,\n NormalizedProjectAnnotations,\n ProjectAnnotations,\n RenderContext,\n Store_CSFExports,\n StoriesWithPartialProps,\n StoryAnnotations,\n StoryAnnotationsOrFn,\n StoryContext,\n WebRenderer,\n} from 'storybook/internal/types'\nimport { harnessed as harnessedFor } from './index'\nimport type { HarnessedContext } from './index'\n\n/**\n * Storybook's renderer type for Ember: a story's `component`, and what its\n * `render` returns, is a component — a Glimmer class or a `<template>`.\n */\nexport interface EmberRenderer extends WebRenderer {\n component: object\n storyResult: object\n}\n\n/** A CSF meta for an Ember component. */\nexport type Meta<TArgs = Args> = ComponentAnnotations<EmberRenderer, TArgs>\n\n/** A CSF story for an Ember component. */\nexport type StoryObj<TArgs = Args> = StoryAnnotations<EmberRenderer, TArgs>\n\n/** The `.storybook/preview.ts` default export. */\nexport type Preview = ProjectAnnotations<EmberRenderer>\n\n/** What a story can set under `parameters.ember`. */\nexport interface EmberParameters {\n /**\n * The owner the story renders with, for components that inject services. A\n * function is called once per render. Without one, Ember's default applies:\n * an empty object, enough for components that look nothing up.\n */\n owner?: object | (() => object)\n}\n\n/**\n * Whether Ember has work in flight: a run loop, a scheduled timer, a render,\n * or a test waiter (an `@ember/test-waiters` waiter, as a data layer or\n * `ember-concurrency` registers). The same signals `settled()` in\n * `@ember/test-helpers` reads, without its test context.\n */\nfunction busy(): boolean {\n return Boolean(_backburner.currentInstance) || _backburner.hasTimers() || hasPendingWaiters()\n}\n\n/**\n * Resolves once Ember is settled: rendering done, no run loop or timers left,\n * and no test waiter pending. Rejects if it is still busy after `timeout`.\n */\nexport async function settled(timeout = timeoutFor()): Promise<void> {\n const deadline = Date.now() + timeout\n for (;;) {\n await renderSettled()\n if (!busy()) return\n if (Date.now() > deadline) {\n throw new Error(\n `Ember did not settle within ${timeout}ms: a timer or test waiter is still pending`,\n )\n }\n await new Promise(resolve => setTimeout(resolve, 10))\n }\n}\n\n/**\n * `harnessed()` for Ember stories: every interaction a harness makes resolves\n * once Ember has settled, as it does under `@harnessed-ts/ember`, so the next\n * read sees the re-rendered canvas.\n *\n * ```ts\n * import { emberRenderer, harnessed } from '@harnessed-ts/storybook/ember'\n * export default { ...emberRenderer, ...harnessed() } satisfies Preview\n * ```\n */\nexport function harnessed(): {\n beforeEach: (context: HarnessedContext) => Promise<void>\n afterEach: (context: HarnessedContext) => Promise<void>\n} {\n return harnessedFor({ afterAction: () => settled() })\n}\n\n/** What each canvas has on it, so the next render can tear it down first. */\nconst mounted = new WeakMap<HTMLElement, { destroy(): void }>()\n\nfunction unmount(canvasElement: HTMLElement): void {\n mounted.get(canvasElement)?.destroy()\n mounted.delete(canvasElement)\n}\n\nfunction ownerFor(parameters: { ember?: EmberParameters }): object | undefined {\n const owner = parameters.ember?.owner\n return typeof owner === 'function' ? owner() : owner\n}\n\n/** The default `render`: the meta's `component`, with the story's args. */\nexport function render(_args: Args, context: StoryContext<EmberRenderer>): object {\n if (context.component === undefined) {\n throw new Error(\n `Unable to render story ${context.id}: the meta names no \\`component\\` and the story has no \\`render\\``,\n )\n }\n return context.component\n}\n\n/**\n * Renders the story's component into the canvas with Ember's public\n * `renderComponent`, passing the story's args. Every render starts fresh, so\n * changing an arg in the Controls panel remounts the component.\n */\nexport async function renderToCanvas(\n { storyContext, unboundStoryFn, showMain }: RenderContext<EmberRenderer>,\n canvasElement: HTMLElement,\n): Promise<() => void> {\n const component = unboundStoryFn(storyContext)\n unmount(canvasElement)\n canvasElement.replaceChildren()\n const owner = ownerFor(storyContext.parameters)\n mounted.set(\n canvasElement,\n renderComponent(component, {\n into: canvasElement,\n args: { ...storyContext.args },\n ...(owner === undefined ? {} : { owner }),\n }),\n )\n await renderSettled()\n showMain()\n return () => unmount(canvasElement)\n}\n\n/** Storybook's `mount`: renders the story, and resolves with its canvas. */\nexport function mount(context: StoryContext<EmberRenderer>) {\n return async (): Promise<StoryContext<EmberRenderer>['canvas']> => {\n await context.renderToCanvas()\n return context.canvas\n }\n}\n\n/**\n * The renderer, as preview annotations. A Storybook built with a renderer\n * that is not Ember's — `@storybook/html-vite` — spreads these into\n * `.storybook/preview.ts`, and its stories render as Ember components.\n */\nexport const emberRenderer = {\n render,\n renderToCanvas,\n mount,\n parameters: { renderer: 'ember' },\n} satisfies ProjectAnnotations<EmberRenderer>\n\nconst DEFAULT_ANNOTATIONS: ProjectAnnotations<EmberRenderer> = composeConfigs([emberRenderer])\n\n/**\n * Portable stories: applies `.storybook/preview.ts` to every story composed\n * after it. Call once, in a Vitest setup file or at the top of a test.\n */\nexport function setProjectAnnotations(\n projectAnnotations:\n | NamedOrDefaultProjectAnnotations<EmberRenderer>\n | NamedOrDefaultProjectAnnotations<EmberRenderer>[],\n): NormalizedProjectAnnotations<EmberRenderer> {\n setDefaultProjectAnnotations(DEFAULT_ANNOTATIONS)\n return originalSetProjectAnnotations(\n projectAnnotations,\n ) as NormalizedProjectAnnotations<EmberRenderer>\n}\n\n/** One story, composed with its meta and the project's annotations: `await story.run()` renders it. */\nexport function composeStory<TArgs extends Args = Args>(\n story: StoryAnnotationsOrFn<EmberRenderer, TArgs>,\n componentAnnotations: Meta<TArgs>,\n projectAnnotations?: ProjectAnnotations<EmberRenderer>,\n exportsName?: string,\n): ComposedStoryFn<EmberRenderer, Partial<TArgs>> {\n return originalComposeStory(\n // Storybook's own signature takes untyped args; the typed ones are a subtype.\n story as StoryAnnotationsOrFn<EmberRenderer>,\n componentAnnotations as Meta,\n projectAnnotations,\n (globalThis as { globalProjectAnnotations?: ProjectAnnotations<EmberRenderer> })\n .globalProjectAnnotations ?? DEFAULT_ANNOTATIONS,\n exportsName,\n ) as ComposedStoryFn<EmberRenderer, Partial<TArgs>>\n}\n\n/** Every story in a CSF module, composed: `const stories = composeStories(LoginStories)`. */\n// `any` args, as in Storybook's own renderers: a module whose stories take typed\n// args is not assignable to one whose stories take `Args`.\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport function composeStories<TModule extends Store_CSFExports<EmberRenderer, any>>(\n csfExports: TModule,\n projectAnnotations?: ProjectAnnotations<EmberRenderer>,\n): Omit<StoriesWithPartialProps<EmberRenderer, TModule>, keyof Store_CSFExports> {\n // Storybook passes it through to `composeStory`, where `undefined` is allowed.\n const project = projectAnnotations as ProjectAnnotations<EmberRenderer>\n return originalComposeStories(csfExports, project, composeStory) as Omit<\n StoriesWithPartialProps<EmberRenderer, TModule>,\n keyof Store_CSFExports\n >\n}\n"]}
package/dist/index.cjs CHANGED
@@ -91,7 +91,7 @@ function listenForPlayErrors() {
91
91
  });
92
92
  listening = true;
93
93
  }
94
- function harnessed() {
94
+ function harnessed(options = {}) {
95
95
  if (!matchersRegistered) {
96
96
  test.expect.extend(core.harnessMatchers);
97
97
  matchersRegistered = true;
@@ -107,7 +107,8 @@ function harnessed() {
107
107
  // Storybook's own instance, so each keystroke shows up in the panel. It
108
108
  // is only set where a clipboard exists; fall back to a plain one.
109
109
  user: hasClick(context.userEvent) ? context.userEvent : test.userEvent.setup(),
110
- container: context.canvasElement
110
+ container: context.canvasElement,
111
+ ...options.afterAction === void 0 ? {} : { afterAction: options.afterAction }
111
112
  });
112
113
  context.harness = withSteps(new HarnessClass(env), context.step);
113
114
  },
@@ -1 +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"]}
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\nexport interface HarnessedOptions {\n /**\n * Awaited after every interaction a harness makes, before the next step reads\n * the canvas. A renderer that updates the DOM asynchronously settles here;\n * `@harnessed-ts/storybook/ember` passes Ember's.\n */\n afterAction?: () => Promise<void>\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(options: HarnessedOptions = {}): {\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 ...(options.afterAction === undefined ? {} : { afterAction: options.afterAction }),\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/index.d.ts CHANGED
@@ -13,6 +13,14 @@ export interface HarnessedContext {
13
13
  step: StepRunner;
14
14
  harness?: ScopedHarness;
15
15
  }
16
+ export interface HarnessedOptions {
17
+ /**
18
+ * Awaited after every interaction a harness makes, before the next step reads
19
+ * the canvas. A renderer that updates the DOM asynchronously settles here;
20
+ * `@harnessed-ts/storybook/ember` passes Ember's.
21
+ */
22
+ afterAction?: () => Promise<void>;
23
+ }
16
24
  /**
17
25
  * Preview annotations that put a harness in every play function. Spread them
18
26
  * into `.storybook/preview.ts`:
@@ -27,7 +35,7 @@ export interface HarnessedContext {
27
35
  * instrumented `userEvent` with each method logged as a step. The harness
28
36
  * matchers are added to `storybook/test`'s `expect`.
29
37
  */
30
- export declare function harnessed(): {
38
+ export declare function harnessed(options?: HarnessedOptions): {
31
39
  beforeEach: (context: HarnessedContext) => Promise<void>;
32
40
  afterEach: (context: HarnessedContext) => Promise<void>;
33
41
  };
@@ -1 +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"}
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;AAED,MAAM,WAAW,gBAAgB;IAC/B;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAClC;AAoBD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,SAAS,CAAC,OAAO,GAAE,gBAAqB,GAAG;IACzD,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,CAmCA;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 CHANGED
@@ -1,134 +1,5 @@
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 };
1
+ export { harnessed, stepLabel, withSteps } from './chunk-XYTGUTWH.js';
2
+ export { PLAY_ERROR_ATTRIBUTE, STORY_READY_TEST_ID } from './chunk-EBSULTWQ.js';
3
+ import './chunk-CGZBV6BD.js';
133
4
  //# sourceMappingURL=index.js.map
134
5
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +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"]}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"index.js"}
package/dist/page.cjs CHANGED
@@ -59,6 +59,9 @@ function storyUrl(stories, name, options = {}) {
59
59
  if (options.play === false) params.push("embed=true");
60
60
  return `/iframe.html?${params.join("&")}`;
61
61
  }
62
+ function storiesModule(meta) {
63
+ return { default: meta };
64
+ }
62
65
  function encode(values) {
63
66
  return Object.entries(values).map(([key, value]) => `${key}:${encodeValue(key, value)}`).join(";");
64
67
  }
@@ -115,6 +118,7 @@ __runInitializers(_init, 1, _StoryPage);
115
118
  var StoryPage = _StoryPage;
116
119
 
117
120
  exports.StoryPage = StoryPage;
121
+ exports.storiesModule = storiesModule;
118
122
  exports.storyUrl = storyUrl;
119
123
  //# sourceMappingURL=page.cjs.map
120
124
  //# sourceMappingURL=page.cjs.map
package/dist/page.cjs.map CHANGED
@@ -1 +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"]}
1
+ {"version":3,"sources":["../src/ready.ts","../src/page.ts"],"names":["toId","storyNameFromExport","Harness","testId","PageHarness","timeoutFor"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAMO,IAAM,mBAAA,GAAsB,uBAAA;AAG5B,IAAM,oBAAA,GAAuB,2BAAA;;;ACkCpC,IAAM,QAAA,GAAW,mBAAA;AAGjB,IAAM,WAAA,GAAc,mBAAA;AACpB,IAAM,OAAA,GAAU,uBAAA;AAMT,SAAS,QAAA,CACd,OAAA,EACA,IAAA,EACA,OAAA,GAAyC,EAAC,EAClC;AACR,EAAA,MAAM,KAAA,GAAQ,OAAA,CAAQ,OAAA,CAAQ,EAAA,IAAM,QAAQ,OAAA,CAAQ,KAAA;AACpD,EAAA,IAAI,UAAU,MAAA,EAAW;AACvB,IAAA,MAAM,IAAI,KAAA;AAAA,MACR,sCAAsC,IAAI,CAAA,qLAAA;AAAA,KAG5C;AAAA,EACF;AACA,EAAA,MAAM,MAAA,GAAS,CAAC,CAAA,GAAA,EAAMA,QAAA,CAAK,KAAA,EAAOC,wBAAoB,IAAI,CAAC,CAAC,CAAA,CAAA,EAAI,gBAAgB,CAAA;AAChF,EAAA,MAAM,IAAA,GAAO,MAAA,CAAO,OAAA,CAAQ,IAAA,IAAQ,EAAE,CAAA;AACtC,EAAA,IAAI,SAAS,EAAA,EAAI,MAAA,CAAO,IAAA,CAAK,CAAA,KAAA,EAAQ,IAAI,CAAA,CAAE,CAAA;AAC3C,EAAA,MAAM,OAAA,GAAU,MAAA,CAAO,OAAA,CAAQ,OAAA,IAAW,EAAE,CAAA;AAC5C,EAAA,IAAI,YAAY,EAAA,EAAI,MAAA,CAAO,IAAA,CAAK,CAAA,QAAA,EAAW,OAAO,CAAA,CAAE,CAAA;AAEpD,EAAA,IAAI,OAAA,CAAQ,IAAA,KAAS,KAAA,EAAO,MAAA,CAAO,KAAK,YAAY,CAAA;AACpD,EAAA,OAAO,CAAA,aAAA,EAAgB,MAAA,CAAO,IAAA,CAAK,GAAG,CAAC,CAAA,CAAA;AACzC;AAcO,SAAS,cAAuC,IAAA,EAAmC;AACxF,EAAA,OAAO,EAAE,SAAS,IAAA,EAAK;AACzB;AAEA,SAAS,OAAO,MAAA,EAAyC;AACvD,EAAA,OAAO,MAAA,CAAO,QAAQ,MAAM,CAAA,CACzB,IAAI,CAAC,CAAC,KAAK,KAAK,CAAA,KAAM,GAAG,GAAG,CAAA,CAAA,EAAI,YAAY,GAAA,EAAK,KAAK,CAAC,CAAA,CAAE,CAAA,CACzD,KAAK,GAAG,CAAA;AACb;AAEA,SAAS,WAAA,CAAY,KAAa,KAAA,EAAwB;AACxD,EAAA,IAAI,QAAA,CAAS,IAAA,CAAK,GAAG,CAAA,EAAG;AACtB,IAAA,IAAI,KAAA,KAAU,QAAW,OAAO,YAAA;AAChC,IAAA,IAAI,KAAA,KAAU,MAAM,OAAO,OAAA;AAC3B,IAAA,IAAI,OAAO,KAAA,KAAU,SAAA,EAAW,OAAO,IAAI,KAAK,CAAA,CAAA;AAEhD,IAAA,IAAI,OAAO,KAAA,KAAU,QAAA,IAAY,OAAA,CAAQ,IAAA,CAAK,MAAA,CAAO,KAAK,CAAC,CAAA,EAAG,OAAO,MAAA,CAAO,KAAK,CAAA;AACjF,IAAA,IAAI,OAAO,KAAA,KAAU,QAAA,IAAY,WAAA,CAAY,IAAA,CAAK,KAAK,CAAA,IAAK,CAAC,OAAA,CAAQ,IAAA,CAAK,KAAK,CAAA,EAAG;AAChF,MAAA,OAAO,KAAA,CAAM,UAAA,CAAW,GAAA,EAAK,GAAG,CAAA;AAAA,IAClC;AAAA,EACF;AACA,EAAA,MAAM,IAAI,KAAA;AAAA,IACR,CAAA,iBAAA,EAAoB,GAAG,CAAA,KAAA,EAAQ,OAAO,KAAA,KAAU,WAAW,KAAA,GAAQ,IAAA,CAAK,SAAA,CAAU,KAAK,CAAC,CAAA,wQAAA;AAAA,GAI1F;AACF;AAnHA,IAAA,qBAAA,EAAA,KAAA,EAAA,EAAA;AAkIA,qBAAA,GAAA,CAACC,aAAQ,EAAE,IAAA,EAAMC,WAAA,CAAO,mBAAmB,GAAG,CAAA,CAAA;AACvC,IAAM,UAAA,GAAN,MAAM,UAAA,UAAkB,EAAA,GAAAC,gBAAA,EAAY;AAAA,EACzC,IAAA,GAAO,EAAA;AAAA,EAEP,aAAa,IAAA,CACX,GAAA,EACA,OAAA,EACA,MACA,OAAA,EACoB;AACpB,IAAA,MAAM,KAAA,GAAQ,IAAI,UAAA,CAAU,GAAG,CAAA;AAC/B,IAAA,KAAA,CAAM,IAAA,GAAO,QAAA,CAAS,OAAA,EAAS,IAAA,EAAM,OAAO,CAAA;AAC5C,IAAA,MAAM,MAAM,IAAA,EAAK;AACjB,IAAA,OAAO,KAAA;AAAA,EACT;AAAA,EAEA,IAAa,IAAA,GAAe;AAC1B,IAAA,OAAO,IAAA,CAAK,IAAA;AAAA,EACd;AAAA;AAAA,EAGA,QAAiC,YAAA,EAA8C;AAC7E,IAAA,OAAO,IAAI,YAAA,CAAa,IAAA,CAAK,IAAI,CAAA;AAAA,EACnC;AAAA,EAEA,MAAgB,YAAA,GAA8B;AAE5C,IAAA,MAAM,QAAA,GAAW,IAAA,CAAK,GAAA,EAAI,GAAIC,eAAA,EAAW;AACzC,IAAA,OAAQ,MAAM,IAAA,CAAK,IAAA,CAAK,KAAA,OAAa,CAAA,EAAG;AACtC,MAAA,IAAI,IAAA,CAAK,GAAA,EAAI,GAAI,QAAA,EAAU;AACzB,QAAA,MAAM,IAAI,KAAA;AAAA,UACR,CAAA,iBAAA,EAAoB,KAAK,IAAI,CAAA,2JAAA;AAAA,SAG/B;AAAA,MACF;AACA,MAAA,MAAM,IAAI,OAAA,CAAQ,CAAA,OAAA,KAAW,UAAA,CAAW,OAAA,EAAS,EAAE,CAAC,CAAA;AAAA,IACtD;AACA,IAAA,MAAM,SAAA,GAAY,MAAM,IAAA,CAAK,IAAA,CAAK,UAAU,oBAAoB,CAAA;AAChE,IAAA,IAAI,cAAc,IAAA,EAAM;AACtB,MAAA,MAAM,IAAI,KAAA,CAAM,CAAA,sCAAA,EAAyC,KAAK,IAAI,CAAA,QAAA,EAAW,SAAS,CAAA,CAAE,CAAA;AAAA,IAC1F;AAAA,EACF;AACF,CAAA;AA1CO,KAAA,GAAA,gBAAA,CAAA,EAAA,CAAA;AAAM,UAAA,GAAN,yCADP,qBAAA,EACa,UAAA,CAAA;AAAN,iBAAA,CAAA,KAAA,EAAA,CAAA,EAAM,UAAA,CAAA;AAAN,IAAM,SAAA,GAAN","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\n/**\n * A stand-in for a stories module the runner cannot import, typed as that\n * module: only its meta's `title` (or `id`), which is all `StoryPage` reads.\n * An Ember `.gts` stories file, or one importing a component, needs Ember's\n * build to load; Playwright's Node process has neither.\n *\n * ```ts\n * import type * as LoginStories from './LoginForm.stories.gts'\n * const Login = storiesModule<typeof LoginStories>({ title: 'Forms/Login Form' })\n * await StoryPage.goto(pw(page), Login, 'Submitted')\n * ```\n */\nexport function storiesModule<M extends StoriesModule>(meta: StoriesModule['default']): M {\n return { default: meta } as M\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 CHANGED
@@ -35,6 +35,19 @@ type Scalar = string | number | boolean | null | undefined;
35
35
  * Storybook derives it — relative to the Storybook root.
36
36
  */
37
37
  export declare function storyUrl<M extends StoriesModule, K extends StoryName<M>>(stories: M, name: K, options?: StoryOptions<StoryArgs<M, K>>): string;
38
+ /**
39
+ * A stand-in for a stories module the runner cannot import, typed as that
40
+ * module: only its meta's `title` (or `id`), which is all `StoryPage` reads.
41
+ * An Ember `.gts` stories file, or one importing a component, needs Ember's
42
+ * build to load; Playwright's Node process has neither.
43
+ *
44
+ * ```ts
45
+ * import type * as LoginStories from './LoginForm.stories.gts'
46
+ * const Login = storiesModule<typeof LoginStories>({ title: 'Forms/Login Form' })
47
+ * await StoryPage.goto(pw(page), Login, 'Submitted')
48
+ * ```
49
+ */
50
+ export declare function storiesModule<M extends StoriesModule>(meta: StoriesModule['default']): M;
38
51
  /**
39
52
  * One Storybook story, opened as a fixture by any runner that can navigate.
40
53
  *