@sharpee/transcript-tester 0.9.61-beta

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.
Files changed (137) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/LICENSE +21 -0
  3. package/dist/cli.d.ts +11 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +367 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/condition-evaluator.d.ts +30 -0
  8. package/dist/condition-evaluator.d.ts.map +1 -0
  9. package/dist/condition-evaluator.js +314 -0
  10. package/dist/condition-evaluator.js.map +1 -0
  11. package/dist/fast-cli.d.ts +13 -0
  12. package/dist/fast-cli.d.ts.map +1 -0
  13. package/dist/fast-cli.js +363 -0
  14. package/dist/fast-cli.js.map +1 -0
  15. package/dist/index.d.ts +17 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +48 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/navigator.d.ts +27 -0
  20. package/dist/navigator.d.ts.map +1 -0
  21. package/dist/navigator.js +303 -0
  22. package/dist/navigator.js.map +1 -0
  23. package/dist/parser.d.ts +19 -0
  24. package/dist/parser.d.ts.map +1 -0
  25. package/dist/parser.js +453 -0
  26. package/dist/parser.js.map +1 -0
  27. package/dist/reporter.d.ts +41 -0
  28. package/dist/reporter.d.ts.map +1 -0
  29. package/dist/reporter.js +386 -0
  30. package/dist/reporter.js.map +1 -0
  31. package/dist/runner.d.ts +44 -0
  32. package/dist/runner.d.ts.map +1 -0
  33. package/dist/runner.js +977 -0
  34. package/dist/runner.js.map +1 -0
  35. package/dist/story-loader.d.ts +31 -0
  36. package/dist/story-loader.d.ts.map +1 -0
  37. package/dist/story-loader.js +169 -0
  38. package/dist/story-loader.js.map +1 -0
  39. package/dist/types.d.ts +204 -0
  40. package/dist/types.d.ts.map +1 -0
  41. package/dist/types.js +8 -0
  42. package/dist/types.js.map +1 -0
  43. package/dist-esm/cli.d.ts +11 -0
  44. package/dist-esm/cli.d.ts.map +1 -0
  45. package/dist-esm/cli.js +332 -0
  46. package/dist-esm/cli.js.map +1 -0
  47. package/dist-esm/condition-evaluator.d.ts +30 -0
  48. package/dist-esm/condition-evaluator.d.ts.map +1 -0
  49. package/dist-esm/condition-evaluator.js +311 -0
  50. package/dist-esm/condition-evaluator.js.map +1 -0
  51. package/dist-esm/fast-cli.d.ts +13 -0
  52. package/dist-esm/fast-cli.d.ts.map +1 -0
  53. package/dist-esm/fast-cli.js +328 -0
  54. package/dist-esm/fast-cli.js.map +1 -0
  55. package/dist-esm/index.d.ts +17 -0
  56. package/dist-esm/index.d.ts.map +1 -0
  57. package/dist-esm/index.js +21 -0
  58. package/dist-esm/index.js.map +1 -0
  59. package/dist-esm/navigator.d.ts +27 -0
  60. package/dist-esm/navigator.d.ts.map +1 -0
  61. package/dist-esm/navigator.js +300 -0
  62. package/dist-esm/navigator.js.map +1 -0
  63. package/dist-esm/parser.d.ts +19 -0
  64. package/dist-esm/parser.d.ts.map +1 -0
  65. package/dist-esm/parser.js +415 -0
  66. package/dist-esm/parser.js.map +1 -0
  67. package/dist-esm/reporter.d.ts +41 -0
  68. package/dist-esm/reporter.d.ts.map +1 -0
  69. package/dist-esm/reporter.js +342 -0
  70. package/dist-esm/reporter.js.map +1 -0
  71. package/dist-esm/runner.d.ts +44 -0
  72. package/dist-esm/runner.d.ts.map +1 -0
  73. package/dist-esm/runner.js +941 -0
  74. package/dist-esm/runner.js.map +1 -0
  75. package/dist-esm/story-loader.d.ts +31 -0
  76. package/dist-esm/story-loader.d.ts.map +1 -0
  77. package/dist-esm/story-loader.js +131 -0
  78. package/dist-esm/story-loader.js.map +1 -0
  79. package/dist-esm/types.d.ts +204 -0
  80. package/dist-esm/types.d.ts.map +1 -0
  81. package/dist-esm/types.js +7 -0
  82. package/dist-esm/types.js.map +1 -0
  83. package/dist-npm/cli.d.ts +11 -0
  84. package/dist-npm/cli.d.ts.map +1 -0
  85. package/dist-npm/cli.js +367 -0
  86. package/dist-npm/cli.js.map +1 -0
  87. package/dist-npm/condition-evaluator.d.ts +30 -0
  88. package/dist-npm/condition-evaluator.d.ts.map +1 -0
  89. package/dist-npm/condition-evaluator.js +314 -0
  90. package/dist-npm/condition-evaluator.js.map +1 -0
  91. package/dist-npm/fast-cli.d.ts +13 -0
  92. package/dist-npm/fast-cli.d.ts.map +1 -0
  93. package/dist-npm/fast-cli.js +363 -0
  94. package/dist-npm/fast-cli.js.map +1 -0
  95. package/dist-npm/index.d.ts +17 -0
  96. package/dist-npm/index.d.ts.map +1 -0
  97. package/dist-npm/index.js +48 -0
  98. package/dist-npm/index.js.map +1 -0
  99. package/dist-npm/navigator.d.ts +27 -0
  100. package/dist-npm/navigator.d.ts.map +1 -0
  101. package/dist-npm/navigator.js +303 -0
  102. package/dist-npm/navigator.js.map +1 -0
  103. package/dist-npm/parser.d.ts +19 -0
  104. package/dist-npm/parser.d.ts.map +1 -0
  105. package/dist-npm/parser.js +453 -0
  106. package/dist-npm/parser.js.map +1 -0
  107. package/dist-npm/reporter.d.ts +41 -0
  108. package/dist-npm/reporter.d.ts.map +1 -0
  109. package/dist-npm/reporter.js +386 -0
  110. package/dist-npm/reporter.js.map +1 -0
  111. package/dist-npm/runner.d.ts +44 -0
  112. package/dist-npm/runner.d.ts.map +1 -0
  113. package/dist-npm/runner.js +977 -0
  114. package/dist-npm/runner.js.map +1 -0
  115. package/dist-npm/story-loader.d.ts +31 -0
  116. package/dist-npm/story-loader.d.ts.map +1 -0
  117. package/dist-npm/story-loader.js +169 -0
  118. package/dist-npm/story-loader.js.map +1 -0
  119. package/dist-npm/types.d.ts +204 -0
  120. package/dist-npm/types.d.ts.map +1 -0
  121. package/dist-npm/types.js +8 -0
  122. package/dist-npm/types.js.map +1 -0
  123. package/package.json +49 -0
  124. package/src/cli.ts +385 -0
  125. package/src/condition-evaluator.ts +382 -0
  126. package/src/fast-cli.ts +403 -0
  127. package/src/index.ts +26 -0
  128. package/src/navigator.ts +365 -0
  129. package/src/parser.ts +488 -0
  130. package/src/reporter.ts +409 -0
  131. package/src/runner.ts +1152 -0
  132. package/src/story-loader.ts +168 -0
  133. package/src/types.ts +244 -0
  134. package/tsconfig.esm.json +11 -0
  135. package/tsconfig.esm.tsbuildinfo +1 -0
  136. package/tsconfig.json +22 -0
  137. package/tsconfig.tsbuildinfo +1 -0
