UNPKG

storybook

Version:

Storybook: Develop, document, and test UI components in isolation

624 lines (620 loc) • 22.7 kB
import { CallStates, EVENTS } from "../_browser-chunks/chunk-SN4J4IQ3.js"; import { processError } from "../_browser-chunks/chunk-JVSKG4YS.js"; import { __name } from "../_browser-chunks/chunk-MM7DTO55.js"; // src/instrumenter/instrumenter.ts import { once } from "storybook/internal/client-logger"; import { FORCE_REMOUNT, SET_CURRENT_STORY, STORY_RENDER_PHASE_CHANGED } from "storybook/internal/core-events"; import { global } from "@storybook/global"; // src/instrumenter/preview-api.ts var addons = globalThis.__STORYBOOK_ADDONS_PREVIEW; // src/instrumenter/instrumenter.ts var alreadyCompletedException = new Error( `This function ran after the play function completed. Did you forget to \`await\` it?` ); var isObject = /* @__PURE__ */ __name((o) => Object.prototype.toString.call(o) === "[object Object]", "isObject"); var isModule = /* @__PURE__ */ __name((o) => Object.prototype.toString.call(o) === "[object Module]", "isModule"); var isInstrumentable = /* @__PURE__ */ __name((o) => { if (!isObject(o) && !isModule(o)) { return false; } if (o.constructor === void 0) { return true; } const proto = o.constructor.prototype; if (!isObject(proto)) { return false; } return true; }, "isInstrumentable"); var construct = /* @__PURE__ */ __name((obj) => { try { return new obj.constructor(); } catch { return {}; } }, "construct"); var getInitialState = /* @__PURE__ */ __name(() => ({ renderPhase: "preparing", isDebugging: false, isPlaying: false, isLocked: false, cursor: 0, calls: [], shadowCalls: [], callRefsByResult: /* @__PURE__ */ new Map(), chainedCallIds: /* @__PURE__ */ new Set(), ancestors: [], playUntil: void 0, resolvers: {}, syncTimeout: void 0 }), "getInitialState"); var getRetainedState = /* @__PURE__ */ __name((state, isDebugging = false) => { const calls = (isDebugging ? state.shadowCalls : state.calls).filter((call) => call.retain); if (!calls.length) { return void 0; } const callRefsByResult = new Map( Array.from(state.callRefsByResult.entries()).filter(([, ref]) => ref.retain) ); return { cursor: calls.length, calls, callRefsByResult }; }, "getRetainedState"); var _Instrumenter = class _Instrumenter { constructor() { this.detached = false; this.initialized = false; // State is tracked per story to deal with multiple stories on the same canvas (i.e. docs mode) this.state = {}; this.loadParentWindowState = /* @__PURE__ */ __name(() => { try { this.state = global.window?.parent?.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER_STATE__ || {}; } catch { this.detached = true; } }, "loadParentWindowState"); this.updateParentWindowState = /* @__PURE__ */ __name(() => { try { global.window.parent.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER_STATE__ = this.state; } catch { this.detached = true; } }, "updateParentWindowState"); this.loadParentWindowState(); const resetState = /* @__PURE__ */ __name(({ storyId, renderPhase, isPlaying = true, isDebugging = false }) => { const state = this.getState(storyId); this.setState(storyId, { ...getInitialState(), ...getRetainedState(state, isDebugging), renderPhase: renderPhase || state.renderPhase, shadowCalls: isDebugging ? state.shadowCalls : [], chainedCallIds: isDebugging ? state.chainedCallIds : /* @__PURE__ */ new Set(), playUntil: isDebugging ? state.playUntil : void 0, isPlaying, isDebugging }); this.sync(storyId); }, "resetState"); const start = /* @__PURE__ */ __name((channel) => ({ storyId, playUntil }) => { if (!this.getState(storyId).isDebugging) { this.setState(storyId, ({ calls }) => ({ calls: [], shadowCalls: calls.map((call) => ({ ...call, status: "waiting" /* WAITING */ })), isDebugging: true })); } const log = this.getLog(storyId); this.setState(storyId, ({ shadowCalls }) => { if (playUntil || !log.length) { return { playUntil }; } const firstRowIndex = shadowCalls.findIndex((call) => call.id === log[0].callId); return { playUntil: shadowCalls.slice(0, firstRowIndex).filter((call) => call.interceptable && !call.ancestors?.length).slice(-1)[0]?.id }; }); channel.emit(FORCE_REMOUNT, { storyId, isDebugging: true }); }, "start"); const back = /* @__PURE__ */ __name((channel) => ({ storyId }) => { const log = this.getLog(storyId).filter((call) => !call.ancestors?.length); const last = log.reduceRight((res, item, index) => { if (res >= 0 || item.status === "waiting" /* WAITING */) { return res; } return index; }, -1); start(channel)({ storyId, playUntil: log[last - 1]?.callId }); }, "back"); const goto = /* @__PURE__ */ __name((channel) => ({ storyId, callId }) => { const { calls, shadowCalls, resolvers } = this.getState(storyId); const call = calls.find(({ id }) => id === callId); const shadowCall = shadowCalls.find(({ id }) => id === callId); if (!call && shadowCall && Object.values(resolvers).length > 0) { const nextId = this.getLog(storyId).find((c) => c.status === "waiting" /* WAITING */)?.callId; if (shadowCall.id !== nextId) { this.setState(storyId, { playUntil: shadowCall.id }); } Object.values(resolvers).forEach((resolve) => resolve()); } else { start(channel)({ storyId, playUntil: callId }); } }, "goto"); const next = /* @__PURE__ */ __name((channel) => ({ storyId }) => { const { resolvers } = this.getState(storyId); if (Object.values(resolvers).length > 0) { Object.values(resolvers).forEach((resolve) => resolve()); } else { const nextId = this.getLog(storyId).find((c) => c.status === "waiting" /* WAITING */)?.callId; if (nextId) { start(channel)({ storyId, playUntil: nextId }); } else { end({ storyId }); } } }, "next"); const end = /* @__PURE__ */ __name(({ storyId }) => { this.setState(storyId, { playUntil: void 0, isDebugging: false }); Object.values(this.getState(storyId).resolvers).forEach((resolve) => resolve()); }, "end"); const renderPhaseChanged = /* @__PURE__ */ __name(({ storyId, newPhase }) => { const { isDebugging } = this.getState(storyId); if (newPhase === "preparing" && isDebugging) { return resetState({ storyId, renderPhase: newPhase, isDebugging }); } else if (newPhase === "playing") { return resetState({ storyId, renderPhase: newPhase, isDebugging }); } if (newPhase === "played") { this.setState(storyId, { renderPhase: newPhase, isLocked: false, isPlaying: false, isDebugging: false }); } else if (newPhase === "errored") { this.setState(storyId, { renderPhase: newPhase, isLocked: false, isPlaying: false }); } else if (newPhase === "aborted") { this.setState(storyId, { renderPhase: newPhase, isLocked: true, isPlaying: false }); } else { this.setState(storyId, { renderPhase: newPhase }); } this.sync(storyId); }, "renderPhaseChanged"); if (addons) { addons.ready().then(() => { this.channel = addons.getChannel(); this.channel.on(FORCE_REMOUNT, resetState); this.channel.on(STORY_RENDER_PHASE_CHANGED, renderPhaseChanged); this.channel.on(SET_CURRENT_STORY, () => { if (this.initialized) { this.cleanup(); } else { this.initialized = true; } }); this.channel.on(EVENTS.START, start(this.channel)); this.channel.on(EVENTS.BACK, back(this.channel)); this.channel.on(EVENTS.GOTO, goto(this.channel)); this.channel.on(EVENTS.NEXT, next(this.channel)); this.channel.on(EVENTS.END, end); }); } } getState(storyId) { return this.state[storyId] || getInitialState(); } setState(storyId, update) { if (storyId) { const state = this.getState(storyId); const patch = typeof update === "function" ? update(state) : update; this.state = { ...this.state, [storyId]: { ...state, ...patch } }; this.updateParentWindowState(); } } cleanup() { this.state = Object.entries(this.state).reduce( (acc, [storyId, state]) => { const retainedState = getRetainedState(state); if (!retainedState) { return acc; } acc[storyId] = Object.assign(getInitialState(), retainedState); return acc; }, {} ); const controlStates = { detached: this.detached, start: false, back: false, goto: false, next: false, end: false }; const payload = { controlStates, logItems: [] }; this.channel?.emit(EVENTS.SYNC, payload); this.updateParentWindowState(); } getLog(storyId) { const { calls, shadowCalls } = this.getState(storyId); const merged = [...shadowCalls]; calls.forEach((call, index) => { merged[index] = call; }); const seen = /* @__PURE__ */ new Set(); return merged.reduceRight((acc, call) => { call.args.forEach((arg) => { if (arg?.__callId__) { seen.add(arg.__callId__); } }); call.path.forEach((node) => { if (node.__callId__) { seen.add(node.__callId__); } }); if ((call.interceptable || call.exception) && !seen.has(call.id)) { acc.unshift({ callId: call.id, status: call.status, ancestors: call.ancestors }); seen.add(call.id); } return acc; }, []); } // Traverses the object structure to recursively patch all function properties. // Returns the original object, or a new object with the same constructor, // depending on whether it should mutate. instrument(obj, options, depth = 0) { if (!isInstrumentable(obj)) { return obj; } const { mutate = false, path = [] } = options; const keys = options.getKeys ? options.getKeys(obj, depth) : Object.keys(obj); depth += 1; return keys.reduce( (acc, key) => { const descriptor = getPropertyDescriptor(obj, key); if (typeof descriptor?.get === "function") { if (descriptor.configurable) { const getter = /* @__PURE__ */ __name(() => descriptor?.get?.bind(obj)?.(), "getter"); Object.defineProperty(acc, key, { get: /* @__PURE__ */ __name(() => { return this.instrument(getter(), { ...options, path: path.concat(key) }, depth); }, "get") }); } return acc; } const value = obj[key]; if (typeof value !== "function") { acc[key] = this.instrument(value, { ...options, path: path.concat(key) }, depth); return acc; } if ("__originalFn__" in value && typeof value.__originalFn__ === "function") { acc[key] = value; return acc; } acc[key] = (...args) => this.track(key, value, obj, args, options); acc[key].__originalFn__ = value; Object.defineProperty(acc[key], "name", { value: key, writable: false }); if (Object.keys(value).length > 0) { Object.assign( acc[key], this.instrument({ ...value }, { ...options, path: path.concat(key) }, depth) ); } return acc; }, mutate ? obj : construct(obj) ); } // Monkey patch an object method to record calls. // Returns a function that invokes the original function, records the invocation ("call") and // returns the original result. track(method, fn, object, args, options) { const storyId = args?.[0]?.__storyId__ || global.__STORYBOOK_PREVIEW__?.selectionStore?.selection?.storyId; const { cursor, ancestors } = this.getState(storyId); this.setState(storyId, { cursor: cursor + 1 }); const id = `${ancestors.slice(-1)[0] || storyId} [${cursor}] ${method}`; const { path = [], intercept = false, retain = false } = options; const interceptable = typeof intercept === "function" ? intercept(method, path) : intercept; const call = { id, cursor, storyId, ancestors, path, method, args, interceptable, retain }; const interceptOrInvoke = interceptable && !ancestors.length ? this.intercept : this.invoke; const result = interceptOrInvoke.call(this, fn, object, call, options); return this.instrument(result, { ...options, mutate: true, path: [{ __callId__: call.id }] }); } intercept(fn, object, call, options) { const { chainedCallIds, isDebugging, playUntil } = this.getState(call.storyId); const isChainedUpon = chainedCallIds.has(call.id); if (!isDebugging || isChainedUpon || playUntil) { if (playUntil === call.id) { this.setState(call.storyId, { playUntil: void 0 }); } return this.invoke(fn, object, call, options); } return new Promise((resolve) => { this.setState(call.storyId, ({ resolvers }) => ({ isLocked: false, resolvers: { ...resolvers, [call.id]: resolve } })); }).then(() => { this.setState(call.storyId, (state) => { const { [call.id]: _, ...resolvers } = state.resolvers; return { isLocked: true, resolvers }; }); return this.invoke(fn, object, call, options); }); } invoke(fn, object, call, options) { const { callRefsByResult, renderPhase } = this.getState(call.storyId); const maximumDepth = 25; const serializeValues = /* @__PURE__ */ __name((value, depth, seen) => { if (seen.includes(value)) { return "[Circular]"; } seen = [...seen, value]; if (depth > maximumDepth) { return "..."; } if (callRefsByResult.has(value)) { return callRefsByResult.get(value); } if (value instanceof Array) { return value.map((it) => serializeValues(it, ++depth, seen)); } if (value instanceof Date) { return { __date__: { value: value.toISOString() } }; } if (value instanceof Error) { const { name, message, stack } = value; return { __error__: { name, message, stack } }; } if (value instanceof RegExp) { const { flags, source } = value; return { __regexp__: { flags, source } }; } if (value instanceof global.window?.HTMLElement) { const { prefix, localName, id, classList, innerText } = value; const classNames = Array.from(classList); return { __element__: { prefix, localName, id, classNames, innerText } }; } if (typeof value === "function") { return { __function__: { name: "getMockName" in value ? value.getMockName() : value.name } }; } if (typeof value === "symbol") { return { __symbol__: { description: value.description } }; } if (typeof value === "object" && value?.constructor?.name && value?.constructor?.name !== "Object") { return { __class__: { name: value.constructor.name } }; } if (Object.prototype.toString.call(value) === "[object Object]") { return Object.fromEntries( Object.entries(value).map(([key, val]) => [key, serializeValues(val, ++depth, seen)]) ); } return value; }, "serializeValues"); const info = { ...call, args: call.args.map((arg) => serializeValues(arg, 0, [])) }; call.path.forEach((ref) => { if (ref?.__callId__) { this.setState(call.storyId, ({ chainedCallIds }) => ({ chainedCallIds: new Set(Array.from(chainedCallIds).concat(ref.__callId__)) })); } }); const handleException = /* @__PURE__ */ __name((e) => { if (e instanceof Error) { const { name, message, stack, callId = call.id } = e; const { showDiff = void 0, diff = void 0, actual = void 0, expected = void 0 } = e.name === "AssertionError" ? processError(e) : e; const exception = { name, message, stack, callId, showDiff, diff, actual, expected }; this.update({ ...info, status: "error" /* ERROR */, exception }); this.setState(call.storyId, (state) => ({ callRefsByResult: new Map([ ...Array.from(state.callRefsByResult.entries()), [e, { __callId__: call.id, retain: call.retain }] ]) })); if (call.ancestors?.length) { if (!Object.prototype.hasOwnProperty.call(e, "callId")) { Object.defineProperty(e, "callId", { value: call.id }); } throw e; } } throw e; }, "handleException"); try { if (renderPhase === "played" && !call.retain) { throw alreadyCompletedException; } const actualArgs = options.getArgs ? options.getArgs(call, this.getState(call.storyId)) : call.args; const finalArgs = actualArgs.map((arg) => { if (typeof arg !== "function" || isClass(arg) || Object.keys(arg).length) { return arg; } return (...args) => { const { cursor, ancestors } = this.getState(call.storyId); this.setState(call.storyId, { cursor: 0, ancestors: [...ancestors, call.id] }); const restore = /* @__PURE__ */ __name(() => this.setState(call.storyId, { cursor, ancestors }), "restore"); let willRestore = false; try { const res = arg(...args); if (res instanceof Promise) { willRestore = true; return res.finally(restore); } return res; } finally { if (!willRestore) { restore(); } } }; }); const result = fn.apply(object, finalArgs); if (result && ["object", "function", "symbol"].includes(typeof result)) { this.setState(call.storyId, (state) => ({ callRefsByResult: new Map([ ...Array.from(state.callRefsByResult.entries()), [result, { __callId__: call.id, retain: call.retain }] ]) })); } this.update({ ...info, status: result instanceof Promise ? "active" /* ACTIVE */ : "done" /* DONE */ }); if (result instanceof Promise) { return result.then((value) => { this.update({ ...info, status: "done" /* DONE */ }); return value; }, handleException); } return result; } catch (e) { return handleException(e); } } // Sends the call info to the manager and synchronizes the log. update(call) { this.channel?.emit(EVENTS.CALL, call); this.setState(call.storyId, ({ calls }) => { const callsById = calls.concat(call).reduce((a, c) => Object.assign(a, { [c.id]: c }), {}); return { // Calls are sorted to ensure parent calls always come before calls in their callback. calls: Object.values(callsById).sort( (a, b) => a.id.localeCompare(b.id, void 0, { numeric: true }) ) }; }); this.sync(call.storyId); } // Builds a log of interceptable calls and control states and sends it to the manager. // Uses a 0ms debounce because this might get called many times in one tick. sync(storyId) { const synchronize = /* @__PURE__ */ __name(() => { const { isLocked, isPlaying } = this.getState(storyId); const logItems = this.getLog(storyId); const pausedAt = logItems.filter(({ ancestors }) => !ancestors.length).find((item) => item.status === "waiting" /* WAITING */)?.callId; const hasActive = logItems.some((item) => item.status === "active" /* ACTIVE */); if (this.detached || isLocked || hasActive || logItems.length === 0) { const controlStates2 = { detached: this.detached, start: false, back: false, goto: false, next: false, end: false }; const payload2 = { controlStates: controlStates2, logItems }; this.channel?.emit(EVENTS.SYNC, payload2); return; } const hasPrevious = logItems.some( (item) => item.status === "done" /* DONE */ || item.status === "error" /* ERROR */ ); const controlStates = { detached: this.detached, start: hasPrevious, back: hasPrevious, goto: true, next: isPlaying, end: isPlaying }; const payload = { controlStates, logItems, pausedAt }; this.channel?.emit(EVENTS.SYNC, payload); }, "synchronize"); this.setState(storyId, ({ syncTimeout }) => { clearTimeout(syncTimeout); return { syncTimeout: setTimeout(synchronize, 0) }; }); } }; __name(_Instrumenter, "Instrumenter"); var Instrumenter = _Instrumenter; function instrument(obj, options = {}) { try { let forceInstrument = false; let skipInstrument = false; if (global.window?.location?.search?.includes("instrument=true")) { forceInstrument = true; } else if (global.window?.location?.search?.includes("instrument=false")) { skipInstrument = true; } if (global.window?.parent === global.window && !forceInstrument || skipInstrument) { return obj; } if (global.window && !global.window.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER__) { global.window.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER__ = new Instrumenter(); } const instrumenter = global.window?.__STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER__; return instrumenter.instrument(obj, options); } catch (e) { once.warn(e); return obj; } } __name(instrument, "instrument"); function getPropertyDescriptor(obj, propName) { let target = obj; while (target != null) { const descriptor = Object.getOwnPropertyDescriptor(target, propName); if (descriptor) { return descriptor; } target = Object.getPrototypeOf(target); } return void 0; } __name(getPropertyDescriptor, "getPropertyDescriptor"); function isClass(obj) { if (typeof obj !== "function") { return false; } const descriptor = Object.getOwnPropertyDescriptor(obj, "prototype"); if (!descriptor) { return false; } return !descriptor.writable; } __name(isClass, "isClass"); export { CallStates, EVENTS, instrument };