Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions docs/testing/deterministic-presentation.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 9 additions & 5 deletions src/app/TerminalApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1427,7 +1428,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}`);
Expand Down Expand Up @@ -2400,7 +2402,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) {
Expand Down Expand Up @@ -2582,7 +2584,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);
Expand Down Expand Up @@ -2676,8 +2679,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 {
Expand Down
22 changes: 22 additions & 0 deletions src/presentation/environment.ts
Original file line number Diff line number Diff line change
@@ -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;
}
4 changes: 2 additions & 2 deletions src/status/activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`,
};
}
Expand Down
47 changes: 47 additions & 0 deletions tests/deterministicPresentation.test.ts
Original file line number Diff line number Diff line change
@@ -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});
}
});
59 changes: 59 additions & 0 deletions tests/presentationEnvironment.test.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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);
});
Loading