framer-code-link 0.1.3 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "framer-code-link",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "CLI tool for syncing Framer code components - controller-centric architecture",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -19,18 +19,18 @@
19
19
  "author": "",
20
20
  "license": "MIT",
21
21
  "dependencies": {
22
- "@typescript/ata": "^0.9.7",
23
- "chokidar": "^4.0.3",
24
- "commander": "^12.1.0",
25
- "prettier": "^3.6.2",
26
- "typescript": "^5.7.2",
27
- "ws": "^8.18.0"
22
+ "@typescript/ata": "^0.9.8",
23
+ "chokidar": "^5.0.0",
24
+ "commander": "^14.0.2",
25
+ "prettier": "^3.7.4",
26
+ "typescript": "^5.9.3",
27
+ "ws": "^8.18.3"
28
28
  },
29
29
  "devDependencies": {
30
- "@types/node": "^22.10.2",
31
- "@types/ws": "^8.5.13",
32
- "tsdown": "^0.2.17",
33
- "tsx": "^4.19.2",
34
- "vitest": "^2.1.8"
30
+ "@types/node": "^22.19.2",
31
+ "@types/ws": "^8.18.1",
32
+ "tsdown": "^0.17.4",
33
+ "tsx": "^4.21.0",
34
+ "vitest": "^4.0.15"
35
35
  }
36
36
  }
@@ -1,98 +1,7 @@
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"
1
+ import { describe, it, expect } from "vitest"
2
+ import { filterEchoedFiles, transition } from "./controller.js"
3
+ import { createHashTracker } from "./utils/hash-tracker.js"
9
4
  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
5
  describe("State Machine", () => {
97
6
  describe("HANDSHAKE transition", () => {
98
7
  it("transitions from disconnected to handshaking", () => {
@@ -176,7 +85,7 @@ describe("State Machine", () => {
176
85
  expect(result.effects[0]).toMatchObject({ type: "PERSIST_STATE" })
177
86
  expect(result.effects[1]).toMatchObject({
178
87
  type: "LOG",
179
- level: "info",
88
+ level: "debug",
180
89
  })
181
90
  })
182
91
  })
@@ -206,7 +115,7 @@ describe("State Machine", () => {
206
115
  expect(result.effects).toHaveLength(2)
207
116
  expect(result.effects[0]).toMatchObject({
208
117
  type: "LOG",
209
- level: "info",
118
+ level: "debug",
210
119
  })
211
120
  expect(result.effects[1]).toMatchObject({
212
121
  type: "DETECT_CONFLICTS",
@@ -362,7 +271,7 @@ describe("State Machine", () => {
362
271
  })
363
272
 
364
273
  describe("REMOTE_FILE_DELETE transition", () => {
365
- it("requests confirmation in watching mode", () => {
274
+ it("applies delete immediately in watching mode", () => {
366
275
  const initialState = {
367
276
  mode: "watching" as const,
368
277
  socket: {} as WebSocket,
@@ -387,36 +296,9 @@ describe("State Machine", () => {
387
296
  })
388
297
 
389
298
  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"
299
+ expect(result.effects.some((e) => e.type === "DELETE_LOCAL_FILES")).toBe(
300
+ true
395
301
  )
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
302
  const deleteEffect = result.effects.find(
421
303
  (e) => e.type === "DELETE_LOCAL_FILES"
422
304
  )
@@ -424,12 +306,10 @@ describe("State Machine", () => {
424
306
  type: "DELETE_LOCAL_FILES",
425
307
  names: ["Test.tsx"],
426
308
  })
427
- expect(
428
- result.effects.some((e) => e.type === "PERSIST_STATE")
429
- ).toBe(true)
309
+ expect(result.effects.some((e) => e.type === "PERSIST_STATE")).toBe(true)
430
310
  })
431
311
 
432
- it("queues deletes during snapshot processing", () => {
312
+ it("applies deletes immediately during snapshot processing", () => {
433
313
  const initialState = {
434
314
  mode: "snapshot_processing" as const,
435
315
  socket: {} as WebSocket,
@@ -445,7 +325,9 @@ describe("State Machine", () => {
445
325
  })
446
326
 
447
327
  expect(result.state.mode).toBe("snapshot_processing")
448
- expect(result.effects.some((e) => e.type === "DELETE_FILES")).toBe(false)
328
+ expect(result.effects.some((e) => e.type === "DELETE_LOCAL_FILES")).toBe(
329
+ true
330
+ )
449
331
  expect(result.effects.some((e) => e.type === "LOG")).toBe(true)
450
332
  })
451
333
 
@@ -465,7 +347,9 @@ describe("State Machine", () => {
465
347
  })
466
348
 
467
349
  expect(result.state.mode).toBe("disconnected")
468
- expect(result.effects.some((e) => e.type === "DELETE_FILES")).toBe(false)
350
+ expect(result.effects.some((e) => e.type === "DELETE_LOCAL_FILES")).toBe(
351
+ false
352
+ )
469
353
  expect(
470
354
  result.effects.some((e) => e.type === "LOG" && e.level === "warn")
471
355
  ).toBe(true)
@@ -665,7 +549,7 @@ describe("State Machine", () => {
665
549
 
666
550
  it("ignores events when disconnected", () => {
667
551
  const initialState = {
668
- mode: "watching" as const,
552
+ mode: "disconnected" as const,
669
553
  socket: null,
670
554
  files: new Map(),
671
555
  queuedDiffs: [],
@@ -774,13 +658,15 @@ describe("State Machine", () => {
774
658
  const writeEffects = result.effects.filter(
775
659
  (e) => e.type === "WRITE_FILES"
776
660
  )
777
- expect(writeEffects).toHaveLength(1)
661
+ // Each conflict gets its own WRITE_FILES effect
662
+ expect(writeEffects).toHaveLength(2)
778
663
  expect(writeEffects[0]).toMatchObject({
779
664
  type: "WRITE_FILES",
780
- files: [
781
- { name: "Test1.tsx", content: "remote 1" },
782
- { name: "Test2.tsx", content: "remote 2" },
783
- ],
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" }],
784
670
  })
785
671
  expect(result.effects.some((e) => e.type === "PERSIST_STATE")).toBe(true)
786
672
  })
@@ -963,4 +849,43 @@ describe("State Machine", () => {
963
849
  }
964
850
  })
965
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
+ })
966
891
  })