framer-code-link 0.2.0 → 0.2.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.
@@ -1,891 +0,0 @@
1
- import { describe, it, expect } from "vitest"
2
- import { filterEchoedFiles, transition } from "./controller.js"
3
- import { createHashTracker } from "./utils/hash-tracker.js"
4
- import type { WebSocket } from "ws"
5
- describe("State Machine", () => {
6
- describe("HANDSHAKE transition", () => {
7
- it("transitions from disconnected to handshaking", () => {
8
- const initialState = {
9
- mode: "disconnected" as const,
10
- socket: null,
11
- files: new Map(),
12
- queuedDiffs: [],
13
- pendingOperations: new Map(),
14
- nextOperationId: 1,
15
- }
16
-
17
- const mockSocket = {} as WebSocket
18
- const result = transition(initialState, {
19
- type: "HANDSHAKE",
20
- socket: mockSocket,
21
- projectInfo: { projectId: "test-id", projectName: "Test Project" },
22
- })
23
-
24
- expect(result.state.mode).toBe("handshaking")
25
- expect(result.state.socket).toBe(mockSocket)
26
- expect(result.effects).toHaveLength(3)
27
- expect(result.effects[0]).toMatchObject({ type: "INIT_WORKSPACE" })
28
- expect(result.effects[1]).toMatchObject({ type: "LOAD_PERSISTED_STATE" })
29
- expect(result.effects[2]).toMatchObject({
30
- type: "SEND_MESSAGE",
31
- payload: { type: "request-files" },
32
- })
33
- })
34
-
35
- it("ignores handshake when not in disconnected mode", () => {
36
- const initialState = {
37
- mode: "watching" as const,
38
- socket: {} as WebSocket,
39
- files: new Map(),
40
- queuedDiffs: [],
41
- pendingOperations: new Map(),
42
- nextOperationId: 1,
43
- }
44
-
45
- const result = transition(initialState, {
46
- type: "HANDSHAKE",
47
- socket: {} as WebSocket,
48
- projectInfo: { projectId: "test-id", projectName: "Test Project" },
49
- })
50
-
51
- expect(result.state.mode).toBe("watching")
52
- expect(result.effects).toHaveLength(1)
53
- expect(result.effects[0]).toMatchObject({
54
- type: "LOG",
55
- level: "warn",
56
- })
57
- })
58
- })
59
-
60
- describe("DISCONNECT transition", () => {
61
- it("transitions to disconnected and persists state", () => {
62
- const initialState = {
63
- mode: "watching" as const,
64
- socket: {} as WebSocket,
65
- files: new Map([
66
- [
67
- "Test.tsx",
68
- {
69
- localHash: "abc123",
70
- lastSyncedHash: "abc123",
71
- lastRemoteTimestamp: Date.now(),
72
- },
73
- ],
74
- ]),
75
- queuedDiffs: [],
76
- pendingOperations: new Map(),
77
- nextOperationId: 1,
78
- }
79
-
80
- const result = transition(initialState, { type: "DISCONNECT" })
81
-
82
- expect(result.state.mode).toBe("disconnected")
83
- expect(result.state.socket).toBe(null)
84
- expect(result.effects).toHaveLength(2)
85
- expect(result.effects[0]).toMatchObject({ type: "PERSIST_STATE" })
86
- expect(result.effects[1]).toMatchObject({
87
- type: "LOG",
88
- level: "debug",
89
- })
90
- })
91
- })
92
-
93
- describe("FILE_LIST transition", () => {
94
- it("transitions to snapshot_processing and emits DETECT_CONFLICTS", () => {
95
- const initialState = {
96
- mode: "handshaking" as const,
97
- socket: {} as WebSocket,
98
- files: new Map(),
99
- queuedDiffs: [],
100
- pendingOperations: new Map(),
101
- nextOperationId: 1,
102
- }
103
-
104
- const remoteFiles = [
105
- { name: "Test.tsx", content: "remote content", modifiedAt: Date.now() },
106
- ]
107
-
108
- const result = transition(initialState, {
109
- type: "FILE_LIST",
110
- files: remoteFiles,
111
- })
112
-
113
- expect(result.state.mode).toBe("snapshot_processing")
114
- expect(result.state.queuedDiffs).toEqual(remoteFiles)
115
- expect(result.effects).toHaveLength(2)
116
- expect(result.effects[0]).toMatchObject({
117
- type: "LOG",
118
- level: "debug",
119
- })
120
- expect(result.effects[1]).toMatchObject({
121
- type: "DETECT_CONFLICTS",
122
- remoteFiles,
123
- })
124
- })
125
-
126
- it("ignores FILE_LIST when not in handshaking mode", () => {
127
- const initialState = {
128
- mode: "watching" as const,
129
- socket: {} as WebSocket,
130
- files: new Map(),
131
- queuedDiffs: [],
132
- pendingOperations: new Map(),
133
- nextOperationId: 1,
134
- }
135
-
136
- const result = transition(initialState, {
137
- type: "FILE_LIST",
138
- files: [],
139
- })
140
-
141
- expect(result.state.mode).toBe("watching")
142
- expect(result.effects).toHaveLength(1)
143
- expect(result.effects[0]).toMatchObject({
144
- type: "LOG",
145
- level: "warn",
146
- })
147
- })
148
- })
149
-
150
- describe("CONFLICTS_DETECTED transition", () => {
151
- it("applies safe writes and transitions to watching when no conflicts", () => {
152
- // detectConflicts already did auto-resolution, so we just get safeWrites
153
- const initialState = {
154
- mode: "snapshot_processing" as const,
155
- socket: {} as WebSocket,
156
- files: new Map(),
157
- queuedDiffs: [],
158
- pendingOperations: new Map(),
159
- nextOperationId: 1,
160
- }
161
-
162
- const result = transition(initialState, {
163
- type: "CONFLICTS_DETECTED",
164
- conflicts: [], // No conflicts - detectConflicts already resolved them
165
- safeWrites: [
166
- {
167
- name: "Test.tsx",
168
- content: "new content",
169
- modifiedAt: Date.now(),
170
- },
171
- ],
172
- localOnly: [],
173
- })
174
-
175
- expect(result.state.mode).toBe("watching")
176
- expect("pendingConflicts" in result.state).toBe(false)
177
- // Should have logs + WRITE_FILES + PERSIST_STATE
178
- expect(result.effects.length).toBeGreaterThan(2)
179
- expect(result.effects.some((e) => e.type === "WRITE_FILES")).toBe(true)
180
- expect(result.effects.some((e) => e.type === "PERSIST_STATE")).toBe(true)
181
- })
182
-
183
- it("transitions to conflict_resolution when manual conflicts exist", () => {
184
- // detectConflicts returns conflicts when both sides changed
185
- const initialState = {
186
- mode: "snapshot_processing" as const,
187
- socket: {} as WebSocket,
188
- files: new Map(),
189
- queuedDiffs: [],
190
- pendingOperations: new Map(),
191
- nextOperationId: 1,
192
- }
193
-
194
- const conflict = {
195
- fileName: "Test.tsx",
196
- localContent: "local content",
197
- remoteContent: "remote content",
198
- localModifiedAt: Date.now(),
199
- remoteModifiedAt: Date.now() + 1000,
200
- }
201
-
202
- const result = transition(initialState, {
203
- type: "CONFLICTS_DETECTED",
204
- conflicts: [conflict],
205
- safeWrites: [],
206
- localOnly: [],
207
- })
208
-
209
- expect(result.state.mode).toBe("conflict_resolution")
210
- if (result.state.mode === "conflict_resolution") {
211
- expect(result.state.pendingConflicts).toHaveLength(1)
212
- }
213
- expect(
214
- result.effects.some((e) => e.type === "REQUEST_CONFLICT_VERSIONS")
215
- ).toBe(true)
216
- })
217
- })
218
-
219
- describe("FILE_CHANGE transition", () => {
220
- it("applies changes immediately in watching mode", () => {
221
- const initialState = {
222
- mode: "watching" as const,
223
- socket: {} as WebSocket,
224
- files: new Map(),
225
- queuedDiffs: [],
226
- pendingOperations: new Map(),
227
- nextOperationId: 1,
228
- }
229
-
230
- const file = {
231
- name: "Test.tsx",
232
- content: "new content",
233
- modifiedAt: Date.now(),
234
- }
235
-
236
- const result = transition(initialState, {
237
- type: "FILE_CHANGE",
238
- file,
239
- })
240
-
241
- expect(result.state.mode).toBe("watching")
242
- expect(result.effects.some((e) => e.type === "WRITE_FILES")).toBe(true)
243
- })
244
-
245
- it("queues changes during snapshot processing", () => {
246
- const initialState = {
247
- mode: "snapshot_processing" as const,
248
- socket: {} as WebSocket,
249
- files: new Map(),
250
- queuedDiffs: [],
251
- pendingOperations: new Map(),
252
- nextOperationId: 1,
253
- }
254
-
255
- const file = {
256
- name: "Test.tsx",
257
- content: "new content",
258
- modifiedAt: Date.now(),
259
- }
260
-
261
- const result = transition(initialState, {
262
- type: "FILE_CHANGE",
263
- file,
264
- })
265
-
266
- expect(result.state.mode).toBe("snapshot_processing")
267
- expect(result.state.queuedDiffs).toHaveLength(1)
268
- expect(result.state.queuedDiffs[0]).toEqual(file)
269
- expect(result.effects.some((e) => e.type === "WRITE_FILES")).toBe(false)
270
- })
271
- })
272
-
273
- describe("REMOTE_FILE_DELETE transition", () => {
274
- it("applies delete immediately in watching mode", () => {
275
- const initialState = {
276
- mode: "watching" as const,
277
- socket: {} as WebSocket,
278
- files: new Map([
279
- [
280
- "Test.tsx",
281
- {
282
- localHash: "abc123",
283
- lastSyncedHash: "abc123",
284
- lastRemoteTimestamp: Date.now(),
285
- },
286
- ],
287
- ]),
288
- queuedDiffs: [],
289
- pendingOperations: new Map(),
290
- nextOperationId: 1,
291
- }
292
-
293
- const result = transition(initialState, {
294
- type: "REMOTE_FILE_DELETE",
295
- fileName: "Test.tsx",
296
- })
297
-
298
- expect(result.state.mode).toBe("watching")
299
- expect(result.effects.some((e) => e.type === "DELETE_LOCAL_FILES")).toBe(
300
- true
301
- )
302
- const deleteEffect = result.effects.find(
303
- (e) => e.type === "DELETE_LOCAL_FILES"
304
- )
305
- expect(deleteEffect).toMatchObject({
306
- type: "DELETE_LOCAL_FILES",
307
- names: ["Test.tsx"],
308
- })
309
- expect(result.effects.some((e) => e.type === "PERSIST_STATE")).toBe(true)
310
- })
311
-
312
- it("applies deletes immediately during snapshot processing", () => {
313
- const initialState = {
314
- mode: "snapshot_processing" as const,
315
- socket: {} as WebSocket,
316
- files: new Map(),
317
- queuedDiffs: [],
318
- pendingOperations: new Map(),
319
- nextOperationId: 1,
320
- }
321
-
322
- const result = transition(initialState, {
323
- type: "REMOTE_FILE_DELETE",
324
- fileName: "Test.tsx",
325
- })
326
-
327
- expect(result.state.mode).toBe("snapshot_processing")
328
- expect(result.effects.some((e) => e.type === "DELETE_LOCAL_FILES")).toBe(
329
- true
330
- )
331
- expect(result.effects.some((e) => e.type === "LOG")).toBe(true)
332
- })
333
-
334
- it("rejects deletes while disconnected", () => {
335
- const initialState = {
336
- mode: "disconnected" as const,
337
- socket: null,
338
- files: new Map(),
339
- queuedDiffs: [],
340
- pendingOperations: new Map(),
341
- nextOperationId: 1,
342
- }
343
-
344
- const result = transition(initialState, {
345
- type: "REMOTE_FILE_DELETE",
346
- fileName: "Test.tsx",
347
- })
348
-
349
- expect(result.state.mode).toBe("disconnected")
350
- expect(result.effects.some((e) => e.type === "DELETE_LOCAL_FILES")).toBe(
351
- false
352
- )
353
- expect(
354
- result.effects.some((e) => e.type === "LOG" && e.level === "warn")
355
- ).toBe(true)
356
- })
357
- })
358
-
359
- describe("REMOTE_DELETE_CONFIRMED transition", () => {
360
- it("applies the delete and persists state", () => {
361
- const initialState = {
362
- mode: "watching" as const,
363
- socket: {} as WebSocket,
364
- files: new Map([
365
- [
366
- "Test.tsx",
367
- {
368
- localHash: "abc123",
369
- lastSyncedHash: "abc123",
370
- lastRemoteTimestamp: Date.now(),
371
- },
372
- ],
373
- ]),
374
- queuedDiffs: [],
375
- pendingOperations: new Map(),
376
- nextOperationId: 1,
377
- }
378
-
379
- const result = transition(initialState, {
380
- type: "REMOTE_DELETE_CONFIRMED",
381
- fileName: "Test.tsx",
382
- })
383
-
384
- expect(result.state.mode).toBe("watching")
385
- expect(result.effects.some((e) => e.type === "DELETE_LOCAL_FILES")).toBe(
386
- true
387
- )
388
- expect(result.effects.some((e) => e.type === "PERSIST_STATE")).toBe(true)
389
- })
390
- })
391
-
392
- describe("REMOTE_DELETE_CANCELLED transition", () => {
393
- it("restores the file", () => {
394
- const initialState = {
395
- mode: "watching" as const,
396
- socket: {} as WebSocket,
397
- files: new Map(),
398
- queuedDiffs: [],
399
- pendingOperations: new Map(),
400
- nextOperationId: 1,
401
- }
402
-
403
- const result = transition(initialState, {
404
- type: "REMOTE_DELETE_CANCELLED",
405
- fileName: "Test.tsx",
406
- content: "restored content",
407
- })
408
-
409
- expect(result.state.mode).toBe("watching")
410
- expect(result.effects.some((e) => e.type === "WRITE_FILES")).toBe(true)
411
- const writeEffect = result.effects.find((e) => e.type === "WRITE_FILES")
412
- expect(writeEffect).toMatchObject({
413
- type: "WRITE_FILES",
414
- files: [
415
- {
416
- name: "Test.tsx",
417
- content: "restored content",
418
- },
419
- ],
420
- })
421
- })
422
- })
423
-
424
- describe("REQUEST_FILES transition", () => {
425
- it("emits LIST_LOCAL_FILES effect when in watching mode", () => {
426
- const initialState = {
427
- mode: "watching" as const,
428
- socket: {} as WebSocket,
429
- files: new Map(),
430
- queuedDiffs: [],
431
- pendingOperations: new Map(),
432
- nextOperationId: 1,
433
- }
434
-
435
- const result = transition(initialState, {
436
- type: "REQUEST_FILES",
437
- })
438
-
439
- expect(result.state.mode).toBe("watching")
440
- expect(result.effects.some((e) => e.type === "LIST_LOCAL_FILES")).toBe(
441
- true
442
- )
443
- })
444
-
445
- it("rejects request when disconnected", () => {
446
- const initialState = {
447
- mode: "disconnected" as const,
448
- socket: null,
449
- files: new Map(),
450
- queuedDiffs: [],
451
- pendingOperations: new Map(),
452
- nextOperationId: 1,
453
- }
454
-
455
- const result = transition(initialState, {
456
- type: "REQUEST_FILES",
457
- })
458
-
459
- expect(result.state.mode).toBe("disconnected")
460
- expect(result.effects.some((e) => e.type === "LIST_LOCAL_FILES")).toBe(
461
- false
462
- )
463
- expect(
464
- result.effects.some((e) => e.type === "LOG" && e.level === "warn")
465
- ).toBe(true)
466
- })
467
- })
468
-
469
- describe("WATCHER_EVENT transition", () => {
470
- it("emits SEND_LOCAL_CHANGE for file add/change in watching mode", () => {
471
- const initialState = {
472
- mode: "watching" as const,
473
- socket: {} as WebSocket,
474
- files: new Map(),
475
- queuedDiffs: [],
476
- pendingOperations: new Map(),
477
- nextOperationId: 1,
478
- }
479
-
480
- const result = transition(initialState, {
481
- type: "WATCHER_EVENT",
482
- event: {
483
- kind: "change",
484
- relativePath: "Test.tsx",
485
- content: "export const Test = () => <div>Test</div>",
486
- },
487
- })
488
-
489
- expect(result.state.mode).toBe("watching")
490
- expect(result.effects.some((e) => e.type === "SEND_LOCAL_CHANGE")).toBe(
491
- true
492
- )
493
- const sendEffect = result.effects.find(
494
- (e) => e.type === "SEND_LOCAL_CHANGE"
495
- )
496
- expect(sendEffect).toMatchObject({
497
- type: "SEND_LOCAL_CHANGE",
498
- fileName: "Test.tsx",
499
- content: "export const Test = () => <div>Test</div>",
500
- })
501
- })
502
-
503
- it("emits REQUEST_LOCAL_DELETE_DECISION for file delete", () => {
504
- const initialState = {
505
- mode: "watching" as const,
506
- socket: {} as WebSocket,
507
- files: new Map(),
508
- queuedDiffs: [],
509
- pendingOperations: new Map(),
510
- nextOperationId: 1,
511
- }
512
-
513
- const result = transition(initialState, {
514
- type: "WATCHER_EVENT",
515
- event: {
516
- kind: "delete",
517
- relativePath: "Test.tsx",
518
- },
519
- })
520
-
521
- expect(
522
- result.effects.some((e) => e.type === "REQUEST_LOCAL_DELETE_DECISION")
523
- ).toBe(true)
524
- })
525
-
526
- it("ignores events when not in watching mode", () => {
527
- const initialState = {
528
- mode: "handshaking" as const,
529
- socket: {} as WebSocket,
530
- files: new Map(),
531
- queuedDiffs: [],
532
- pendingOperations: new Map(),
533
- nextOperationId: 1,
534
- }
535
-
536
- const result = transition(initialState, {
537
- type: "WATCHER_EVENT",
538
- event: {
539
- kind: "change",
540
- relativePath: "Test.tsx",
541
- content: "content",
542
- },
543
- })
544
-
545
- expect(result.effects.some((e) => e.type === "SEND_LOCAL_CHANGE")).toBe(
546
- false
547
- )
548
- })
549
-
550
- it("ignores events when disconnected", () => {
551
- const initialState = {
552
- mode: "disconnected" as const,
553
- socket: null,
554
- files: new Map(),
555
- queuedDiffs: [],
556
- pendingOperations: new Map(),
557
- nextOperationId: 1,
558
- }
559
-
560
- const result = transition(initialState, {
561
- type: "WATCHER_EVENT",
562
- event: {
563
- kind: "change",
564
- relativePath: "Test.tsx",
565
- content: "content",
566
- },
567
- })
568
-
569
- expect(result.effects.some((e) => e.type === "SEND_LOCAL_CHANGE")).toBe(
570
- false
571
- )
572
- })
573
- })
574
-
575
- describe("FILE_SYNCED transition", () => {
576
- it("updates file metadata with remote timestamp", () => {
577
- const initialState = {
578
- mode: "watching" as const,
579
- socket: {} as WebSocket,
580
- files: new Map([
581
- [
582
- "Test.tsx",
583
- {
584
- baseRemoteHash: "abc123",
585
- lastRemoteTimestamp: 1000,
586
- },
587
- ],
588
- ]),
589
- queuedDiffs: [],
590
- pendingOperations: new Map(),
591
- nextOperationId: 1,
592
- }
593
-
594
- const result = transition(initialState, {
595
- type: "FILE_SYNCED",
596
- fileName: "Test.tsx",
597
- remoteModifiedAt: 2000,
598
- })
599
-
600
- expect(
601
- result.effects.some((e) => e.type === "UPDATE_FILE_METADATA")
602
- ).toBe(true)
603
- })
604
-
605
- it("creates metadata entry if file not tracked yet", () => {
606
- const initialState = {
607
- mode: "watching" as const,
608
- socket: {} as WebSocket,
609
- files: new Map(),
610
- queuedDiffs: [],
611
- pendingOperations: new Map(),
612
- nextOperationId: 1,
613
- }
614
-
615
- const result = transition(initialState, {
616
- type: "FILE_SYNCED",
617
- fileName: "NewFile.tsx",
618
- remoteModifiedAt: 3000,
619
- })
620
- })
621
- })
622
-
623
- describe("CONFLICTS_RESOLVED transition", () => {
624
- it("applies all remote versions when user picks remote", () => {
625
- const conflict1 = {
626
- fileName: "Test1.tsx",
627
- localContent: "local 1",
628
- remoteContent: "remote 1",
629
- localModifiedAt: Date.now(),
630
- remoteModifiedAt: Date.now() + 1000,
631
- }
632
- const conflict2 = {
633
- fileName: "Test2.tsx",
634
- localContent: "local 2",
635
- remoteContent: "remote 2",
636
- localModifiedAt: Date.now(),
637
- remoteModifiedAt: Date.now() + 1000,
638
- }
639
-
640
- const initialState = {
641
- mode: "conflict_resolution" as const,
642
- socket: {} as WebSocket,
643
- files: new Map(),
644
- pendingConflicts: [conflict1, conflict2],
645
- queuedDiffs: [],
646
- pendingOperations: new Map(),
647
- nextOperationId: 1,
648
- }
649
-
650
- const result = transition(initialState, {
651
- type: "CONFLICTS_RESOLVED",
652
- resolution: "remote",
653
- })
654
-
655
- expect(result.state.mode).toBe("watching")
656
- expect("pendingConflicts" in result.state).toBe(false)
657
-
658
- const writeEffects = result.effects.filter(
659
- (e) => e.type === "WRITE_FILES"
660
- )
661
- // Each conflict gets its own WRITE_FILES effect
662
- expect(writeEffects).toHaveLength(2)
663
- expect(writeEffects[0]).toMatchObject({
664
- type: "WRITE_FILES",
665
- files: [{ name: "Test1.tsx", content: "remote 1" }],
666
- })
667
- expect(writeEffects[1]).toMatchObject({
668
- type: "WRITE_FILES",
669
- files: [{ name: "Test2.tsx", content: "remote 2" }],
670
- })
671
- expect(result.effects.some((e) => e.type === "PERSIST_STATE")).toBe(true)
672
- })
673
-
674
- it("sends all local versions when user picks local", () => {
675
- const conflict1 = {
676
- fileName: "Test1.tsx",
677
- localContent: "local 1",
678
- remoteContent: "remote 1",
679
- localModifiedAt: Date.now(),
680
- remoteModifiedAt: Date.now() + 1000,
681
- }
682
- const conflict2 = {
683
- fileName: "Test2.tsx",
684
- localContent: "local 2",
685
- remoteContent: "remote 2",
686
- localModifiedAt: Date.now(),
687
- remoteModifiedAt: Date.now() + 1000,
688
- }
689
-
690
- const initialState = {
691
- mode: "conflict_resolution" as const,
692
- socket: {} as WebSocket,
693
- files: new Map(),
694
- pendingConflicts: [conflict1, conflict2],
695
- queuedDiffs: [],
696
- pendingOperations: new Map(),
697
- nextOperationId: 1,
698
- }
699
-
700
- const result = transition(initialState, {
701
- type: "CONFLICTS_RESOLVED",
702
- resolution: "local",
703
- })
704
-
705
- expect(result.state.mode).toBe("watching")
706
- expect("pendingConflicts" in result.state).toBe(false)
707
-
708
- const sendEffects = result.effects.filter(
709
- (e) => e.type === "SEND_MESSAGE"
710
- )
711
- expect(sendEffects).toHaveLength(2)
712
- expect(sendEffects[0]).toMatchObject({
713
- payload: {
714
- type: "file-change",
715
- fileName: "Test1.tsx",
716
- content: "local 1",
717
- },
718
- })
719
- expect(sendEffects[1]).toMatchObject({
720
- payload: {
721
- type: "file-change",
722
- fileName: "Test2.tsx",
723
- content: "local 2",
724
- },
725
- })
726
- })
727
-
728
- it("ignores resolution when not in conflict_resolution mode", () => {
729
- const initialState = {
730
- mode: "watching" as const,
731
- socket: {} as WebSocket,
732
- files: new Map(),
733
- queuedDiffs: [],
734
- pendingOperations: new Map(),
735
- nextOperationId: 1,
736
- }
737
-
738
- const result = transition(initialState, {
739
- type: "CONFLICTS_RESOLVED",
740
- resolution: "remote",
741
- })
742
-
743
- expect(result.state.mode).toBe("watching")
744
- expect(
745
- result.effects.some((e) => e.type === "LOG" && e.level === "warn")
746
- ).toBe(true)
747
- })
748
- })
749
-
750
- describe("CONFLICT_VERSION_RESPONSE transition", () => {
751
- it("auto-applies local changes when remote is unchanged", () => {
752
- const conflict = {
753
- fileName: "Test.tsx",
754
- localContent: "local content",
755
- remoteContent: "remote content",
756
- localModifiedAt: 1000,
757
- remoteModifiedAt: 2000,
758
- lastSyncedAt: 5_000,
759
- localClean: false,
760
- }
761
-
762
- const initialState = {
763
- mode: "conflict_resolution" as const,
764
- socket: {} as WebSocket,
765
- pendingConflicts: [conflict],
766
- queuedDiffs: [],
767
- pendingOperations: new Map(),
768
- nextOperationId: 1,
769
- }
770
-
771
- const result = transition(initialState, {
772
- type: "CONFLICT_VERSION_RESPONSE",
773
- versions: [{ fileName: "Test.tsx", latestRemoteVersionMs: 5_000 }],
774
- })
775
-
776
- expect(result.state.mode).toBe("watching")
777
- expect(
778
- result.effects.some((effect) => effect.type === "SEND_LOCAL_CHANGE")
779
- ).toBe(true)
780
- expect(
781
- result.effects.some((effect) => effect.type === "PERSIST_STATE")
782
- ).toBe(true)
783
- })
784
-
785
- it("auto-applies remote changes when local is clean", () => {
786
- const conflict = {
787
- fileName: "Test.tsx",
788
- localContent: "local content",
789
- remoteContent: "remote content",
790
- localModifiedAt: 1000,
791
- remoteModifiedAt: 2000,
792
- lastSyncedAt: 5_000,
793
- localClean: true,
794
- }
795
-
796
- const initialState = {
797
- mode: "conflict_resolution" as const,
798
- socket: {} as WebSocket,
799
- pendingConflicts: [conflict],
800
- queuedDiffs: [],
801
- pendingOperations: new Map(),
802
- nextOperationId: 1,
803
- }
804
-
805
- const result = transition(initialState, {
806
- type: "CONFLICT_VERSION_RESPONSE",
807
- versions: [{ fileName: "Test.tsx", latestRemoteVersionMs: 10_000 }],
808
- })
809
-
810
- expect(result.state.mode).toBe("watching")
811
- expect(
812
- result.effects.some((effect) => effect.type === "WRITE_FILES")
813
- ).toBe(true)
814
- })
815
-
816
- it("requests manual decisions when both sides changed", () => {
817
- const conflict = {
818
- fileName: "Test.tsx",
819
- localContent: "local content",
820
- remoteContent: "remote content",
821
- localModifiedAt: 1000,
822
- remoteModifiedAt: 2000,
823
- lastSyncedAt: 5_000,
824
- localClean: false,
825
- }
826
-
827
- const initialState = {
828
- mode: "conflict_resolution" as const,
829
- socket: {} as WebSocket,
830
- pendingConflicts: [conflict],
831
- queuedDiffs: [],
832
- pendingOperations: new Map(),
833
- nextOperationId: 1,
834
- }
835
-
836
- const result = transition(initialState, {
837
- type: "CONFLICT_VERSION_RESPONSE",
838
- versions: [{ fileName: "Test.tsx", latestRemoteVersionMs: 9_000 }],
839
- })
840
-
841
- expect(result.state.mode).toBe("conflict_resolution")
842
- expect(
843
- result.effects.some(
844
- (effect) => effect.type === "REQUEST_CONFLICT_DECISIONS"
845
- )
846
- ).toBe(true)
847
- if (result.state.mode === "conflict_resolution") {
848
- expect(result.state.pendingConflicts).toHaveLength(1)
849
- }
850
- })
851
- })
852
-
853
- describe("echo prevention filter", () => {
854
- it("skips inbound file-change that matches last local send", () => {
855
- const hashTracker = createHashTracker()
856
- hashTracker.remember("Hey.tsx", "content")
857
-
858
- const filtered = filterEchoedFiles(
859
- [
860
- {
861
- name: "Hey.tsx",
862
- content: "content",
863
- modifiedAt: Date.now(),
864
- },
865
- ],
866
- hashTracker
867
- )
868
-
869
- expect(filtered).toHaveLength(0)
870
- })
871
-
872
- it("keeps inbound change when content differs", () => {
873
- const hashTracker = createHashTracker()
874
- hashTracker.remember("Hey.tsx", "old content")
875
-
876
- const filtered = filterEchoedFiles(
877
- [
878
- {
879
- name: "Hey.tsx",
880
- content: "new content",
881
- modifiedAt: Date.now(),
882
- },
883
- ],
884
- hashTracker
885
- )
886
-
887
- expect(filtered).toHaveLength(1)
888
- expect(filtered[0]?.content).toBe("new content")
889
- })
890
- })
891
- })