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