From 3a1d90b5aecc162ea9ecd2b4a258416afa693e31 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:52:58 +0530 Subject: [PATCH] Add deterministic presentation mode --- docs/testing/deterministic-presentation.md | 34 +++++++++++++ src/app/TerminalApp.ts | 14 +++-- src/presentation/environment.ts | 22 ++++++++ src/status/activity.ts | 4 +- tests/deterministicPresentation.test.ts | 47 +++++++++++++++++ tests/presentationEnvironment.test.ts | 59 ++++++++++++++++++++++ 6 files changed, 173 insertions(+), 7 deletions(-) create mode 100644 docs/testing/deterministic-presentation.md create mode 100644 src/presentation/environment.ts create mode 100644 tests/deterministicPresentation.test.ts create mode 100644 tests/presentationEnvironment.test.ts diff --git a/docs/testing/deterministic-presentation.md b/docs/testing/deterministic-presentation.md new file mode 100644 index 0000000..22d44fd --- /dev/null +++ b/docs/testing/deterministic-presentation.md @@ -0,0 +1,34 @@ +# Deterministic presentation for tests + +Set `NMSH_DETERMINISTIC=1` when launching NMSh to make its current presentation +more repeatable in visual and integration captures: + +```sh +NMSH_DETERMINISTIC=1 npm run dev +``` + +This is a developer/testing aid for visual snapshots, integration tests, and +later VHS/demo tooling. It is opt-in and does not change the normal runtime. + +## Stabilized today + +- NMSh's displayed command completion time is fixed to 09:41 local time. The + measured command duration and the underlying shell event timestamp remain + real. +- NMSh shimmer and live activity spinner presentation use a fixed phase. + Command duration text remains based on real elapsed time. +- Vespyr stays in its open-eye frame; the ambient blink timer is not started. + Its existing blink delay sequence is already deterministic and has no random + choices. + +The fixed completion clock uses the local-time formatter, so it stabilizes the +displayed hour and minute across runs on a host. It does not normalize locale, +terminal width, colors, or other host presentation settings. + +## Deliberately unchanged + +This mode does not freeze `Date`, randomness, or timers globally. Shell commands, +zsh state, PTY output and timing, session expiry, service protocol timing, +timeouts/retries, external programs, persisted timestamps, and filesystem IDs +remain real. NMSh currently has no general animation engine or random ambient +visual choices; this seam does not implement the future #172 animation system. diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index a68561f..1f96deb 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -53,6 +53,7 @@ import {copyFeedback, copyStats, writeClipboard} from '../clipboard/clipboard.js import {shouldPassthrough} from '../passthrough/PassthroughPolicy.js'; import {layoutInput, graphemes} from '../input/inputLayout.js'; import {shimmerText} from '../status/shimmer.js'; +import {isDeterministicPresentation, presentationAnimationElapsed, presentationCompletionTime, presentationNow} from '../presentation/environment.js'; import {TaskProgress} from '../status/TaskProgress.js'; import {completedActivity, liveActivityParts} from '../status/activity.js'; import {extractFacts} from '../status/adapters.js'; @@ -1420,7 +1421,8 @@ export class TerminalApp { const outputText = completedRecord?.output ?? ''; const facts = extractFacts(command.command, outputText); const isInterrupted = command.interrupted || exitCode === 130; - const parts = completedActivity(command.command, elapsed, completedAt, isInterrupted ? 0 : exitCode, isInterrupted, facts); + const displayCompletedAt = presentationCompletionTime(completedAt); + const parts = completedActivity(command.command, elapsed, displayCompletedAt, isInterrupted ? 0 : exitCode, isInterrupted, facts); this.output.setCompletionLifecycle(`${parts.main}${parts.detail}`); const rowStyle = isInterrupted ? STOPPED : (exitCode !== 0 ? ERROR : SUCCESS); this.output.addHistoryLine(`${rowStyle}${parts.main}${SECONDARY}${parts.detail}${RESET}`); @@ -2393,7 +2395,7 @@ export class TerminalApp { * change. Blinks are skipped (not queued) while no welcome is present. */ private scheduleWelcomeBlink(): void { - if (this.stopped) return; + if (this.stopped || isDeterministicPresentation()) return; this.welcomeBlinkTimer = setTimeout(() => { if (this.stopped) return; if (!this.output.hasWelcome || this.passthrough) { @@ -2575,7 +2577,8 @@ export class TerminalApp { const outputHeight = plan.transcript.height; const presenter = this.output.presenter; const interaction = {hoveredLineIndex: this.hoveredLineIndex, focusedLineIndex: this.focusedLineIndex, - focusedCommandIndex: this.focusedCommandIndex, focusedActivityId: this.focusedActivityId, now: Date.now()}; + focusedCommandIndex: this.focusedCommandIndex, focusedActivityId: this.focusedActivityId, + now: presentationNow().getTime()}; const visible = wrapped.slice(viewStart, viewStart + outputHeight).map(row => presenter.decorate(row, row.lineIndex === undefined ? undefined : this.output.lineTypes.get(row.lineIndex), interaction)); const sticky = this.stickyHeader(wrapped, viewStart); @@ -2669,8 +2672,9 @@ export class TerminalApp { if (!this.running) return ''; const elapsed = this.activityAnimationNow - this.running.startedAt; const isActive = (Date.now() - this.lastOutputTime) < 750; - const parts = liveActivityParts(this.running.command, elapsed); - return `${shimmerText(parts.phrase, elapsed, isActive)}${SECONDARY}${parts.duration}${RESET}`; + const animationElapsed = presentationAnimationElapsed(elapsed); + const parts = liveActivityParts(this.running.command, elapsed, animationElapsed); + return `${shimmerText(parts.phrase, animationElapsed, isDeterministicPresentation() ? false : isActive)}${SECONDARY}${parts.duration}${RESET}`; } private jumpAffordance(columns: number): string { diff --git a/src/presentation/environment.ts b/src/presentation/environment.ts new file mode 100644 index 0000000..ba79912 --- /dev/null +++ b/src/presentation/environment.ts @@ -0,0 +1,22 @@ +/** + * Explicit clock helpers for NMSh-owned presentation. This seam must not be + * used for shell events, command durations, timers, or persisted timestamps. + */ +export function isDeterministicPresentation(): boolean { + return process.env.NMSH_DETERMINISTIC === '1'; +} + +/** Stable local wall time for display-only content; ordinary runs use real time. */ +export function presentationNow(): Date { + return isDeterministicPresentation() ? new Date(2026, 0, 1, 9, 41, 0) : new Date(); +} + +/** Keep the real event timestamp unless formatting it for deterministic presentation. */ +export function presentationCompletionTime(completedAt: Date): Date { + return isDeterministicPresentation() ? presentationNow() : completedAt; +} + +/** Stable shimmer phase for visual captures without changing measured durations. */ +export function presentationAnimationElapsed(elapsedMs: number): number { + return isDeterministicPresentation() ? 0 : elapsedMs; +} diff --git a/src/status/activity.ts b/src/status/activity.ts index 03a8488..f9872cb 100644 --- a/src/status/activity.ts +++ b/src/status/activity.ts @@ -9,13 +9,13 @@ export function activityGlyph(elapsedMs: number): string { return frames[Math.floor(Math.max(0, elapsedMs) / ACTIVITY_GLYPH_INTERVAL_MS) % frames.length]; } -export function liveActivityParts(command: string, elapsedMs: number): {phrase: string; duration: string} { +export function liveActivityParts(command: string, elapsedMs: number, animationElapsedMs = elapsedMs): {phrase: string; duration: string} { let flattenedCommand = command.trim().replace(/\r?\n/g, ' ⏎ '); if (flattenedCommand.length > 50) { flattenedCommand = flattenedCommand.slice(0, 49) + '…'; } return { - phrase: `${activityGlyph(elapsedMs)} Running ${flattenedCommand}`, + phrase: `${activityGlyph(animationElapsedMs)} Running ${flattenedCommand}`, duration: ` · ${formatDuration(elapsedMs)}`, }; } diff --git a/tests/deterministicPresentation.test.ts b/tests/deterministicPresentation.test.ts new file mode 100644 index 0000000..ede0ab4 --- /dev/null +++ b/tests/deterministicPresentation.test.ts @@ -0,0 +1,47 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdir, mkdtemp, readFile, rm, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {TerminalApp} from '../src/app/TerminalApp.js'; +import {presentationNow} from '../src/presentation/environment.js'; + +test('deterministic presentation keeps Vespyr in its stable open frame without a blink timer', () => { + const previous = process.env.NMSH_DETERMINISTIC; + process.env.NMSH_DETERMINISTIC = '1'; + let app: TerminalApp | undefined; + try { + app = new TerminalApp(); + assert.equal(app['welcomeBlinkTimer'], undefined); + assert.ok(app['output'].transcript().welcome); + assert.doesNotMatch(app['output'].wrapped(80)[1]!.plain, /▂/u, 'welcome stays in its open-eye frame'); + } finally { + app?.['stop'](0); + app?.['session'].kill(); + if (previous === undefined) delete process.env.NMSH_DETERMINISTIC; + else process.env.NMSH_DETERMINISTIC = previous; + } +}); + +test('deterministic presentation helpers leave user config bytes untouched', async () => { + const previousDeterministic = process.env.NMSH_DETERMINISTIC; + const previousConfigHome = process.env.XDG_CONFIG_HOME; + const configHome = await mkdtemp(join(tmpdir(), 'nmsh-deterministic-')); + const configDirectory = join(configHome, 'nmsh'); + const configPath = join(configDirectory, 'config.json'); + const configContents = '{"provider":"nmsh","onboardingComplete":true}\n'; + await mkdir(configDirectory, {recursive: true}); + await writeFile(configPath, configContents); + process.env.NMSH_DETERMINISTIC = '1'; + process.env.XDG_CONFIG_HOME = configHome; + try { + assert.ok(presentationNow() instanceof Date); + assert.equal(await readFile(configPath, 'utf8'), configContents); + } finally { + if (previousDeterministic === undefined) delete process.env.NMSH_DETERMINISTIC; + else process.env.NMSH_DETERMINISTIC = previousDeterministic; + if (previousConfigHome === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = previousConfigHome; + await rm(configHome, {recursive: true, force: true}); + } +}); diff --git a/tests/presentationEnvironment.test.ts b/tests/presentationEnvironment.test.ts new file mode 100644 index 0000000..bb928c3 --- /dev/null +++ b/tests/presentationEnvironment.test.ts @@ -0,0 +1,59 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {presentationAnimationElapsed, presentationCompletionTime, presentationNow} from '../src/presentation/environment.js'; +import {completedActivity, liveActivityParts} from '../src/status/activity.js'; + +const deterministicValue = process.env.NMSH_DETERMINISTIC; + +function withDeterministicPresentation(enabled: boolean, run: () => T): T { + if (enabled) process.env.NMSH_DETERMINISTIC = '1'; + else delete process.env.NMSH_DETERMINISTIC; + try { + return run(); + } finally { + if (deterministicValue === undefined) delete process.env.NMSH_DETERMINISTIC; + else process.env.NMSH_DETERMINISTIC = deterministicValue; + } +} + +test('presentation clock and animation preserve ordinary runtime values by default', () => { + withDeterministicPresentation(false, () => { + const before = Date.now(); + const now = presentationNow().getTime(); + const after = Date.now(); + assert.ok(now >= before && now <= after); + assert.equal(presentationAnimationElapsed(731), 731); + + const shellCompletion = new Date(1_800_000_000_000); + assert.equal(presentationCompletionTime(shellCompletion), shellCompletion); + }); +}); + +test('deterministic presentation repeats timestamps and shimmer phase across renders', () => { + withDeterministicPresentation(true, () => { + const eventTimeA = new Date(1_800_000_000_000); + const eventTimeB = new Date(1_900_000_000_000); + const first = completedActivity('echo stable', 42, presentationCompletionTime(eventTimeA), 0, false); + const second = completedActivity('echo stable', 42, presentationCompletionTime(eventTimeB), 0, false); + + assert.deepEqual(first, second); + assert.equal(first.detail, ' · 09:41'); + assert.equal(presentationNow().getTime(), presentationNow().getTime()); + assert.equal(presentationAnimationElapsed(731), 0); + assert.equal(eventTimeA.getTime(), 1_800_000_000_000, 'shell event time remains untouched'); + + const shortRun = liveActivityParts('echo stable', 100, presentationAnimationElapsed(100)); + const longRun = liveActivityParts('echo stable', 5000, presentationAnimationElapsed(5000)); + assert.equal(shortRun.phrase, longRun.phrase, 'spinner phase stays fixed'); + assert.notEqual(shortRun.duration, longRun.duration, 'measured duration remains real'); + }); +}); + +test('deterministic environment does not leak after an assertion scope', () => { + const before = process.env.NMSH_DETERMINISTIC; + assert.throws(() => withDeterministicPresentation(true, () => { + assert.equal(presentationAnimationElapsed(12), 0); + throw new Error('exercise restoration'); + }), /exercise restoration/u); + assert.equal(process.env.NMSH_DETERMINISTIC, before); +});