|
1 | | -import { NodeContext } from "@effect/platform-node" |
2 | | -import type * as FileSystem from "@effect/platform/FileSystem" |
3 | | -import * as Path from "@effect/platform/Path" |
4 | | -import { describe, expect, it } from "@effect/vitest" |
5 | | -import { Effect, Layer, Option, pipe } from "effect" |
| 1 | +import { describe, it } from "@effect/vitest" |
| 2 | +import { Effect } from "effect" |
6 | 3 | import fc from "fast-check" |
7 | 4 |
|
8 | | -import { CryptoService, CryptoServiceLive } from "../../src/shell/services/crypto.js" |
9 | | -import { FileSystemLive } from "../../src/shell/services/file-system.js" |
10 | | -import { RuntimeEnv } from "../../src/shell/services/runtime-env.js" |
11 | | -import { buildSyncProgram } from "../../src/shell/sync/index.js" |
12 | | -import { buildTestPaths, makeTempDir, withFsPath, writeFile } from "../support/fs-helpers.js" |
13 | | - |
14 | | -const forEach = Effect.forEach |
15 | | -const some = Option.some |
16 | | -const mapFileEntry = ( |
17 | | - sessionDir: string, |
18 | | - fs: FileSystem.FileSystem, |
19 | | - path: Path.Path |
20 | | -) => |
21 | | -(entry: string) => |
22 | | - pipe( |
23 | | - fs.stat(path.join(sessionDir, entry)), |
24 | | - Effect.map((info) => info.type === "File" ? some(entry) : Option.none()) |
25 | | - ) |
26 | | - |
27 | | -const testPaths = buildTestPaths( |
28 | | - new URL(import.meta.url), |
29 | | - "context-doc-tests" |
30 | | -) |
31 | | - |
32 | | -const withTempDir = Effect.gen(function*(_) { |
33 | | - const { tempBase } = yield* _(testPaths) |
34 | | - return yield* _(makeTempDir(tempBase, "context-doc-")) |
35 | | -}) |
36 | | - |
37 | | -const makeRuntimeEnvLayer = (cwd: string): Layer.Layer<RuntimeEnv> => |
38 | | - Layer.succeed(RuntimeEnv, { |
39 | | - argv: Effect.succeed(["node", "main"]), |
40 | | - cwd: Effect.succeed(cwd), |
41 | | - homedir: Effect.succeed(cwd), |
42 | | - envVar: () => Effect.succeed(Option.none()) |
43 | | - }) |
44 | | - |
45 | | -const assertSyncOutput = ( |
46 | | - destDir: string, |
47 | | - qwenHash: string, |
48 | | - cwd: string, |
49 | | - expectedMessage: string, |
50 | | - skippedMessage: string |
51 | | -) => |
52 | | - withFsPath((fs, path) => |
53 | | - Effect.gen(function*(_) { |
54 | | - const sessionDir = path.join(destDir, "sessions/2025/11") |
55 | | - const entries = yield* _(fs.readDirectory(sessionDir)) |
56 | | - const files = yield* _( |
57 | | - forEach(entries, mapFileEntry(sessionDir, fs, path)) |
58 | | - ) |
59 | | - const copiedFiles = files.flatMap((entry) => Option.isSome(entry) ? [entry.value] : []) |
60 | | - |
61 | | - expect(copiedFiles).toEqual(["match.jsonl"]) |
62 | | - |
63 | | - const content = yield* _( |
64 | | - fs.readFileString(path.join(sessionDir, "match.jsonl")) |
65 | | - ) |
66 | | - |
67 | | - expect(content).toContain(`"message":"${expectedMessage}"`) |
68 | | - expect(content).not.toContain(`"message":"${skippedMessage}"`) |
69 | | - |
70 | | - const qwenCopied = path.join( |
71 | | - cwd, |
72 | | - ".knowledge", |
73 | | - ".qwen", |
74 | | - qwenHash, |
75 | | - "chats", |
76 | | - "session-1.json" |
77 | | - ) |
78 | | - |
79 | | - const exists = yield* _(fs.exists(qwenCopied)) |
80 | | - expect(exists).toBe(true) |
81 | | - }) |
82 | | - ) |
83 | | - |
84 | | -const runSyncScenario = ( |
85 | | - matchMessage: string, |
86 | | - skippedMessage: string |
87 | | -) => |
88 | | - Effect.scoped( |
89 | | - Effect.gen(function*(_) { |
90 | | - const path = yield* _(Path.Path) |
91 | | - const crypto = yield* _(CryptoService) |
92 | | - const cwd = yield* _(withTempDir) |
93 | | - const codexDir = path.join(cwd, ".codex") |
94 | | - const destDir = path.join(cwd, ".knowledge", ".codex") |
95 | | - const qwenSource = path.join(cwd, ".qwen", "tmp") |
96 | | - const qwenHash = yield* _(crypto.sha256(cwd)) |
97 | | - yield* _( |
98 | | - writeFile( |
99 | | - path.join(codexDir, "sessions/2025/11/match.jsonl"), |
100 | | - [ |
101 | | - JSON.stringify({ cwd, message: matchMessage }), |
102 | | - JSON.stringify({ |
103 | | - payload: { cwd: path.join(cwd, "sub") } |
104 | | - }) |
105 | | - ].join("\n") |
106 | | - ) |
107 | | - ) |
108 | | - |
109 | | - yield* _( |
110 | | - writeFile( |
111 | | - path.join(codexDir, "sessions/2025/11/ignore.jsonl"), |
112 | | - [ |
113 | | - JSON.stringify({ |
114 | | - cwd: "/home/user/other", |
115 | | - message: skippedMessage |
116 | | - }) |
117 | | - ].join("\n") |
118 | | - ) |
119 | | - ) |
120 | | - |
121 | | - yield* _( |
122 | | - writeFile( |
123 | | - path.join(qwenSource, qwenHash, "chats", "session-1.json"), |
124 | | - JSON.stringify({ sessionId: "s1", projectHash: qwenHash }) |
125 | | - ) |
126 | | - ) |
127 | | - |
128 | | - yield* _( |
129 | | - Effect.provide( |
130 | | - buildSyncProgram({ |
131 | | - cwd, |
132 | | - sourceDir: codexDir, |
133 | | - destinationDir: destDir, |
134 | | - qwenSourceDir: qwenSource |
135 | | - }), |
136 | | - Layer.mergeAll(FileSystemLive, makeRuntimeEnvLayer(cwd)) |
137 | | - ) |
138 | | - ) |
139 | | - |
140 | | - yield* _(assertSyncOutput(destDir, qwenHash, cwd, matchMessage, skippedMessage)) |
141 | | - }) |
142 | | - ).pipe( |
143 | | - Effect.provide(Layer.mergeAll(NodeContext.layer, CryptoServiceLive)) |
144 | | - ) |
| 5 | +import { |
| 6 | + runSyncScenario, |
| 7 | + runSyncScenarioFromHomeCodex, |
| 8 | + runSyncScenarioWithBrokenSymlink |
| 9 | +} from "../support/sync-knowledge-helpers.js" |
145 | 10 |
|
146 | 11 | describe("sync-knowledge end-to-end", () => { |
147 | 12 | const safeChar = fc.constantFrom( |
@@ -200,4 +65,18 @@ describe("sync-knowledge end-to-end", () => { |
200 | 65 | ), |
201 | 66 | catch: (error) => error instanceof Error ? error : new Error(String(error)) |
202 | 67 | })) |
| 68 | + |
| 69 | + it.effect("ignores broken Codex symlink entries", () => |
| 70 | + Effect.gen(function*(_) { |
| 71 | + const matchMessage = "match" |
| 72 | + const skippedMessage = "skip" |
| 73 | + yield* _(runSyncScenarioWithBrokenSymlink(matchMessage, skippedMessage)) |
| 74 | + })) |
| 75 | + |
| 76 | + it.effect("finds Codex entries in homedir when local source is missing", () => |
| 77 | + Effect.gen(function*(_) { |
| 78 | + const matchMessage = "home-match" |
| 79 | + const skippedMessage = "home-skip" |
| 80 | + yield* _(runSyncScenarioFromHomeCodex(matchMessage, skippedMessage)) |
| 81 | + })) |
203 | 82 | }) |
0 commit comments