tuil
ReferencePackages@mwillbanks/tuil-storyAPI

TuilStorySession

class exported by @mwillbanks/tuil-story.

View rawEdit

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

MemberTypeRequiredDescriptionRelated types
#catalogTuilStoryCatalogYesThe #catalog member uses the TuilStoryCatalog contract.TuilStoryCatalog
#storyIdstringYesThe #storyId member uses the string contract.
#variantstringYesThe #variant member uses the string contract.
#themeRegistryThemeRegistry | undefinedNoThe #themeRegistry member uses the ThemeRegistry | undefined contract.ThemeRegistry
#signalAbortSignal | undefinedNoThe #signal member uses the AbortSignal | undefined contract.
#releaseRenderLock() => voidYesThe #releaseRenderLock member uses the () => void contract.
#previousChalkLevel0 | 1 | 2 | 3YesThe #previousChalkLevel member uses the 0 | 1 | 2 | 3 contract.
#eventsObservedEvent[]YesThe #events member uses the ObservedEvent[] contract.ObservedEvent
#actionsStoryAction[]YesThe #actions member uses the StoryAction[] contract.StoryAction
#argsReadonly<Record<string, unknown>>YesThe #args member uses the Readonly<Record<string, unknown>> contract.
#controlsTerminalStoryControlsYesThe #controls member uses the TerminalStoryControls contract.TerminalStoryControls
#instanceTuilTestInstance | undefinedNoThe #instance member uses the TuilTestInstance | undefined contract.TuilTestInstance
#stopObserving(() => void) | undefinedNoThe #stopObserving member uses the (() => void) | undefined contract.
#closedbooleanYesThe #closed member uses the boolean contract.
opentypeof TuilStorySession.openYesThe open member uses the typeof TuilStorySession.open contract.TuilStorySession
argsReadonly<Record<string, unknown>>YesThe args member uses the Readonly<Record<string, unknown>> contract.
controlsTerminalStoryControlsYesThe controls member uses the TerminalStoryControls contract.TerminalStoryControls
setArgs(args: Readonly<Record<string, unknown>>, signal?: AbortSignal | undefined) => Promise<void>YesThe setArgs member uses the (args: Readonly<Record<string, unknown>>, signal?: AbortSignal | undefined) => Promise<void> contract.
setControls(controls: Partial<TerminalStoryControls>, signal?: AbortSignal | undefined) => Promise<void>YesThe setControls member uses the (controls: Partial<TerminalStoryControls>, signal?: AbortSignal | undefined) => Promise<void> contract.TerminalStoryControls
press(input: string, signal?: AbortSignal | undefined) => Promise<void>YesThe press member uses the (input: string, signal?: AbortSignal | undefined) => Promise<void> contract.
snapshot() => StoryFrameYesThe snapshot member uses the () => StoryFrame contract.StoryFrame
close(signal?: AbortSignal) => Promise<void>YesThe close member uses the (signal?: AbortSignal) => Promise<void> contract.
#story() => TuilStory<Record<string, unknown>>YesThe #story member uses the () => TuilStory<Record<string, unknown>> contract.TuilStory
#element() => ReactElementYesThe #element member uses the () => ReactElement contract.
#requireInstance() => TuilTestInstanceYesThe #requireInstance member uses the () => TuilTestInstance contract.TuilTestInstance
#render(signal?: AbortSignal | undefined) => Promise<void>YesThe #render member uses the (signal?: AbortSignal | undefined) => Promise<void> contract.
#disposeInstance() => Promise<void>YesThe #disposeInstance member uses the () => Promise<void> contract.
#recordAction(action: StoryAction) => voidYesThe #recordAction member uses the (action: StoryAction) => void contract.StoryAction
#assertOpen() => voidYesThe #assertOpen member uses the () => void contract.
#releaseRenderer() => voidYesThe #releaseRenderer member uses the () => void contract.
#releasedbooleanYesThe #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.

Source

View the secondary source reference

Package

@mwillbanks/tuil-story

On this page