TuilStorySession
class exported by @mwillbanks/tuil-story.
class
Public class exported by @mwillbanks/tuil-story.
export class TuilStorySession {
readonly #catalog: TuilStoryCatalog;
readonly #storyId: string;
readonly #variant: string;
readonly #themeRegistry?: ThemeRegistry;
readonly #signal?: AbortSignal;
readonly #releaseRenderLock: () => void;
readonly #previousChalkLevel: 0 | 1 | 2 | 3;
readonly #events: ObservedEvent[] = [];
readonly #actions: StoryAction[] = [];
#args: Readonly<Record<string, unknown>>;
#controls: TerminalStoryControls;
#instance?: TuilTestInstance;
#stopObserving?: () => void;
#closed = false;
private constructor(
catalog: TuilStoryCatalog,
options: OpenStoryOptions,
releaseRenderLock: () => void,
previousChalkLevel: 0 | 1 | 2 | 3,
) {
this.#catalog = catalog;
this.#storyId = options.storyId;
this.#variant = options.variant;
this.#themeRegistry = options.themeRegistry;
this.#signal = options.signal;
this.#releaseRenderLock = releaseRenderLock;
this.#previousChalkLevel = previousChalkLevel;
const story = this.#story();
this.#args = Object.freeze({ ...story.args, ...options.args });
this.#controls = Object.freeze({
...defaultTerminalStoryControls,
...story.terminal,
...options.controls,
});
}
static async open(
catalog: TuilStoryCatalog,
options: OpenStoryOptions,
): Promise<TuilStorySession> {
const releaseRenderLock = await storyRenderLock.acquire(options.signal);
const previousChalkLevel = chalk.level;
let session: TuilStorySession;
try {
session = new TuilStorySession(
catalog,
options,
releaseRenderLock,
previousChalkLevel,
);
} catch (error) {
chalk.level = previousChalkLevel;
releaseRenderLock();
throw error;
}
try {
await session.#render(options.signal);
return session;
} catch (error) {
try {
await session.#disposeInstance();
} catch (cleanupError) {
session.#releaseRenderer();
throw new AggregateError(
[error, cleanupError],
"Story rendering and cleanup failed",
);
}
session.#releaseRenderer();
throw error;
}
}
get args(): Readonly<Record<string, unknown>> {
return this.#args;
}
get controls(): TerminalStoryControls {
return this.#controls;
}
async setArgs(
args: Readonly<Record<string, unknown>>,
signal = this.#signal,
): Promise<void> {
this.#assertOpen();
signal?.throwIfAborted();
chalk.level = chalkLevel(this.#controls.colorDepth);
this.#args = Object.freeze({ ...this.#args, ...args });
this.#recordAction({ type: "args", timestamp: Date.now(), detail: args });
this.#instance?.rerender(this.#element());
await waitWithSignal(Bun.sleep(10), signal);
}
async setControls(
controls: Partial<TerminalStoryControls>,
signal = this.#signal,
): Promise<void> {
this.#assertOpen();
signal?.throwIfAborted();
const previous = this.#controls;
this.#controls = Object.freeze({ ...previous, ...controls });
this.#recordAction({
type: "controls",
timestamp: Date.now(),
detail: controls,
});
if (controls.width !== undefined || controls.height !== undefined) {
this.#recordAction({
type: "resize",
timestamp: Date.now(),
detail: {
width: this.#controls.width,
height: this.#controls.height,
},
});
}
// Capabilities and normalized themes are immutable runtime state. Recreate
// the renderer so every control, including dimensions, reaches components.
await this.#disposeInstance();
await this.#render(signal);
}
async press(input: string, signal = this.#signal): Promise<void> {
this.#assertOpen();
signal?.throwIfAborted();
chalk.level = chalkLevel(this.#controls.colorDepth);
this.#recordAction({
type: "input",
timestamp: Date.now(),
detail: { input },
});
await waitWithSignal(this.#requireInstance().user.press(input), signal);
}
snapshot(): StoryFrame {
this.#assertOpen();
const instance = this.#requireInstance();
const snapshot = instance.screen.snapshot();
return Object.freeze({
storyId: this.#storyId,
variant: this.#variant,
frame: snapshot.frame,
ansiFrame: instance.frames.at(-1) ?? snapshot.frame,
semantics: Object.freeze([...snapshot.nodes]),
focus: Object.freeze({
focusedId: instance.app.focus.focusedId,
nodes: Object.freeze(
instance.app.focus.nodes().map(({ id, label, role }) => ({
id,
label,
role,
})),
),
}),
events: Object.freeze([...this.#events]),
actions: Object.freeze([...this.#actions]),
controls: this.#controls,
});
}
async close(signal?: AbortSignal): Promise<void> {
if (this.#closed) {
signal?.throwIfAborted();
return;
}
this.#closed = true;
try {
await this.#disposeInstance();
} finally {
this.#releaseRenderer();
}
signal?.throwIfAborted();
}
#story() {
const set = this.#catalog.get(this.#storyId);
if (!set) throw new Error(`Unknown story set "${this.#storyId}"`);
const story = set.definition.stories[this.#variant];
if (!story) {
throw new Error(
`Unknown variant "${this.#variant}" in story set "${this.#storyId}"`,
);
}
return story;
}
#element(): ReactElement {
const set = this.#catalog.get(this.#storyId);
if (!set) throw new Error(`Unknown story set "${this.#storyId}"`);
return createElement(set.definition.component, this.#args);
}
#requireInstance(): TuilTestInstance {
this.#assertOpen();
if (!this.#instance) throw new Error("Story session is closed");
return this.#instance;
}
async #render(signal = this.#signal): Promise<void> {
signal?.throwIfAborted();
const controls = this.#controls;
chalk.level = chalkLevel(controls.colorDepth);
const instance = renderTuil(this.#element() as ReactElement, {
theme: resolveTheme(controls, this.#themeRegistry),
terminal: {
mode: controls.interactive ? "interactive" : "static",
capabilities: {
width: controls.width,
height: controls.height,
colorDepth: controls.colorDepth,
unicode: controls.unicode,
hyperlinks: controls.hyperlinks,
interactive: controls.interactive,
tty: controls.interactive,
alternateScreen: controls.interactive,
mouse: controls.mouse,
images: false,
reducedMotion: controls.reducedMotion,
platform: controls.platform,
},
},
});
this.#instance = instance;
instance.resize(controls.width, controls.height);
this.#stopObserving = instance.app.events.observe((event) => {
this.#events.push(event);
if (this.#events.length > 200) this.#events.shift();
});
try {
await waitWithSignal(instance.ready, signal);
} catch (error) {
await this.#disposeInstance().catch(() => undefined);
throw error;
}
this.#recordAction({ type: "render", timestamp: Date.now() });
}
async #disposeInstance(): Promise<void> {
this.#stopObserving?.();
this.#stopObserving = undefined;
const instance = this.#instance;
this.#instance = undefined;
if (instance) await instance.cleanup();
}
#recordAction(action: StoryAction): void {
this.#actions.push(action);
if (this.#actions.length > 200) this.#actions.shift();
}
#assertOpen(): void {
if (this.#closed) throw new Error("Story session is closed");
}
#releaseRenderer(): void {
if (this.#released) return;
this.#released = true;
chalk.level = this.#previousChalkLevel;
this.#releaseRenderLock();
}
#released = false;
}Members
| Member | Type | Required | Description | Related types |
|---|---|---|---|---|
#catalog | TuilStoryCatalog | Yes | The #catalog member uses the TuilStoryCatalog contract. | TuilStoryCatalog |
#storyId | string | Yes | The #storyId member uses the string contract. | — |
#variant | string | Yes | The #variant member uses the string contract. | — |
#themeRegistry | ThemeRegistry | undefined | No | The #themeRegistry member uses the ThemeRegistry | undefined contract. | ThemeRegistry |
#signal | AbortSignal | undefined | No | The #signal member uses the AbortSignal | undefined contract. | — |
#releaseRenderLock | () => void | Yes | The #releaseRenderLock member uses the () => void contract. | — |
#previousChalkLevel | 0 | 1 | 2 | 3 | Yes | The #previousChalkLevel member uses the 0 | 1 | 2 | 3 contract. | — |
#events | ObservedEvent[] | Yes | The #events member uses the ObservedEvent[] contract. | ObservedEvent |
#actions | StoryAction[] | Yes | The #actions member uses the StoryAction[] contract. | StoryAction |
#args | Readonly<Record<string, unknown>> | Yes | The #args member uses the Readonly<Record<string, unknown>> contract. | — |
#controls | TerminalStoryControls | Yes | The #controls member uses the TerminalStoryControls contract. | TerminalStoryControls |
#instance | TuilTestInstance | undefined | No | The #instance member uses the TuilTestInstance | undefined contract. | TuilTestInstance |
#stopObserving | (() => void) | undefined | No | The #stopObserving member uses the (() => void) | undefined contract. | — |
#closed | boolean | Yes | The #closed member uses the boolean contract. | — |
open | typeof TuilStorySession.open | Yes | The open member uses the typeof TuilStorySession.open contract. | TuilStorySession |
args | Readonly<Record<string, unknown>> | Yes | The args member uses the Readonly<Record<string, unknown>> contract. | — |
controls | TerminalStoryControls | Yes | The controls member uses the TerminalStoryControls contract. | TerminalStoryControls |
setArgs | (args: Readonly<Record<string, unknown>>, signal?: AbortSignal | undefined) => Promise<void> | Yes | The setArgs member uses the (args: Readonly<Record<string, unknown>>, signal?: AbortSignal | undefined) => Promise<void> contract. | — |
setControls | (controls: Partial<TerminalStoryControls>, signal?: AbortSignal | undefined) => Promise<void> | Yes | The setControls member uses the (controls: Partial<TerminalStoryControls>, signal?: AbortSignal | undefined) => Promise<void> contract. | TerminalStoryControls |
press | (input: string, signal?: AbortSignal | undefined) => Promise<void> | Yes | The press member uses the (input: string, signal?: AbortSignal | undefined) => Promise<void> contract. | — |
snapshot | () => StoryFrame | Yes | The snapshot member uses the () => StoryFrame contract. | StoryFrame |
close | (signal?: AbortSignal) => Promise<void> | Yes | The close member uses the (signal?: AbortSignal) => Promise<void> contract. | — |
#story | () => TuilStory<Record<string, unknown>> | Yes | The #story member uses the () => TuilStory<Record<string, unknown>> contract. | TuilStory |
#element | () => ReactElement | Yes | The #element member uses the () => ReactElement contract. | — |
#requireInstance | () => TuilTestInstance | Yes | The #requireInstance member uses the () => TuilTestInstance contract. | TuilTestInstance |
#render | (signal?: AbortSignal | undefined) => Promise<void> | Yes | The #render member uses the (signal?: AbortSignal | undefined) => Promise<void> contract. | — |
#disposeInstance | () => Promise<void> | Yes | The #disposeInstance member uses the () => Promise<void> contract. | — |
#recordAction | (action: StoryAction) => void | Yes | The #recordAction member uses the (action: StoryAction) => void contract. | StoryAction |
#assertOpen | () => void | Yes | The #assertOpen member uses the () => void contract. | — |
#releaseRenderer | () => void | Yes | The #releaseRenderer member uses the () => void contract. | — |
#released | boolean | Yes | The #released member uses the boolean contract. | — |
Parameters
This declaration has no public members.
Returns
This declaration does not return a value.
Throws
No thrown errors are documented for this declaration.
Related types
Source
View the secondary source reference