@specific.dev/spectest 0.54.0 → 0.54.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/daemon.js CHANGED
@@ -3928,6 +3928,7 @@ async function runOne(testCase) {
3928
3928
  rows: term.rows,
3929
3929
  outputPreview: preview.value,
3930
3930
  outputTruncated: preview.truncated,
3931
+ interactive: true,
3931
3932
  });
3932
3933
  return term;
3933
3934
  };
@@ -481,6 +481,14 @@ export interface TerminalEvent extends BaseEvent {
481
481
  outputTruncated: boolean;
482
482
  /** Set if spawning the PTY itself failed. */
483
483
  error?: string;
484
+ /**
485
+ * Set on the open marker of an interactive session (`ctx.terminal.open`).
486
+ * A one-shot `ctx.terminal(...)` event covers a whole command run, so the
487
+ * UI seeks its replay to the end; the open marker is the state at open,
488
+ * so the UI seeks to 0 instead. Older recordings lack this flag — the UI
489
+ * falls back to the marker's sentinel shape (exitCode -1, durationMs 0).
490
+ */
491
+ interactive?: boolean;
484
492
  }
485
493
  /** Action taken on an open interactive terminal session. */
486
494
  export type TerminalStepAction = "send" | "sendLine" | "press" | "waitFor" | "exit" | "close";
package/dist/terminal.js CHANGED
@@ -128,8 +128,23 @@ export async function openTerminal(args) {
128
128
  // sendLine / press so the recorded `castTimeSec` lands on the echo's
129
129
  // frame instead of microseconds before it.
130
130
  const frameWaiters = [];
131
+ // Time of the most recently ingested frame. Two jobs: (1) frame
132
+ // timestamps are forced strictly increasing (Date.now() has ms
133
+ // resolution, so a fast PTY can land two frames — or two *ops* —
134
+ // in the same millisecond), and (2) it is what `recordOpEvent`
135
+ // stamps as the step's `castTimeSec`. Together they make the
136
+ // player's `seek(castTimeSec)` (which applies frames `<= target`)
137
+ // land on exactly the frames this op had seen: the step's own last
138
+ // frame is included, and the next op's first frame can never share
139
+ // the stamp. Before this, a `waitFor` that matched in the same
140
+ // millisecond as the following `sendLine`'s echo showed that echo
141
+ // — the replay ran one step ahead of the step list.
142
+ let lastFrameT = 0;
131
143
  const ingest = (chunk) => {
132
- const t = (Date.now() - start) / 1000;
144
+ let t = (Date.now() - start) / 1000;
145
+ if (t <= lastFrameT)
146
+ t = lastFrameT + 0.001;
147
+ lastFrameT = t;
133
148
  sink.pushFrame(t, chunk);
134
149
  emu.write(chunk);
135
150
  if (output.length < OUTPUT_CAP_BYTES) {
@@ -223,8 +238,8 @@ export async function openTerminal(args) {
223
238
  }
224
239
  // Record cast time AFTER drain, so the `exit` step we record below
225
240
  // points the player at the very last frame (the program's final
226
- // output before EOF).
227
- exitCastTimeSec = (Date.now() - start) / 1000;
241
+ // output before EOF). Same between-frames stamp as `recordOpEvent`.
242
+ exitCastTimeSec = lastFrameT + 0.0005;
228
243
  sink.markClosed();
229
244
  resolveExit(code);
230
245
  })();
@@ -265,10 +280,17 @@ export async function openTerminal(args) {
265
280
  if (!args.recordEvents)
266
281
  return undefined;
267
282
  const preview = truncateUtf8(renderScreen());
268
- // Use the SAME clock the asciicast frames use (offset from `start`,
269
- // which is captured at Bun.spawn). That way the UI seeks the player
270
- // directly to this value with no frame arithmetic.
271
- const castTimeSec = (Date.now() - start) / 1000;
283
+ // Stamp the step half a millisecond past its last ingested frame:
284
+ // frame times are strictly increasing in 1ms steps (see `ingest`),
285
+ // so the stamp sits strictly BETWEEN the op's own last frame and
286
+ // any later op's first frame. That makes the boundary unambiguous
287
+ // in both directions — an inclusive seek (`<= stamp`) shows the
288
+ // op's own frame and can never pull in the next op's, and the
289
+ // viewer's exclusive `waitFor` seek (stamp − 0.0004, which
290
+ // disambiguates legacy wall-clock stamps that tied with the next
291
+ // op's echo) still lands past the op's own frame. Same clock as
292
+ // the frames (offset from `start`), so the UI seeks directly.
293
+ const castTimeSec = lastFrameT + 0.0005;
272
294
  // Return the event seq so callers can `wrap()` their result value
273
295
  // against it — e.g. `expect(await term.waitFor(...))` then links its
274
296
  // assertion to this step (sourceSeq) and the UI nests it here.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.54.0",
3
+ "version": "0.54.1",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/daemon.ts CHANGED
@@ -4815,6 +4815,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
4815
4815
  rows: term.rows,
4816
4816
  outputPreview: preview.value,
4817
4817
  outputTruncated: preview.truncated,
4818
+ interactive: true,
4818
4819
  });