package/src/parser.ts ADDED
@@ -0,0 +1,488 @@
1
+ /**
2
+ * Transcript Parser
3
+ *
4
+ * Parses .transcript files into a structured format for testing.
5
+ */
6
+
7
+ import * as fs from 'fs';
8
+ import * as path from 'path';
9
+ import {
10
+ Transcript,
11
+ TranscriptHeader,
12
+ TranscriptCommand,
13
+ TranscriptItem,
14
+ Directive,
15
+ GoalDefinition,
16
+ Assertion
17
+ } from './types';
18
+
19
+ /**
20
+ * Parse a transcript file from disk
21
+ */
22
+ export function parseTranscriptFile(filePath: string): Transcript {
23
+ const content = fs.readFileSync(filePath, 'utf-8');
24
+ return parseTranscript(content, filePath);
25
+ }
26
+
27
+ /**
28
+ * Parse transcript content string
29
+ */
30
+ export function parseTranscript(content: string, filePath: string = '<inline>'): Transcript {
31
+ const lines = content.split('\n');
32
+ const transcript: Transcript = {
33
+ filePath,
34
+ header: {},
35
+ commands: [],
36
+ items: [],
37
+ goals: [],
38
+ comments: []
39
+ };
40
+
41
+ let inHeader = true;
42
+ let currentCommand: TranscriptCommand | null = null;
43
+ let lineNumber = 0;
44
+
45
+ for (const line of lines) {
46
+ lineNumber++;
47
+ const trimmed = line.trim();
48
+
49
+ // Empty lines
50
+ if (trimmed === '') {
51
+ if (currentCommand && currentCommand.expectedOutput.length > 0) {
52
+ // Empty line in output - preserve it
53
+ currentCommand.expectedOutput.push('');
54
+ }
55
+ continue;
56
+ }
57
+
58
+ // Header separator
59
+ if (trimmed === '---') {
60
+ inHeader = false;
61
+ continue;
62
+ }
63
+
64
+ // Comments - add to both comments array (legacy) and items array (for annotation context)
65
+ if (trimmed.startsWith('#') && !trimmed.startsWith('#[')) {
66
+ const commentText = trimmed.slice(1).trim();
67
+ transcript.comments.push(commentText);
68
+ // Also add as item for annotation processing
69
+ transcript.items!.push({
70
+ type: 'comment',
71
+ comment: { lineNumber, text: commentText },
72
+ });
73
+ continue;
74
+ }
75
+
76
+ // $ directives ($save, $restore) - these are standalone directives
77
+ if (trimmed.startsWith('$')) {
78
+ // Save any pending command first
79
+ if (currentCommand) {
80
+ finalizeCommand(currentCommand);
81
+ transcript.commands.push(currentCommand);
82
+ transcript.items!.push({ type: 'command', command: currentCommand });
83
+ currentCommand = null;
84
+ }
85
+
86
+ const directive = parseDollarDirective(trimmed, lineNumber);
87
+ if (directive) {
88
+ transcript.items!.push({ type: 'directive', directive });
89
+ }
90
+ continue;
91
+ }
92
+
93
+ // Header lines (key: value)
94
+ if (inHeader && trimmed.includes(':') && !trimmed.startsWith('>')) {
95
+ const colonIndex = trimmed.indexOf(':');
96
+ const key = trimmed.slice(0, colonIndex).trim().toLowerCase();
97
+ const value = trimmed.slice(colonIndex + 1).trim();
98
+ transcript.header[key] = value;
99
+ continue;
100
+ }
101
+
102
+ // Command input
103
+ if (trimmed.startsWith('>')) {
104
+ // Save previous command
105
+ if (currentCommand) {
106
+ finalizeCommand(currentCommand);
107
+ transcript.commands.push(currentCommand);
108
+ transcript.items!.push({ type: 'command', command: currentCommand });
109
+ }
110
+
111
+ currentCommand = {
112
+ lineNumber,
113
+ input: trimmed.slice(1).trim(),
114
+ expectedOutput: [],
115
+ assertions: []
116
+ };
117
+ continue;
118
+ }
119
+
120
+ // Directive or assertion tags (both use [ ])
121
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
122
+ // Try to parse as directive first
123
+ const directive = parseDirective(trimmed, lineNumber);
124
+ if (directive) {
125
+ // Save any pending command first
126
+ if (currentCommand) {
127
+ finalizeCommand(currentCommand);
128
+ transcript.commands.push(currentCommand);
129
+ transcript.items!.push({ type: 'command', command: currentCommand });
130
+ currentCommand = null;
131
+ }
132
+ transcript.items!.push({ type: 'directive', directive });
133
+ continue;
134
+ }
135
+
136
+ // Not a directive - try as assertion (must be attached to a command)
137
+ if (currentCommand) {
138
+ const assertion = parseAssertion(trimmed);
139
+ if (assertion) {
140
+ currentCommand.assertions.push(assertion);
141
+ }
142
+ }
143
+ continue;
144
+ }
145
+
146
+ // Expected output lines
147
+ if (currentCommand) {
148
+ currentCommand.expectedOutput.push(line); // Preserve original indentation
149
+ }
150
+ }
151
+
152
+ // Don't forget the last command
153
+ if (currentCommand) {
154
+ finalizeCommand(currentCommand);
155
+ transcript.commands.push(currentCommand);
156
+ transcript.items!.push({ type: 'command', command: currentCommand });
157
+ }
158
+
159
+ // Parse goal segments from items
160
+ transcript.goals = parseGoals(transcript.items!);
161
+
162
+ return transcript;
163
+ }
164
+
165
+ /**
166
+ * Parse a directive tag like [GOAL: name], [IF: condition], [NAVIGATE TO: "Room"]
167
+ */
168
+ function parseDirective(tag: string, lineNumber: number): Directive | null {
169
+ const inner = tag.slice(1, -1).trim(); // Remove [ ]
170
+
171
+ // [GOAL: name]
172
+ const goalMatch = inner.match(/^GOAL:\s*(.+)$/i);
173
+ if (goalMatch) {
174
+ return { type: 'goal', lineNumber, goalName: goalMatch[1].trim() };
175
+ }
176
+
177
+ // [END GOAL]
178
+ if (inner.toUpperCase() === 'END GOAL') {
179
+ return { type: 'end_goal', lineNumber };
180
+ }
181
+
182
+ // [REQUIRES: condition]
183
+ const requiresMatch = inner.match(/^REQUIRES:\s*(.+)$/i);
184
+ if (requiresMatch) {
185
+ return { type: 'requires', lineNumber, condition: requiresMatch[1].trim() };
186
+ }
187
+
188
+ // [ENSURES: condition]
189
+ const ensuresMatch = inner.match(/^ENSURES:\s*(.+)$/i);
190
+ if (ensuresMatch) {
191
+ return { type: 'ensures', lineNumber, condition: ensuresMatch[1].trim() };
192
+ }
193
+
194
+ // [IF: condition]
195
+ const ifMatch = inner.match(/^IF:\s*(.+)$/i);
196
+ if (ifMatch) {
197
+ return { type: 'if', lineNumber, condition: ifMatch[1].trim() };
198
+ }
199
+
200
+ // [END IF]
201
+ if (inner.toUpperCase() === 'END IF') {
202
+ return { type: 'end_if', lineNumber };
203
+ }
204
+
205
+ // [WHILE: condition]
206
+ const whileMatch = inner.match(/^WHILE:\s*(.+)$/i);
207
+ if (whileMatch) {
208
+ return { type: 'while', lineNumber, condition: whileMatch[1].trim() };
209
+ }
210
+
211
+ // [END WHILE]
212
+ if (inner.toUpperCase() === 'END WHILE') {
213
+ return { type: 'end_while', lineNumber };
214
+ }
215
+
216
+ // [NAVIGATE TO: "Room Name"]
217
+ const navigateMatch = inner.match(/^NAVIGATE\s+TO:\s*"([^"]+)"$/i);
218
+ if (navigateMatch) {
219
+ return { type: 'navigate', lineNumber, target: navigateMatch[1] };
220
+ }
221
+
222
+ // Not a directive
223
+ return null;
224
+ }
225
+
226
+ /**
227
+ * Parse a $ directive like $save <name>, $restore <name>, or ext-testing commands
228
+ */
229
+ function parseDollarDirective(line: string, lineNumber: number): Directive | null {
230
+ const trimmed = line.trim();
231
+
232
+ // $save <name>
233
+ const saveMatch = trimmed.match(/^\$save\s+(.+)$/i);
234
+ if (saveMatch) {
235
+ return { type: 'save', lineNumber, saveName: saveMatch[1].trim() };
236
+ }
237
+
238
+ // $restore <name>
239
+ const restoreMatch = trimmed.match(/^\$restore\s+(.+)$/i);
240
+ if (restoreMatch) {
241
+ return { type: 'restore', lineNumber, saveName: restoreMatch[1].trim() };
242
+ }
243
+
244
+ // Any other $ directive is a test command (ext-testing)
245
+ // Valid test commands: $teleport, $take, $move, $kill, $immortal, $mortal, $state, $describe, etc.
246
+ const testCommandMatch = trimmed.match(/^\$(\w+)(.*)$/);
247
+ if (testCommandMatch) {
248
+ return { type: 'test-command', lineNumber, testCommand: trimmed };
249
+ }
250
+
251
+ return null;
252
+ }
253
+
254
+ /**
255
+ * Parse goal segments from items array
256
+ */
257
+ function parseGoals(items: TranscriptItem[]): GoalDefinition[] {
258
+ const goals: GoalDefinition[] = [];
259
+ let currentGoal: Partial<GoalDefinition> | null = null;
260
+ let goalStartIndex = -1;
261
+
262
+ for (let i = 0; i < items.length; i++) {
263
+ const item = items[i];
264
+ if (item.type !== 'directive') continue;
265
+
266
+ const directive = item.directive!;
267
+
268
+ switch (directive.type) {
269
+ case 'goal':
270
+ if (currentGoal) {
271
+ console.warn(`Line ${directive.lineNumber}: Nested goals not allowed. Closing previous goal.`);
272
+ goals.push({
273
+ ...currentGoal,
274
+ endIndex: i - 1
275
+ } as GoalDefinition);
276
+ }
277
+ currentGoal = {
278
+ name: directive.goalName!,
279
+ lineNumber: directive.lineNumber,
280
+ requires: [],
281
+ ensures: [],
282
+ startIndex: i + 1
283
+ };
284
+ goalStartIndex = i;
285
+ break;
286
+
287
+ case 'requires':
288
+ if (currentGoal && directive.condition) {
289
+ currentGoal.requires!.push(directive.condition);
290
+ }
291
+ break;
292
+
293
+ case 'ensures':
294
+ if (currentGoal && directive.condition) {
295
+ currentGoal.ensures!.push(directive.condition);
296
+ }
297
+ break;
298
+
299
+ case 'end_goal':
300
+ if (currentGoal) {
301
+ goals.push({
302
+ ...currentGoal,
303
+ endIndex: i
304
+ } as GoalDefinition);
305
+ currentGoal = null;
306
+ }
307
+ break;
308
+ }
309
+ }
310
+
311
+ // Handle unclosed goal
312
+ if (currentGoal) {
313
+ console.warn(`Unclosed goal: ${currentGoal.name}`);
314
+ goals.push({
315
+ ...currentGoal,
316
+ endIndex: items.length - 1
317
+ } as GoalDefinition);
318
+ }
319
+
320
+ return goals;
321
+ }
322
+
323
+ /**
324
+ * Parse an assertion tag like [OK], [OK: contains "foo"], [FAIL: reason]
325
+ */
326
+ function parseAssertion(tag: string): Assertion | null {
327
+ const inner = tag.slice(1, -1).trim(); // Remove [ ]
328
+
329
+ // [OK] - exact match
330
+ if (inner === 'OK') {
331
+ return { type: 'ok' };
332
+ }
333
+
334
+ // [SKIP]
335
+ if (inner === 'SKIP') {
336
+ return { type: 'skip' };
337
+ }
338
+
339
+ // [OK: contains "text"]
340
+ const containsMatch = inner.match(/^OK:\s*contains\s+"([^"]+)"$/i);
341
+ if (containsMatch) {
342
+ return { type: 'ok-contains', value: containsMatch[1] };
343
+ }
344
+
345
+ // [OK: not contains "text"]
346
+ const notContainsMatch = inner.match(/^OK:\s*not\s+contains\s+"([^"]+)"$/i);
347
+ if (notContainsMatch) {
348
+ return { type: 'ok-not-contains', value: notContainsMatch[1] };
349
+ }
350
+
351
+ // [OK: matches /regex/flags]
352
+ const matchesMatch = inner.match(/^OK:\s*matches\s+\/(.+)\/([gimsuy]*)$/i);
353
+ if (matchesMatch) {
354
+ try {
355
+ return {
356
+ type: 'ok-matches',
357
+ pattern: new RegExp(matchesMatch[1], matchesMatch[2])
358
+ };
359
+ } catch (e) {
360
+ console.error(`Invalid regex in assertion: ${tag}`);
361
+ return null;
362
+ }
363
+ }
364
+
365
+ // [FAIL: reason]
366
+ const failMatch = inner.match(/^FAIL(?::\s*(.+))?$/i);
367
+ if (failMatch) {
368
+ return { type: 'fail', reason: failMatch[1] || 'Expected failure' };
369
+ }
370
+
371
+ // [TODO: note]
372
+ const todoMatch = inner.match(/^TODO(?::\s*(.+))?$/i);
373
+ if (todoMatch) {
374
+ return { type: 'todo', reason: todoMatch[1] || 'Not implemented' };
375
+ }
376
+
377
+ // [EVENTS: N] - exact event count
378
+ const eventsMatch = inner.match(/^EVENTS:\s*(\d+)$/i);
379
+ if (eventsMatch) {
380
+ return { type: 'event-count', eventCount: parseInt(eventsMatch[1], 10) };
381
+ }
382
+
383
+ // [EVENT: true|false, N?, type="..." key="value"]
384
+ // Format: [EVENT: true, 1, type="if.event.pushed" target="y09"]
385
+ // [EVENT: false, type="if.event.destroyed"]
386
+ const eventAssertMatch = inner.match(/^EVENT:\s*(true|false)\s*,\s*(.+)$/i);
387
+ if (eventAssertMatch) {
388
+ const assertTrue = eventAssertMatch[1].toLowerCase() === 'true';
389
+ const rest = eventAssertMatch[2];
390
+
391
+ // Check if there's a position number before the type
392
+ const positionMatch = rest.match(/^(\d+)\s*,\s*(.+)$/);
393
+ let eventPosition: number | undefined;
394
+ let propsStr: string;
395
+
396
+ if (positionMatch) {
397
+ eventPosition = parseInt(positionMatch[1], 10);
398
+ propsStr = positionMatch[2];
399
+ } else {
400
+ propsStr = rest;
401
+ }
402
+
403
+ // Parse key="value" pairs
404
+ const eventData: Record<string, any> = {};
405
+ let eventType: string | undefined;
406
+ const propRegex = /(\w+)="([^"]+)"/g;
407
+ let match;
408
+ while ((match = propRegex.exec(propsStr)) !== null) {
409
+ const [, key, value] = match;
410
+ if (key === 'type') {
411
+ eventType = value;
412
+ } else {
413
+ eventData[key] = value;
414
+ }
415
+ }
416
+
417
+ if (eventType) {
418
+ return {
419
+ type: 'event-assert',
420
+ assertTrue,
421
+ eventPosition,
422
+ eventType,
423
+ eventData: Object.keys(eventData).length > 0 ? eventData : undefined
424
+ };
425
+ }
426
+ }
427
+
428
+ // [STATE: true|false, expression]
429
+ // Format: [STATE: true, egg.location = thief]
430
+ // [STATE: false, player.canSee(egg)]
431
+ const stateAssertMatch = inner.match(/^STATE:\s*(true|false)\s*,\s*(.+)$/i);
432
+ if (stateAssertMatch) {
433
+ const assertTrue = stateAssertMatch[1].toLowerCase() === 'true';
434
+ const expression = stateAssertMatch[2].trim();
435
+ return {
436
+ type: 'state-assert',
437
+ assertTrue,
438
+ stateExpression: expression
439
+ };
440
+ }
441
+
442
+ console.warn(`Unknown assertion format: ${tag}`);
443
+ return null;
444
+ }
445
+
446
+ /**
447
+ * Clean up a command before adding to transcript
448
+ */
449
+ function finalizeCommand(command: TranscriptCommand): void {
450
+ // Trim trailing empty lines from expected output
451
+ while (command.expectedOutput.length > 0 &&
452
+ command.expectedOutput[command.expectedOutput.length - 1].trim() === '') {
453
+ command.expectedOutput.pop();
454
+ }
455
+
456
+ // If no explicit assertion and we have expected output, default to [OK]
457
+ if (command.assertions.length === 0 && command.expectedOutput.length > 0) {
458
+ command.assertions.push({ type: 'ok' });
459
+ }
460
+
461
+ // If no assertion at all, default to [SKIP]
462
+ if (command.assertions.length === 0) {
463
+ command.assertions.push({ type: 'skip' });
464
+ }
465
+ }
466
+
467
+ /**
468
+ * Validate a transcript for common issues
469
+ */
470
+ export function validateTranscript(transcript: Transcript): string[] {
471
+ const errors: string[] = [];
472
+
473
+ if (transcript.commands.length === 0) {
474
+ errors.push('Transcript has no commands');
475
+ }
476
+
477
+ if (!transcript.header.story && !transcript.header.title) {
478
+ errors.push('Transcript should have a title or story in header');
479
+ }
480
+
481
+ for (const cmd of transcript.commands) {
482
+ if (!cmd.input) {
483
+ errors.push(`Line ${cmd.lineNumber}: Empty command`);
484
+ }
485
+ }
486
+
487
+ return errors;
488
+ }