glost-processor 0.5.0 → 0.7.0

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.
@@ -0,0 +1,992 @@
1
+ /**
2
+ * GLOST Processor Tests
3
+ *
4
+ * Comprehensive test suite for the unified-style GLOST processor.
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach } from "vitest";
8
+ import { glost, GLOSTProcessor } from "../index.js";
9
+ import type {
10
+ Plugin,
11
+ PluginSpec,
12
+ Preset,
13
+ ProcessorOptions,
14
+ BeforeHook,
15
+ AfterHook,
16
+ ErrorHook,
17
+ SkipHook,
18
+ ProgressHook,
19
+ } from "../types.js";
20
+ import type { GLOSTRoot, GLOSTExtension } from "glost-extensions";
21
+
22
+ // Mock GLOST document for testing
23
+ const createMockDocument = (): GLOSTRoot => ({
24
+ type: "RootNode",
25
+ lang: "en",
26
+ children: [
27
+ {
28
+ type: "ParagraphNode",
29
+ children: [
30
+ {
31
+ type: "SentenceNode",
32
+ children: [
33
+ {
34
+ type: "WordNode",
35
+ text: "Hello",
36
+ metadata: {},
37
+ extras: {},
38
+ },
39
+ {
40
+ type: "WordNode",
41
+ text: "world",
42
+ metadata: {},
43
+ extras: {},
44
+ },
45
+ ],
46
+ metadata: {},
47
+ extras: {},
48
+ },
49
+ ],
50
+ metadata: {},
51
+ extras: {},
52
+ },
53
+ ],
54
+ metadata: {},
55
+ extras: {},
56
+ });
57
+
58
+ // Mock extensions for testing
59
+ const createMockExtension = (id: string, name: string): GLOSTExtension => ({
60
+ id,
61
+ name,
62
+ description: `Mock ${name} extension`,
63
+ transform: (tree) => tree,
64
+ });
65
+
66
+ const transcriptionExtension: GLOSTExtension = {
67
+ id: "transcription",
68
+ name: "Transcription",
69
+ description: "Add transcription to words",
70
+ visit: {
71
+ word: (node) => {
72
+ node.extras = {
73
+ ...node.extras,
74
+ transcription: `[${node.text.toLowerCase()}]`,
75
+ };
76
+ },
77
+ },
78
+ };
79
+
80
+ const translationExtension: GLOSTExtension = {
81
+ id: "translation",
82
+ name: "Translation",
83
+ description: "Add translation to words",
84
+ visit: {
85
+ word: (node) => {
86
+ node.extras = {
87
+ ...node.extras,
88
+ translation: { en: node.text },
89
+ };
90
+ },
91
+ },
92
+ };
93
+
94
+ const errorExtension: GLOSTExtension = {
95
+ id: "error-plugin",
96
+ name: "Error Plugin",
97
+ description: "Always throws an error",
98
+ transform: () => {
99
+ throw new Error("Plugin error");
100
+ },
101
+ };
102
+
103
+ describe("glost() factory function", () => {
104
+ it("creates a new processor instance", () => {
105
+ const processor = glost();
106
+ expect(processor).toBeInstanceOf(GLOSTProcessor);
107
+ });
108
+
109
+ it("accepts initial options", () => {
110
+ const processor = glost({ lenient: true, debug: true });
111
+ expect(processor).toBeInstanceOf(GLOSTProcessor);
112
+ });
113
+
114
+ it("accepts data map in options", () => {
115
+ const dataMap = new Map([["key", "value"]]);
116
+ const processor = glost({ data: dataMap });
117
+ expect(processor.data("key")).toBe("value");
118
+ });
119
+ });
120
+
121
+ describe("GLOSTProcessor", () => {
122
+ describe("constructor", () => {
123
+ it("creates instance with default options", () => {
124
+ const processor = new GLOSTProcessor();
125
+ expect(processor).toBeInstanceOf(GLOSTProcessor);
126
+ });
127
+
128
+ it("creates instance with custom options", () => {
129
+ const options: ProcessorOptions = {
130
+ lenient: true,
131
+ conflictStrategy: "warn",
132
+ };
133
+ const processor = new GLOSTProcessor(options);
134
+ expect(processor).toBeInstanceOf(GLOSTProcessor);
135
+ });
136
+
137
+ it("initializes data store from options", () => {
138
+ const dataMap = new Map([
139
+ ["config", { theme: "dark" }],
140
+ ["version", 1],
141
+ ]);
142
+ const processor = new GLOSTProcessor({ data: dataMap });
143
+ expect(processor.data("config")).toEqual({ theme: "dark" });
144
+ expect(processor.data("version")).toBe(1);
145
+ });
146
+ });
147
+
148
+ describe("use() - plugin chaining", () => {
149
+ it("allows chaining multiple plugins", () => {
150
+ const processor = glost()
151
+ .use(transcriptionExtension)
152
+ .use(translationExtension);
153
+
154
+ expect(processor).toBeInstanceOf(GLOSTProcessor);
155
+ });
156
+
157
+ it("accepts extension objects", () => {
158
+ const processor = glost().use(transcriptionExtension);
159
+ expect(processor).toBeInstanceOf(GLOSTProcessor);
160
+ });
161
+
162
+ it("accepts plugin functions", () => {
163
+ const plugin: Plugin = (options) => ({
164
+ id: "test-plugin",
165
+ name: "Test Plugin",
166
+ transform: (tree) => tree,
167
+ });
168
+
169
+ const processor = glost().use(plugin, { option: true });
170
+ expect(processor).toBeInstanceOf(GLOSTProcessor);
171
+ });
172
+
173
+ it("passes options to plugin functions", async () => {
174
+ const pluginFn = vi.fn((options) => ({
175
+ id: "test",
176
+ name: "Test",
177
+ transform: (tree: GLOSTRoot) => tree,
178
+ }));
179
+
180
+ const processor = glost().use(pluginFn, { custom: "option" });
181
+ await processor.process(createMockDocument());
182
+
183
+ expect(pluginFn).toHaveBeenCalledWith({ custom: "option" });
184
+ });
185
+
186
+ it("accepts presets", () => {
187
+ const preset: Preset = {
188
+ id: "test-preset",
189
+ name: "Test Preset",
190
+ plugins: [transcriptionExtension, translationExtension],
191
+ };
192
+
193
+ const processor = glost().use(preset);
194
+ expect(processor).toBeInstanceOf(GLOSTProcessor);
195
+ });
196
+
197
+ it("expands preset plugins with options", () => {
198
+ const plugin1 = createMockExtension("plugin1", "Plugin 1");
199
+ const plugin2 = createMockExtension("plugin2", "Plugin 2");
200
+
201
+ const preset: Preset = {
202
+ id: "test-preset",
203
+ name: "Test Preset",
204
+ plugins: [
205
+ [plugin1, { opt1: true }],
206
+ plugin2,
207
+ ],
208
+ };
209
+
210
+ const processor = glost().use(preset);
211
+ expect(processor).toBeInstanceOf(GLOSTProcessor);
212
+ });
213
+
214
+ it("throws when adding plugins to frozen processor", () => {
215
+ const processor = glost()
216
+ .use(transcriptionExtension)
217
+ .freeze();
218
+
219
+ expect(() => {
220
+ (processor as any).use(translationExtension);
221
+ }).toThrow("Cannot modify frozen processor");
222
+ });
223
+ });
224
+
225
+ describe("hooks - before()", () => {
226
+ it("registers before hooks", () => {
227
+ const hook = vi.fn();
228
+ const processor = glost()
229
+ .before("transcription", hook)
230
+ .use(transcriptionExtension);
231
+
232
+ expect(processor).toBeInstanceOf(GLOSTProcessor);
233
+ });
234
+
235
+ it("executes before hooks in order", async () => {
236
+ const calls: string[] = [];
237
+ const hook1: BeforeHook = () => {
238
+ calls.push("before1");
239
+ };
240
+ const hook2: BeforeHook = () => {
241
+ calls.push("before2");
242
+ };
243
+
244
+ const processor = glost()
245
+ .before("transcription", hook1)
246
+ .before("transcription", hook2)
247
+ .use(transcriptionExtension);
248
+
249
+ await processor.process(createMockDocument());
250
+
251
+ expect(calls).toEqual(["before1", "before2"]);
252
+ });
253
+
254
+ it("passes document and plugin ID to before hooks", async () => {
255
+ const hook = vi.fn();
256
+ const processor = glost()
257
+ .before("transcription", hook)
258
+ .use(transcriptionExtension);
259
+
260
+ const doc = createMockDocument();
261
+ await processor.process(doc);
262
+
263
+ expect(hook).toHaveBeenCalledWith(
264
+ expect.objectContaining({ type: "RootNode" }),
265
+ "transcription"
266
+ );
267
+ });
268
+
269
+ it("supports async before hooks", async () => {
270
+ const calls: string[] = [];
271
+ const asyncHook: BeforeHook = async () => {
272
+ await new Promise((resolve) => setTimeout(resolve, 10));
273
+ calls.push("async-before");
274
+ };
275
+
276
+ const processor = glost()
277
+ .before("transcription", asyncHook)
278
+ .use(transcriptionExtension);
279
+
280
+ await processor.process(createMockDocument());
281
+
282
+ expect(calls).toEqual(["async-before"]);
283
+ });
284
+
285
+ it("throws when adding hooks to frozen processor", () => {
286
+ const processor = glost().freeze();
287
+
288
+ expect(() => {
289
+ (processor as any).before("test", () => {});
290
+ }).toThrow("Cannot modify frozen processor");
291
+ });
292
+ });
293
+
294
+ describe("hooks - after()", () => {
295
+ it("registers after hooks", () => {
296
+ const hook = vi.fn();
297
+ const processor = glost()
298
+ .use(transcriptionExtension)
299
+ .after("transcription", hook);
300
+
301
+ expect(processor).toBeInstanceOf(GLOSTProcessor);
302
+ });
303
+
304
+ it("executes after hooks in order", async () => {
305
+ const calls: string[] = [];
306
+ const hook1: AfterHook = () => {
307
+ calls.push("after1");
308
+ };
309
+ const hook2: AfterHook = () => {
310
+ calls.push("after2");
311
+ };
312
+
313
+ const processor = glost()
314
+ .use(transcriptionExtension)
315
+ .after("transcription", hook1)
316
+ .after("transcription", hook2);
317
+
318
+ await processor.process(createMockDocument());
319
+
320
+ expect(calls).toEqual(["after1", "after2"]);
321
+ });
322
+
323
+ it("passes processed document to after hooks", async () => {
324
+ const hook = vi.fn();
325
+ const processor = glost()
326
+ .use(transcriptionExtension)
327
+ .after("transcription", hook);
328
+
329
+ await processor.process(createMockDocument());
330
+
331
+ expect(hook).toHaveBeenCalledWith(
332
+ expect.objectContaining({ type: "RootNode" }),
333
+ "transcription"
334
+ );
335
+ });
336
+
337
+ it("supports async after hooks", async () => {
338
+ const calls: string[] = [];
339
+ const asyncHook: AfterHook = async () => {
340
+ await new Promise((resolve) => setTimeout(resolve, 10));
341
+ calls.push("async-after");
342
+ };
343
+
344
+ const processor = glost()
345
+ .use(transcriptionExtension)
346
+ .after("transcription", asyncHook);
347
+
348
+ await processor.process(createMockDocument());
349
+
350
+ expect(calls).toEqual(["async-after"]);
351
+ });
352
+ });
353
+
354
+ describe("hooks - onError()", () => {
355
+ it("registers error hooks", () => {
356
+ const hook = vi.fn();
357
+ const processor = glost({ lenient: true }).onError(hook);
358
+
359
+ expect(processor).toBeInstanceOf(GLOSTProcessor);
360
+ });
361
+
362
+ it("calls error hooks when plugin throws in strict mode", async () => {
363
+ const hook = vi.fn();
364
+ const processor = glost()
365
+ .onError(hook)
366
+ .use(errorExtension);
367
+
368
+ // In strict mode, error will be thrown, but hook should be called first
369
+ await expect(processor.process(createMockDocument())).rejects.toThrow();
370
+
371
+ expect(hook).toHaveBeenCalledWith(
372
+ expect.objectContaining({ message: "Plugin error" }),
373
+ "error-plugin"
374
+ );
375
+ });
376
+
377
+ it("does not call error hooks for extension errors in lenient mode", async () => {
378
+ // In lenient mode, processGLOSTWithExtensionsAsync catches errors
379
+ // and returns them in metadata without throwing, so onError hooks
380
+ // are not triggered
381
+ const hook = vi.fn();
382
+ const processor = glost({ lenient: true })
383
+ .onError(hook)
384
+ .use(errorExtension);
385
+
386
+ const result = await processor.processWithMeta(createMockDocument());
387
+
388
+ // Error should be in metadata
389
+ expect(result.metadata.errors).toHaveLength(1);
390
+ // But onError hook should not be called
391
+ expect(hook).not.toHaveBeenCalled();
392
+ });
393
+
394
+ it("catches errors in error hooks when they are called", async () => {
395
+ const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
396
+ const badHook: ErrorHook = () => {
397
+ throw new Error("Hook error");
398
+ };
399
+
400
+ const processor = glost()
401
+ .onError(badHook)
402
+ .use(errorExtension);
403
+
404
+ // This will throw the original error, but should catch hook error
405
+ await expect(processor.process(createMockDocument())).rejects.toThrow(
406
+ "Plugin error"
407
+ );
408
+
409
+ expect(consoleSpy).toHaveBeenCalledWith(
410
+ "Error in error hook:",
411
+ expect.any(Error)
412
+ );
413
+
414
+ consoleSpy.mockRestore();
415
+ });
416
+ });
417
+
418
+ describe("hooks - onSkip()", () => {
419
+ it("registers skip hooks", () => {
420
+ const hook = vi.fn();
421
+ const processor = glost().onSkip(hook);
422
+
423
+ expect(processor).toBeInstanceOf(GLOSTProcessor);
424
+ });
425
+
426
+ it("calls skip hooks when plugin is skipped in lenient mode", async () => {
427
+ const hook = vi.fn();
428
+ const processor = glost({ lenient: true })
429
+ .onSkip(hook)
430
+ .use(errorExtension);
431
+
432
+ await processor.process(createMockDocument());
433
+
434
+ // When processGLOSTWithExtensionsAsync catches an error and marks it
435
+ // as skipped, the processor calls skip with "Skipped by processor"
436
+ expect(hook).toHaveBeenCalledWith("error-plugin", "Skipped by processor");
437
+ });
438
+
439
+ it("calls skip hooks with error message in strict mode", async () => {
440
+ const hook = vi.fn();
441
+ const processor = glost()
442
+ .onSkip(hook)
443
+ .use(errorExtension);
444
+
445
+ // In strict mode, the error is caught by processor's catch block
446
+ await expect(processor.process(createMockDocument())).rejects.toThrow();
447
+
448
+ expect(hook).toHaveBeenCalledWith("error-plugin", "Plugin error");
449
+ });
450
+ });
451
+
452
+ describe("hooks - onProgress()", () => {
453
+ it("registers progress hooks", () => {
454
+ const hook = vi.fn();
455
+ const processor = glost().onProgress(hook);
456
+
457
+ expect(processor).toBeInstanceOf(GLOSTProcessor);
458
+ });
459
+
460
+ it("calls progress hooks during processing", async () => {
461
+ const hook = vi.fn();
462
+ const processor = glost()
463
+ .onProgress(hook)
464
+ .use(transcriptionExtension)
465
+ .use(translationExtension);
466
+
467
+ await processor.process(createMockDocument());
468
+
469
+ // Should be called at start (0/2) and after each plugin (1/2, 2/2)
470
+ expect(hook).toHaveBeenCalledTimes(3);
471
+ });
472
+
473
+ it("provides progress statistics", async () => {
474
+ const hook = vi.fn();
475
+ const processor = glost()
476
+ .onProgress(hook)
477
+ .use(transcriptionExtension);
478
+
479
+ await processor.process(createMockDocument());
480
+
481
+ expect(hook).toHaveBeenCalledWith(
482
+ expect.objectContaining({
483
+ total: 1,
484
+ completed: expect.any(Number),
485
+ startTime: expect.any(Number),
486
+ elapsed: expect.any(Number),
487
+ })
488
+ );
489
+ });
490
+ });
491
+
492
+ describe("data() - data store", () => {
493
+ it("sets and gets data", () => {
494
+ const processor = glost();
495
+ processor.data("key", "value");
496
+
497
+ expect(processor.data("key")).toBe("value");
498
+ });
499
+
500
+ it("supports chaining when setting data", () => {
501
+ const processor = glost()
502
+ .data("config", { theme: "dark" })
503
+ .data("version", 1);
504
+
505
+ expect(processor.data("config")).toEqual({ theme: "dark" });
506
+ expect(processor.data("version")).toBe(1);
507
+ });
508
+
509
+ it("returns undefined for non-existent keys", () => {
510
+ const processor = glost();
511
+ expect(processor.data("nonexistent")).toBeUndefined();
512
+ });
513
+
514
+ it("stores complex objects", () => {
515
+ const processor = glost();
516
+ const complexData = {
517
+ nested: { value: 42 },
518
+ array: [1, 2, 3],
519
+ };
520
+
521
+ processor.data("complex", complexData);
522
+ expect(processor.data("complex")).toEqual(complexData);
523
+ });
524
+
525
+ it("throws when setting data on frozen processor", () => {
526
+ const processor = glost().freeze();
527
+
528
+ expect(() => {
529
+ (processor as any).data("key", "value");
530
+ }).toThrow("Cannot modify frozen processor");
531
+ });
532
+
533
+ it("allows getting data from frozen processor", () => {
534
+ const processor = glost().data("key", "value");
535
+ const frozen = processor.freeze();
536
+
537
+ expect(frozen.data("key")).toBe("value");
538
+ });
539
+ });
540
+
541
+ describe("freeze()", () => {
542
+ it("creates a frozen copy of the processor", () => {
543
+ const processor = glost()
544
+ .use(transcriptionExtension)
545
+ .data("config", { value: 1 });
546
+
547
+ const frozen = processor.freeze();
548
+
549
+ expect((frozen as any).frozen).toBe(true);
550
+ });
551
+
552
+ it("frozen processor can still process documents", async () => {
553
+ const processor = glost()
554
+ .use(transcriptionExtension)
555
+ .freeze();
556
+
557
+ const doc = createMockDocument();
558
+ const result = await processor.process(doc);
559
+
560
+ expect(result.type).toBe("RootNode");
561
+ });
562
+
563
+ it("frozen processor reuses plugin configuration", async () => {
564
+ const processor = glost()
565
+ .use(transcriptionExtension)
566
+ .use(translationExtension)
567
+ .freeze();
568
+
569
+ const doc1 = createMockDocument();
570
+ const doc2 = createMockDocument();
571
+
572
+ const result1 = await processor.process(doc1);
573
+ const result2 = await processor.process(doc2);
574
+
575
+ expect(result1.type).toBe("RootNode");
576
+ expect(result2.type).toBe("RootNode");
577
+ });
578
+
579
+ it("frozen processor preserves hooks", async () => {
580
+ const hook = vi.fn();
581
+ const processor = glost()
582
+ .use(transcriptionExtension)
583
+ .after("transcription", hook)
584
+ .freeze();
585
+
586
+ await processor.process(createMockDocument());
587
+
588
+ expect(hook).toHaveBeenCalled();
589
+ });
590
+
591
+ it("frozen processor preserves data", () => {
592
+ const processor = glost()
593
+ .data("key", "value")
594
+ .freeze();
595
+
596
+ expect(processor.data("key")).toBe("value");
597
+ });
598
+
599
+ it("does not affect original processor", () => {
600
+ const processor = glost()
601
+ .use(transcriptionExtension)
602
+ .data("original", true);
603
+
604
+ const frozen = processor.freeze();
605
+
606
+ // Modify original
607
+ processor.data("modified", true);
608
+
609
+ // Frozen should not have the modification
610
+ expect((frozen as any).data("modified")).toBeUndefined();
611
+ expect(processor.data("modified")).toBe(true);
612
+ });
613
+ });
614
+
615
+ describe("process() - document processing", () => {
616
+ it("processes a document through the pipeline", async () => {
617
+ const processor = glost().use(transcriptionExtension);
618
+ const doc = createMockDocument();
619
+
620
+ const result = await processor.process(doc);
621
+
622
+ expect(result.type).toBe("RootNode");
623
+ });
624
+
625
+ it("applies plugins in order", async () => {
626
+ const calls: string[] = [];
627
+
628
+ const plugin1: GLOSTExtension = {
629
+ id: "plugin1",
630
+ name: "Plugin 1",
631
+ transform: (tree) => {
632
+ calls.push("plugin1");
633
+ return tree;
634
+ },
635
+ };
636
+
637
+ const plugin2: GLOSTExtension = {
638
+ id: "plugin2",
639
+ name: "Plugin 2",
640
+ transform: (tree) => {
641
+ calls.push("plugin2");
642
+ return tree;
643
+ },
644
+ };
645
+
646
+ const processor = glost().use(plugin1).use(plugin2);
647
+ await processor.process(createMockDocument());
648
+
649
+ expect(calls).toEqual(["plugin1", "plugin2"]);
650
+ });
651
+
652
+ it("returns processed document", async () => {
653
+ const processor = glost().use(transcriptionExtension);
654
+ const doc = createMockDocument();
655
+
656
+ const result = await processor.process(doc);
657
+
658
+ expect(result).toBeDefined();
659
+ expect(result.type).toBe("RootNode");
660
+ });
661
+
662
+ it("processes without plugins", async () => {
663
+ const processor = glost();
664
+ const doc = createMockDocument();
665
+
666
+ const result = await processor.process(doc);
667
+
668
+ expect(result).toEqual(doc);
669
+ });
670
+
671
+ it("throws on error in strict mode", async () => {
672
+ const processor = glost().use(errorExtension);
673
+
674
+ await expect(processor.process(createMockDocument())).rejects.toThrow(
675
+ "Plugin error"
676
+ );
677
+ });
678
+
679
+ it("continues on error in lenient mode", async () => {
680
+ const processor = glost({ lenient: true })
681
+ .use(errorExtension)
682
+ .use(transcriptionExtension);
683
+
684
+ const result = await processor.process(createMockDocument());
685
+
686
+ expect(result).toBeDefined();
687
+ });
688
+ });
689
+
690
+ describe("processWithMeta() - detailed processing", () => {
691
+ it("returns document and metadata", async () => {
692
+ const processor = glost().use(transcriptionExtension);
693
+ const doc = createMockDocument();
694
+
695
+ const result = await processor.processWithMeta(doc);
696
+
697
+ expect(result.document).toBeDefined();
698
+ expect(result.metadata).toBeDefined();
699
+ });
700
+
701
+ it("tracks applied plugins", async () => {
702
+ const processor = glost()
703
+ .use(transcriptionExtension)
704
+ .use(translationExtension);
705
+
706
+ const result = await processor.processWithMeta(createMockDocument());
707
+
708
+ expect(result.metadata.appliedPlugins).toContain("transcription");
709
+ expect(result.metadata.appliedPlugins).toContain("translation");
710
+ });
711
+
712
+ it("tracks skipped plugins", async () => {
713
+ const processor = glost({ lenient: true })
714
+ .use(errorExtension)
715
+ .use(transcriptionExtension);
716
+
717
+ const result = await processor.processWithMeta(createMockDocument());
718
+
719
+ expect(result.metadata.skippedPlugins).toContain("error-plugin");
720
+ expect(result.metadata.appliedPlugins).toContain("transcription");
721
+ });
722
+
723
+ it("includes processing errors", async () => {
724
+ const processor = glost({ lenient: true }).use(errorExtension);
725
+
726
+ const result = await processor.processWithMeta(createMockDocument());
727
+
728
+ expect(result.metadata.errors).toHaveLength(1);
729
+ expect(result.metadata.errors[0]).toMatchObject({
730
+ plugin: "error-plugin",
731
+ phase: "transform",
732
+ message: "Plugin error",
733
+ // recoverable: true when caught by processGLOSTWithExtensionsAsync
734
+ // recoverable: false only when caught in processor's catch block
735
+ recoverable: true,
736
+ });
737
+ });
738
+
739
+ it("provides processing statistics", async () => {
740
+ const processor = glost().use(transcriptionExtension);
741
+
742
+ const result = await processor.processWithMeta(createMockDocument());
743
+
744
+ expect(result.metadata.stats).toMatchObject({
745
+ totalTime: expect.any(Number),
746
+ timing: expect.any(Map),
747
+ startTime: expect.any(Number),
748
+ endTime: expect.any(Number),
749
+ });
750
+ });
751
+
752
+ it("records timing per plugin", async () => {
753
+ const processor = glost()
754
+ .use(transcriptionExtension)
755
+ .use(translationExtension);
756
+
757
+ const result = await processor.processWithMeta(createMockDocument());
758
+
759
+ expect(result.metadata.stats.timing.has("transcription")).toBe(true);
760
+ expect(result.metadata.stats.timing.has("translation")).toBe(true);
761
+ expect(result.metadata.stats.timing.get("transcription")).toBeGreaterThanOrEqual(0);
762
+ expect(result.metadata.stats.timing.get("translation")).toBeGreaterThanOrEqual(0);
763
+ });
764
+
765
+ it("initializes warnings array", async () => {
766
+ const processor = glost().use(transcriptionExtension);
767
+
768
+ const result = await processor.processWithMeta(createMockDocument());
769
+
770
+ expect(result.metadata.warnings).toEqual([]);
771
+ });
772
+ });
773
+
774
+ describe("processSync()", () => {
775
+ it("throws not implemented error", () => {
776
+ const processor = glost();
777
+ const doc = createMockDocument();
778
+
779
+ expect(() => processor.processSync(doc)).toThrow(
780
+ "Synchronous processing not yet implemented"
781
+ );
782
+ });
783
+ });
784
+
785
+ describe("plugin resolution", () => {
786
+ it("resolves plugin functions to extensions", async () => {
787
+ const plugin: Plugin = () => ({
788
+ id: "test",
789
+ name: "Test",
790
+ transform: (tree) => tree,
791
+ });
792
+
793
+ const processor = glost().use(plugin);
794
+ const result = await processor.process(createMockDocument());
795
+
796
+ expect(result).toBeDefined();
797
+ });
798
+
799
+ it("uses extension objects directly", async () => {
800
+ const processor = glost().use(transcriptionExtension);
801
+ const result = await processor.process(createMockDocument());
802
+
803
+ expect(result).toBeDefined();
804
+ });
805
+
806
+ it("handles plugin functions that return void", async () => {
807
+ const plugin: Plugin = () => {
808
+ return undefined as any;
809
+ };
810
+
811
+ const processor = glost().use(plugin);
812
+ const result = await processor.process(createMockDocument());
813
+
814
+ // Should skip the plugin but not crash
815
+ expect(result).toBeDefined();
816
+ });
817
+ });
818
+
819
+ describe("error handling", () => {
820
+ it("propagates errors in strict mode", async () => {
821
+ const processor = glost().use(errorExtension);
822
+
823
+ await expect(processor.process(createMockDocument())).rejects.toThrow(
824
+ "Plugin error"
825
+ );
826
+ });
827
+
828
+ it("continues processing in lenient mode", async () => {
829
+ const processor = glost({ lenient: true })
830
+ .use(errorExtension)
831
+ .use(transcriptionExtension);
832
+
833
+ const result = await processor.process(createMockDocument());
834
+
835
+ expect(result).toBeDefined();
836
+ });
837
+
838
+ it("records all errors in lenient mode", async () => {
839
+ const error1: GLOSTExtension = {
840
+ id: "error1",
841
+ name: "Error 1",
842
+ transform: () => {
843
+ throw new Error("Error 1");
844
+ },
845
+ };
846
+
847
+ const error2: GLOSTExtension = {
848
+ id: "error2",
849
+ name: "Error 2",
850
+ transform: () => {
851
+ throw new Error("Error 2");
852
+ },
853
+ };
854
+
855
+ const processor = glost({ lenient: true }).use(error1).use(error2);
856
+
857
+ const result = await processor.processWithMeta(createMockDocument());
858
+
859
+ expect(result.metadata.errors).toHaveLength(2);
860
+ expect(result.metadata.errors[0]!.message).toBe("Error 1");
861
+ expect(result.metadata.errors[1]!.message).toBe("Error 2");
862
+ });
863
+
864
+ it("converts non-Error values to Error objects", async () => {
865
+ const stringError: GLOSTExtension = {
866
+ id: "string-error",
867
+ name: "String Error",
868
+ transform: () => {
869
+ throw "String error message";
870
+ },
871
+ };
872
+
873
+ const processor = glost({ lenient: true }).use(stringError);
874
+
875
+ const result = await processor.processWithMeta(createMockDocument());
876
+
877
+ expect(result.metadata.errors[0]).toMatchObject({
878
+ plugin: "string-error",
879
+ message: "String error message",
880
+ });
881
+ });
882
+ });
883
+
884
+ describe("integration scenarios", () => {
885
+ it("supports complex pipeline with hooks and data", async () => {
886
+ const calls: string[] = [];
887
+
888
+ const processor = glost()
889
+ .data("config", { enabled: true })
890
+ .before("transcription", () => {
891
+ calls.push("before-transcription");
892
+ })
893
+ .use(transcriptionExtension)
894
+ .after("transcription", () => {
895
+ calls.push("after-transcription");
896
+ })
897
+ .before("translation", () => {
898
+ calls.push("before-translation");
899
+ })
900
+ .use(translationExtension)
901
+ .after("translation", () => {
902
+ calls.push("after-translation");
903
+ })
904
+ .onProgress((stats) => {
905
+ if (stats.completed === stats.total) {
906
+ calls.push("complete");
907
+ }
908
+ });
909
+
910
+ await processor.process(createMockDocument());
911
+
912
+ expect(calls).toEqual([
913
+ "before-transcription",
914
+ "after-transcription",
915
+ "before-translation",
916
+ "after-translation",
917
+ "complete",
918
+ ]);
919
+ });
920
+
921
+ it("reuses frozen processor for multiple documents", async () => {
922
+ const callCount = vi.fn();
923
+
924
+ const countingExtension: GLOSTExtension = {
925
+ id: "counter",
926
+ name: "Counter",
927
+ transform: (tree) => {
928
+ callCount();
929
+ return tree;
930
+ },
931
+ };
932
+
933
+ const frozen = glost().use(countingExtension).freeze();
934
+
935
+ await frozen.process(createMockDocument());
936
+ await frozen.process(createMockDocument());
937
+ await frozen.process(createMockDocument());
938
+
939
+ expect(callCount).toHaveBeenCalledTimes(3);
940
+ });
941
+
942
+ it("handles preset expansion correctly", async () => {
943
+ const calls: string[] = [];
944
+
945
+ const plugin1: GLOSTExtension = {
946
+ id: "plugin1",
947
+ name: "Plugin 1",
948
+ transform: (tree) => {
949
+ calls.push("plugin1");
950
+ return tree;
951
+ },
952
+ };
953
+
954
+ const plugin2: GLOSTExtension = {
955
+ id: "plugin2",
956
+ name: "Plugin 2",
957
+ transform: (tree) => {
958
+ calls.push("plugin2");
959
+ return tree;
960
+ },
961
+ };
962
+
963
+ const preset: Preset = {
964
+ id: "test-preset",
965
+ name: "Test Preset",
966
+ plugins: [plugin1, plugin2],
967
+ };
968
+
969
+ const processor = glost().use(preset);
970
+ await processor.process(createMockDocument());
971
+
972
+ expect(calls).toEqual(["plugin1", "plugin2"]);
973
+ });
974
+
975
+ it("provides detailed timing information", async () => {
976
+ const slowExtension: GLOSTExtension = {
977
+ id: "slow",
978
+ name: "Slow",
979
+ transform: async (tree) => {
980
+ await new Promise((resolve) => setTimeout(resolve, 50));
981
+ return tree;
982
+ },
983
+ };
984
+
985
+ const processor = glost().use(slowExtension);
986
+ const result = await processor.processWithMeta(createMockDocument());
987
+
988
+ expect(result.metadata.stats.timing.get("slow")).toBeGreaterThanOrEqual(50);
989
+ expect(result.metadata.stats.totalTime).toBeGreaterThanOrEqual(50);
990
+ });
991
+ });
992
+ });