4819
4820
  return term;
4820
4821
  };
package/src/recorder.ts CHANGED
@@ -537,6 +537,14 @@ export interface TerminalEvent extends BaseEvent {
537
537
  outputTruncated: boolean;
538
538
  /** Set if spawning the PTY itself failed. */
539
539
  error?: string;
540
+ /**
541
+ * Set on the open marker of an interactive session (`ctx.terminal.open`).
542
+ * A one-shot `ctx.terminal(...)` event covers a whole command run, so the
543
+ * UI seeks its replay to the end; the open marker is the state at open,
544
+ * so the UI seeks to 0 instead. Older recordings lack this flag — the UI
545
+ * falls back to the marker's sentinel shape (exitCode -1, durationMs 0).
546
+ */
547
+ interactive?: boolean;
540
548
  }
541
549
 
542
550
  /** Action taken on an open interactive terminal session. */
package/src/terminal.ts CHANGED
@@ -305,8 +305,22 @@ export async function openTerminal(args: OpenTerminalArgs): Promise<InternalTerm
305
305
  // sendLine / press so the recorded `castTimeSec` lands on the echo's
306
306
  // frame instead of microseconds before it.
307
307
  const frameWaiters: Array<() => void> = [];
308
+ // Time of the most recently ingested frame. Two jobs: (1) frame
309
+ // timestamps are forced strictly increasing (Date.now() has ms
310
+ // resolution, so a fast PTY can land two frames — or two *ops* —
311
+ // in the same millisecond), and (2) it is what `recordOpEvent`
312
+ // stamps as the step's `castTimeSec`. Together they make the
313
+ // player's `seek(castTimeSec)` (which applies frames `<= target`)
314
+ // land on exactly the frames this op had seen: the step's own last
315
+ // frame is included, and the next op's first frame can never share
316
+ // the stamp. Before this, a `waitFor` that matched in the same
317
+ // millisecond as the following `sendLine`'s echo showed that echo
318
+ // — the replay ran one step ahead of the step list.
319
+ let lastFrameT = 0;
308
320
  const ingest = (chunk: string): void => {
309
- const t = (Date.now() - start) / 1000;
321
+ let t = (Date.now() - start) / 1000;
322
+ if (t <= lastFrameT) t = lastFrameT + 0.001;
323
+ lastFrameT = t;
310
324
  sink.pushFrame(t, chunk);
311
325
  emu.write(chunk);
312
326
  if (output.length < OUTPUT_CAP_BYTES) {
@@ -397,8 +411,8 @@ export async function openTerminal(args: OpenTerminalArgs): Promise<InternalTerm
397
411
  }
398
412
  // Record cast time AFTER drain, so the `exit` step we record below
399
413
  // points the player at the very last frame (the program's final
400
- // output before EOF).
401
- exitCastTimeSec = (Date.now() - start) / 1000;
414
+ // output before EOF). Same between-frames stamp as `recordOpEvent`.
415
+ exitCastTimeSec = lastFrameT + 0.0005;
402
416
  sink.markClosed();
403
417
  resolveExit(code);
404
418
  })();
@@ -444,10 +458,17 @@ export async function openTerminal(args: OpenTerminalArgs): Promise<InternalTerm
444
458
  ): number | undefined => {
445
459
  if (!args.recordEvents) return undefined;
446
460
  const preview = truncateUtf8(renderScreen());
447
- // Use the SAME clock the asciicast frames use (offset from `start`,
448
- // which is captured at Bun.spawn). That way the UI seeks the player
449
- // directly to this value with no frame arithmetic.
450
- const castTimeSec = (Date.now() - start) / 1000;
461
+ // Stamp the step half a millisecond past its last ingested frame:
462
+ // frame times are strictly increasing in 1ms steps (see `ingest`),
463
+ // so the stamp sits strictly BETWEEN the op's own last frame and
464
+ // any later op's first frame. That makes the boundary unambiguous
465
+ // in both directions — an inclusive seek (`<= stamp`) shows the
466
+ // op's own frame and can never pull in the next op's, and the
467
+ // viewer's exclusive `waitFor` seek (stamp − 0.0004, which
468
+ // disambiguates legacy wall-clock stamps that tied with the next
469
+ // op's echo) still lands past the op's own frame. Same clock as
470
+ // the frames (offset from `start`), so the UI seeks directly.
471
+ const castTimeSec = lastFrameT + 0.0005;
451
472
  // Return the event seq so callers can `wrap()` their result value
452
473
  // against it — e.g. `expect(await term.waitFor(...))` then links its
453
474
  // assertion to this step (sourceSeq) and the UI nests it here.