@symbiosis-lab/moss-plugin-matters 1.4.2

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +18 -0
  3. package/assets/icon.svg +1 -0
  4. package/assets/manifest.json +36 -0
  5. package/codegen.ts +26 -0
  6. package/e2e/moss-cli.test.ts +338 -0
  7. package/features/api/fetch-articles.feature +39 -0
  8. package/features/auth/wallet-auth.feature +27 -0
  9. package/features/download/retry-logic.feature +36 -0
  10. package/features/download/self-correcting.feature +83 -0
  11. package/features/download/worker-pool.feature +29 -0
  12. package/features/social/fetch-social-data.feature +40 -0
  13. package/features/steps/api.steps.ts +180 -0
  14. package/features/steps/download.steps.ts +423 -0
  15. package/features/steps/incremental-sync.steps.ts +105 -0
  16. package/features/steps/self-correcting.steps.ts +575 -0
  17. package/features/steps/social.steps.ts +257 -0
  18. package/features/steps/syndication.steps.ts +264 -0
  19. package/features/steps/wallet-auth.steps.ts +185 -0
  20. package/features/sync/article-sync.feature +49 -0
  21. package/features/sync/homepage-grid.feature +43 -0
  22. package/features/sync/incremental-sync.feature +28 -0
  23. package/features/syndication/create-draft.feature +35 -0
  24. package/package.json +58 -0
  25. package/src/__generated__/schema.graphql +4289 -0
  26. package/src/__generated__/types.ts +5355 -0
  27. package/src/__tests__/api.test.ts +678 -0
  28. package/src/__tests__/auth-route.test.ts +38 -0
  29. package/src/__tests__/auth-routing.test.ts +462 -0
  30. package/src/__tests__/auto-detect.test.ts +412 -0
  31. package/src/__tests__/binding-guard.test.ts +256 -0
  32. package/src/__tests__/config.test.ts +212 -0
  33. package/src/__tests__/converter.test.ts +289 -0
  34. package/src/__tests__/credential.test.ts +332 -0
  35. package/src/__tests__/domain.test.ts +341 -0
  36. package/src/__tests__/downloader.test.ts +679 -0
  37. package/src/__tests__/folder-detection.test.ts +289 -0
  38. package/src/__tests__/force-fresh-login.test.ts +236 -0
  39. package/src/__tests__/main.test.ts +2437 -0
  40. package/src/__tests__/progress.test.ts +93 -0
  41. package/src/__tests__/session.test.ts +375 -0
  42. package/src/__tests__/social-integration.test.ts +386 -0
  43. package/src/__tests__/social-sync-logic.test.ts +107 -0
  44. package/src/__tests__/social.test.ts +788 -0
  45. package/src/__tests__/sync.test.ts +1273 -0
  46. package/src/__tests__/syndication-toast-law.test.ts +649 -0
  47. package/src/__tests__/syndication.test.ts +125 -0
  48. package/src/__tests__/test-profile-escape.test.ts +209 -0
  49. package/src/__tests__/url-detect.test.ts +79 -0
  50. package/src/__tests__/utils.test.ts +226 -0
  51. package/src/api.ts +1366 -0
  52. package/src/auth-route.ts +38 -0
  53. package/src/config.ts +80 -0
  54. package/src/converter.ts +305 -0
  55. package/src/credential.ts +329 -0
  56. package/src/domain.ts +183 -0
  57. package/src/downloader.ts +761 -0
  58. package/src/main.ts +2092 -0
  59. package/src/progress.ts +89 -0
  60. package/src/queries/user.graphql +85 -0
  61. package/src/queries/viewer.graphql +104 -0
  62. package/src/social.ts +413 -0
  63. package/src/sync.ts +818 -0
  64. package/src/types.ts +477 -0
  65. package/src/url-detect.ts +49 -0
  66. package/src/utils.ts +305 -0
  67. package/test-fixtures/syndication-test-site/input/index.md +8 -0
  68. package/test-fixtures/syndication-test-site/input/posts/rich-test-article.md +90 -0
  69. package/test-helpers/TEST_ACCOUNT.md +151 -0
  70. package/test-helpers/api-client.ts +252 -0
  71. package/test-helpers/fixtures/articles.ts +147 -0
  72. package/test-helpers/wallet-auth.ts +305 -0
  73. package/test-setup/e2e.ts +93 -0
  74. package/tsconfig.json +23 -0
  75. package/vitest.config.ts +39 -0
@@ -0,0 +1,679 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import {
3
+ extractAssetUuid,
4
+ escapeRegex,
5
+ buildAssetUrlPattern,
6
+ replaceAssetUrls,
7
+ replaceImageWithWikilink,
8
+ replaceImageUrlWithWikilink,
9
+ calculateRelativePath,
10
+ } from "../downloader";
11
+
12
+ // Note: The downloader module heavily depends on:
13
+ // 1. window.__TAURI__ for file operations
14
+ // 2. global fetch for HTTP requests
15
+ // 3. Other utils functions
16
+ //
17
+ // Full integration tests would require mocking these. Here we test
18
+ // the module's structure and any pure logic that can be extracted.
19
+
20
+ describe("Downloader Module", () => {
21
+ describe("Module Structure", () => {
22
+ it("exports downloadMediaAndUpdate function", async () => {
23
+ const module = await import("../downloader");
24
+ expect(typeof module.downloadMediaAndUpdate).toBe("function");
25
+ });
26
+
27
+ it("exports rewriteAllInternalLinks function", async () => {
28
+ const module = await import("../downloader");
29
+ expect(typeof module.rewriteAllInternalLinks).toBe("function");
30
+ });
31
+ });
32
+
33
+ describe("Constants", () => {
34
+ // These are internal constants, but we can verify the module loads correctly
35
+ it("module loads without errors", async () => {
36
+ await expect(import("../downloader")).resolves.toBeDefined();
37
+ });
38
+ });
39
+ });
40
+
41
+ describe("extractAssetUuid", () => {
42
+ it("extracts UUID from assets.matters.news URL", () => {
43
+ const url = "https://assets.matters.news/embed/66296200-de80-43f1-a1a2-ce2b1403a3e2/141562277039-pic-hd.jpg";
44
+ expect(extractAssetUuid(url)).toBe("66296200-de80-43f1-a1a2-ce2b1403a3e2");
45
+ });
46
+
47
+ it("extracts UUID from imagedelivery.net URL", () => {
48
+ const url = "https://imagedelivery.net/kDRCweMmqLnTPNlbum-pYA/prod/embed/66296200-de80-43f1-a1a2-ce2b1403a3e2/141562277039-pic-hd.jpg/public";
49
+ expect(extractAssetUuid(url)).toBe("66296200-de80-43f1-a1a2-ce2b1403a3e2");
50
+ });
51
+
52
+ it("extracts UUID without filename suffix", () => {
53
+ const url = "https://assets.matters.news/embed/8ef4fb5d-ae3f-4e10-826b-169b0762d555.png";
54
+ expect(extractAssetUuid(url)).toBe("8ef4fb5d-ae3f-4e10-826b-169b0762d555");
55
+ });
56
+
57
+ it("handles uppercase UUIDs", () => {
58
+ const url = "https://example.com/66296200-DE80-43F1-A1A2-CE2B1403A3E2.jpg";
59
+ expect(extractAssetUuid(url)).toBe("66296200-DE80-43F1-A1A2-CE2B1403A3E2");
60
+ });
61
+
62
+ it("returns null for URL without UUID", () => {
63
+ const url = "https://example.com/image.jpg";
64
+ expect(extractAssetUuid(url)).toBeNull();
65
+ });
66
+
67
+ it("returns null for malformed UUID", () => {
68
+ const url = "https://example.com/66296200-de80-43f1-a1a2.jpg"; // Missing last segment
69
+ expect(extractAssetUuid(url)).toBeNull();
70
+ });
71
+
72
+ it("returns first UUID if multiple present", () => {
73
+ const url = "https://example.com/66296200-de80-43f1-a1a2-ce2b1403a3e2/8ef4fb5d-ae3f-4e10-826b-169b0762d555.png";
74
+ expect(extractAssetUuid(url)).toBe("66296200-de80-43f1-a1a2-ce2b1403a3e2");
75
+ });
76
+ });
77
+
78
+ describe("escapeRegex", () => {
79
+ it("escapes dots", () => {
80
+ expect(escapeRegex("file.png")).toBe("file\\.png");
81
+ });
82
+
83
+ it("escapes special regex characters", () => {
84
+ expect(escapeRegex("test.*+?^${}()|[]\\")).toBe("test\\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\");
85
+ });
86
+
87
+ it("leaves alphanumeric and hyphens unchanged", () => {
88
+ expect(escapeRegex("66296200-de80-43f1-a1a2-ce2b1403a3e2")).toBe("66296200-de80-43f1-a1a2-ce2b1403a3e2");
89
+ });
90
+
91
+ it("handles empty string", () => {
92
+ expect(escapeRegex("")).toBe("");
93
+ });
94
+ });
95
+
96
+ describe("buildAssetUrlPattern", () => {
97
+ it("creates pattern that matches assets.matters.news URL", () => {
98
+ const pattern = buildAssetUrlPattern("66296200-de80-43f1-a1a2-ce2b1403a3e2");
99
+ const url = "https://assets.matters.news/embed/66296200-de80-43f1-a1a2-ce2b1403a3e2/file.jpg";
100
+ expect(pattern.test(url)).toBe(true);
101
+ });
102
+
103
+ it("creates pattern that matches imagedelivery.net URL", () => {
104
+ const pattern = buildAssetUrlPattern("66296200-de80-43f1-a1a2-ce2b1403a3e2");
105
+ const url = "https://imagedelivery.net/kDRCweMmqLnTPNlbum-pYA/prod/embed/66296200-de80-43f1-a1a2-ce2b1403a3e2/public";
106
+ expect(pattern.test(url)).toBe(true);
107
+ });
108
+
109
+ it("does not match URL with different UUID", () => {
110
+ const pattern = buildAssetUrlPattern("66296200-de80-43f1-a1a2-ce2b1403a3e2");
111
+ const url = "https://assets.matters.news/embed/8ef4fb5d-ae3f-4e10-826b-169b0762d555.png";
112
+ expect(pattern.test(url)).toBe(false);
113
+ });
114
+
115
+ it("does not match non-URL text containing UUID", () => {
116
+ const pattern = buildAssetUrlPattern("66296200-de80-43f1-a1a2-ce2b1403a3e2");
117
+ const text = "The asset ID is 66296200-de80-43f1-a1a2-ce2b1403a3e2";
118
+ expect(pattern.test(text)).toBe(false);
119
+ });
120
+
121
+ it("stops at markdown image closing paren", () => {
122
+ const pattern = buildAssetUrlPattern("66296200-de80-43f1-a1a2-ce2b1403a3e2");
123
+ const markdown = "![](https://assets.matters.news/embed/66296200-de80-43f1-a1a2-ce2b1403a3e2.jpg)*caption*";
124
+ const match = markdown.match(pattern);
125
+ expect(match).not.toBeNull();
126
+ expect(match![0]).toBe("https://assets.matters.news/embed/66296200-de80-43f1-a1a2-ce2b1403a3e2.jpg");
127
+ });
128
+ });
129
+
130
+ describe("replaceAssetUrls", () => {
131
+ const assetId = "66296200-de80-43f1-a1a2-ce2b1403a3e2";
132
+ const localPath = "assets/66296200-de80-43f1-a1a2-ce2b1403a3e2.jpg";
133
+
134
+ it("replaces assets.matters.news URL in markdown", () => {
135
+ const content = "![](https://assets.matters.news/embed/66296200-de80-43f1-a1a2-ce2b1403a3e2/file.jpg)";
136
+ const result = replaceAssetUrls(content, assetId, localPath);
137
+ expect(result.replaced).toBe(true);
138
+ expect(result.content).toBe(`![](${localPath})`);
139
+ });
140
+
141
+ it("replaces imagedelivery.net URL in markdown", () => {
142
+ const content = "![](https://imagedelivery.net/xxx/prod/embed/66296200-de80-43f1-a1a2-ce2b1403a3e2/public)";
143
+ const result = replaceAssetUrls(content, assetId, localPath);
144
+ expect(result.replaced).toBe(true);
145
+ expect(result.content).toBe(`![](${localPath})`);
146
+ });
147
+
148
+ it("replaces multiple occurrences", () => {
149
+ const content = `
150
+ ![](https://assets.matters.news/embed/66296200-de80-43f1-a1a2-ce2b1403a3e2.jpg)
151
+ Some text
152
+ ![](https://imagedelivery.net/xxx/66296200-de80-43f1-a1a2-ce2b1403a3e2/public)
153
+ `.trim();
154
+ const result = replaceAssetUrls(content, assetId, localPath);
155
+ expect(result.replaced).toBe(true);
156
+ expect(result.content).toBe(`
157
+ ![](${localPath})
158
+ Some text
159
+ ![](${localPath})
160
+ `.trim());
161
+ });
162
+
163
+ it("returns replaced=false when no match", () => {
164
+ const content = "![](https://example.com/other-image.jpg)";
165
+ const result = replaceAssetUrls(content, assetId, localPath);
166
+ expect(result.replaced).toBe(false);
167
+ expect(result.content).toBe(content);
168
+ });
169
+
170
+ it("preserves surrounding content", () => {
171
+ const content = "Before ![alt](https://assets.matters.news/embed/66296200-de80-43f1-a1a2-ce2b1403a3e2.jpg)*caption* After";
172
+ const result = replaceAssetUrls(content, assetId, localPath);
173
+ expect(result.replaced).toBe(true);
174
+ expect(result.content).toBe(`Before ![alt](${localPath})*caption* After`);
175
+ });
176
+
177
+ it("handles URL without extension", () => {
178
+ const content = "![](https://assets.matters.news/embed/66296200-de80-43f1-a1a2-ce2b1403a3e2)";
179
+ const result = replaceAssetUrls(content, assetId, localPath);
180
+ expect(result.replaced).toBe(true);
181
+ expect(result.content).toBe(`![](${localPath})`);
182
+ });
183
+ });
184
+
185
+ describe("replaceImageWithWikilink", () => {
186
+ const assetId = "66296200-de80-43f1-a1a2-ce2b1403a3e2";
187
+ const filename = "66296200-de80-43f1-a1a2-ce2b1403a3e2.jpg";
188
+
189
+ it("replaces the WHOLE image token with a filename-only wikilink (B2)", () => {
190
+ const content = "![](https://assets.matters.news/embed/66296200-de80-43f1-a1a2-ce2b1403a3e2/file.jpg)";
191
+ const result = replaceImageWithWikilink(content, assetId, filename);
192
+ expect(result.replaced).toBe(true);
193
+ expect(result.content).toBe(`![[${filename}]]`);
194
+ // No residual relative-markdown wrapper.
195
+ expect(result.content).not.toContain("](");
196
+ });
197
+
198
+ it("drops alt text and the CDN URL (depth-independent ![[file]])", () => {
199
+ const content = "before ![some alt](https://imagedelivery.net/xxx/prod/embed/66296200-de80-43f1-a1a2-ce2b1403a3e2/public) after";
200
+ const result = replaceImageWithWikilink(content, assetId, filename);
201
+ expect(result.replaced).toBe(true);
202
+ expect(result.content).toBe(`before ![[${filename}]] after`);
203
+ });
204
+
205
+ it("handles a markdown title attribute ![alt](url \"title\") (htmd emits these)", () => {
206
+ const content = `![cap](https://assets.matters.news/embed/66296200-de80-43f1-a1a2-ce2b1403a3e2/file.jpg "A caption")`;
207
+ const result = replaceImageWithWikilink(content, assetId, filename);
208
+ expect(result.replaced).toBe(true);
209
+ expect(result.content).toBe(`![[${filename}]]`);
210
+ expect(result.content).not.toContain("https://"); // CDN url fully removed
211
+ });
212
+
213
+ it("returns replaced=false when the asset id is absent", () => {
214
+ const content = "![](https://example.com/other.jpg)";
215
+ const result = replaceImageWithWikilink(content, assetId, filename);
216
+ expect(result.replaced).toBe(false);
217
+ expect(result.content).toBe(content);
218
+ });
219
+ });
220
+
221
+ describe("replaceImageUrlWithWikilink (B6 — legacy non-UUID assets)", () => {
222
+ // A legacy cloudfront URL with no UUID segment to key on.
223
+ const url = "https://d1y0vy6cjcgwlk.cloudfront.net/legacy/photo.jpg";
224
+ const filename = "photo.jpg";
225
+
226
+ it("replaces the whole image token for an exact non-UUID URL", () => {
227
+ const content = `before ![cap](${url}) after`;
228
+ const result = replaceImageUrlWithWikilink(content, url, filename);
229
+ expect(result.replaced).toBe(true);
230
+ expect(result.content).toBe(`before ![[${filename}]] after`);
231
+ expect(result.content).not.toContain("cloudfront.net");
232
+ });
233
+
234
+ it("handles an htmd title trailer ![alt](url \"title\")", () => {
235
+ const content = `![cap](${url} "A caption")`;
236
+ const result = replaceImageUrlWithWikilink(content, url, filename);
237
+ expect(result.replaced).toBe(true);
238
+ expect(result.content).toBe(`![[${filename}]]`);
239
+ });
240
+
241
+ it("only matches the exact URL, not a different one", () => {
242
+ const content = "![](https://d1y0vy6cjcgwlk.cloudfront.net/legacy/other.jpg)";
243
+ const result = replaceImageUrlWithWikilink(content, url, filename);
244
+ expect(result.replaced).toBe(false);
245
+ expect(result.content).toBe(content);
246
+ });
247
+ });
248
+
249
+ describe("calculateRelativePath", () => {
250
+ it("returns asset path directly for root-level markdown", () => {
251
+ // Markdown at root, asset in assets/
252
+ expect(calculateRelativePath("article.md", "assets/image.png")).toBe("assets/image.png");
253
+ });
254
+
255
+ it("calculates path from nested markdown to assets", () => {
256
+ // Markdown in 文章/, asset in assets/
257
+ expect(calculateRelativePath("文章/article.md", "assets/image.png")).toBe("../assets/image.png");
258
+ });
259
+
260
+ it("calculates path from deeply nested markdown to assets", () => {
261
+ // Markdown in a/b/c/, asset in assets/
262
+ expect(calculateRelativePath("a/b/c/article.md", "assets/image.png")).toBe("../../../assets/image.png");
263
+ });
264
+
265
+ it("handles markdown and asset in same directory", () => {
266
+ // Both in same directory
267
+ expect(calculateRelativePath("folder/article.md", "folder/image.png")).toBe("image.png");
268
+ });
269
+
270
+ it("handles markdown in subdirectory of assets parent", () => {
271
+ // Markdown in assets/docs/, asset in assets/
272
+ expect(calculateRelativePath("assets/docs/article.md", "assets/image.png")).toBe("../image.png");
273
+ });
274
+
275
+ it("handles two-level nesting with Chinese characters", () => {
276
+ // Real-world case with Chinese directory names
277
+ expect(calculateRelativePath("刘果/文章/ipfs開發者大會記錄.md", "assets/66296200-de80-43f1-a1a2-ce2b1403a3e2.jpg"))
278
+ .toBe("../../assets/66296200-de80-43f1-a1a2-ce2b1403a3e2.jpg");
279
+ });
280
+ });
281
+
282
+ // Note: withTimeout was removed - timeout handling is now done by Rust side
283
+ // (tokio::time::timeout with Semaphore for concurrency control)
284
+
285
+ // ============================================================================
286
+ // Integration Tests with Mock Tauri
287
+ // ============================================================================
288
+
289
+ import { setupMockTauri, type MockTauriContext } from "@symbiosis-lab/moss-api/testing";
290
+
291
+ describe("downloadMediaAndUpdate - partial completion", () => {
292
+ let ctx: MockTauriContext;
293
+
294
+ beforeEach(() => {
295
+ ctx = setupMockTauri({ projectPath: "/test-project" });
296
+ });
297
+
298
+ afterEach(() => {
299
+ ctx.cleanup();
300
+ });
301
+
302
+ it("updates file references for successfully downloaded images", async () => {
303
+ // Set up a markdown file with 2 images
304
+ const uuid1 = "aaaaaaaa-1111-1111-1111-111111111111";
305
+ const uuid2 = "bbbbbbbb-2222-2222-2222-222222222222";
306
+ const markdownContent = `---
307
+ title: "Test Article"
308
+ ---
309
+
310
+ # Article
311
+
312
+ ![](https://assets.matters.news/embed/${uuid1}.jpg)
313
+
314
+ Some text
315
+
316
+ ![](https://assets.matters.news/embed/${uuid2}.jpg)
317
+ `;
318
+ ctx.filesystem.setFile(`${ctx.projectPath}/article.md`, markdownContent);
319
+
320
+ // Configure URL responses - uuid1 succeeds, uuid2 fails with 404
321
+ ctx.urlConfig.setResponse(`https://assets.matters.news/embed/${uuid1}.jpg`, {
322
+ status: 200,
323
+ ok: true,
324
+ contentType: "image/jpeg",
325
+ bytesWritten: 1024,
326
+ actualPath: `assets/${uuid1}.jpg`,
327
+ });
328
+ ctx.urlConfig.setResponse(`https://assets.matters.news/embed/${uuid2}.jpg`, {
329
+ status: 404,
330
+ ok: false,
331
+ contentType: null,
332
+ bytesWritten: 0,
333
+ actualPath: "",
334
+ });
335
+
336
+ // Run the download and update function, capturing progress reports
337
+ const { downloadMediaAndUpdate } = await import("../downloader");
338
+ const onProgress = vi.fn();
339
+ const result = await downloadMediaAndUpdate(onProgress);
340
+
341
+ // Verify: 1 image downloaded, 1 error
342
+ expect(result.imagesDownloaded).toBe(1);
343
+ expect(result.errors.length).toBeGreaterThan(0);
344
+
345
+ // Media-download progress is forwarded to the unified task via onProgress
346
+ // (replacing the legacy reportProgress path the panel dropped) so the
347
+ // import hairline advances through the heaviest phase.
348
+ expect(onProgress).toHaveBeenCalledWith(
349
+ "downloading_media",
350
+ expect.any(Number),
351
+ 100,
352
+ expect.any(String),
353
+ );
354
+ // …and the reported overall value is a real (non-zero) fraction of the band,
355
+ // not a constant 0 — the reporter is actually carrying progress.
356
+ const mediaCall = onProgress.mock.calls.find((c) => c[0] === "downloading_media");
357
+ expect(mediaCall?.[1]).toBeGreaterThan(0);
358
+
359
+ // The failed image's SOURCE URL is surfaced for a per-image advisory (so
360
+ // the user sees which image broke, not an opaque "1 failed" count).
361
+ expect(result.failedImageUrls).toContain(
362
+ `https://assets.matters.news/embed/${uuid2}.jpg`,
363
+ );
364
+ // The successful image is NOT listed as failed.
365
+ expect(result.failedImageUrls).not.toContain(
366
+ `https://assets.matters.news/embed/${uuid1}.jpg`,
367
+ );
368
+
369
+ // Verify: File was modified to update successful reference
370
+ const updatedContent = ctx.filesystem.getFile(`${ctx.projectPath}/article.md`)?.content;
371
+ expect(updatedContent).toBeDefined();
372
+
373
+ // UUID1 should be replaced with a filename-only wikilink (B2)
374
+ expect(updatedContent).toContain(`![[${uuid1}.jpg]]`);
375
+ expect(updatedContent).not.toContain(`https://assets.matters.news/embed/${uuid1}.jpg`);
376
+
377
+ // UUID2 should remain as remote URL (download failed)
378
+ expect(updatedContent).toContain(`https://assets.matters.news/embed/${uuid2}.jpg`);
379
+ });
380
+
381
+ it("tracks downloaded UUIDs correctly in map", async () => {
382
+ const uuid = "cccccccc-3333-3333-3333-333333333333";
383
+ const markdownContent = `---
384
+ title: "Test"
385
+ ---
386
+
387
+ ![](https://assets.matters.news/embed/${uuid}.png)
388
+ `;
389
+ ctx.filesystem.setFile(`${ctx.projectPath}/test.md`, markdownContent);
390
+
391
+ ctx.urlConfig.setResponse(`https://assets.matters.news/embed/${uuid}.png`, {
392
+ status: 200,
393
+ ok: true,
394
+ contentType: "image/png",
395
+ bytesWritten: 512,
396
+ actualPath: `assets/${uuid}.png`,
397
+ });
398
+
399
+ const { downloadMediaAndUpdate } = await import("../downloader");
400
+ const result = await downloadMediaAndUpdate();
401
+
402
+ expect(result.imagesDownloaded).toBe(1);
403
+ expect(result.filesProcessed).toBe(1);
404
+
405
+ // Verify the file was updated
406
+ const updatedContent = ctx.filesystem.getFile(`${ctx.projectPath}/test.md`)?.content;
407
+ expect(updatedContent).toContain(`![[${uuid}.png]]`);
408
+ });
409
+
410
+ it("handles file in subdirectory with a depth-independent wikilink", async () => {
411
+ const uuid = "dddddddd-4444-4444-4444-444444444444";
412
+ const markdownContent = `---
413
+ title: "Nested Article"
414
+ ---
415
+
416
+ ![](https://assets.matters.news/embed/${uuid}.jpg)
417
+ `;
418
+ // File in nested directory
419
+ ctx.filesystem.setFile(`${ctx.projectPath}/文章/游记/article.md`, markdownContent);
420
+
421
+ ctx.urlConfig.setResponse(`https://assets.matters.news/embed/${uuid}.jpg`, {
422
+ status: 200,
423
+ ok: true,
424
+ contentType: "image/jpeg",
425
+ bytesWritten: 2048,
426
+ actualPath: `assets/${uuid}.jpg`,
427
+ });
428
+
429
+ const { downloadMediaAndUpdate } = await import("../downloader");
430
+ const result = await downloadMediaAndUpdate();
431
+
432
+ expect(result.imagesDownloaded).toBe(1);
433
+
434
+ // Wikilink is depth-INDEPENDENT: identical at any nesting, no `../` chain.
435
+ const updatedContent = ctx.filesystem.getFile(`${ctx.projectPath}/文章/游记/article.md`)?.content;
436
+ expect(updatedContent).toContain(`![[${uuid}.jpg]]`);
437
+ expect(updatedContent).not.toContain("../");
438
+ });
439
+
440
+ it("skips already existing assets", async () => {
441
+ const existingUuid = "eeeeeeee-5555-5555-5555-555555555555";
442
+ const newUuid = "ffffffff-6666-6666-6666-666666666666";
443
+
444
+ const markdownContent = `---
445
+ title: "Test"
446
+ ---
447
+
448
+ ![](https://assets.matters.news/embed/${existingUuid}.jpg)
449
+ ![](https://assets.matters.news/embed/${newUuid}.jpg)
450
+ `;
451
+ ctx.filesystem.setFile(`${ctx.projectPath}/article.md`, markdownContent);
452
+
453
+ // Simulate existing asset on disk
454
+ ctx.filesystem.setFile(`${ctx.projectPath}/assets/${existingUuid}.jpg`, "[binary image data]");
455
+
456
+ // Only configure the new UUID
457
+ ctx.urlConfig.setResponse(`https://assets.matters.news/embed/${newUuid}.jpg`, {
458
+ status: 200,
459
+ ok: true,
460
+ contentType: "image/jpeg",
461
+ bytesWritten: 1024,
462
+ actualPath: `assets/${newUuid}.jpg`,
463
+ });
464
+
465
+ const { downloadMediaAndUpdate } = await import("../downloader");
466
+ const result = await downloadMediaAndUpdate();
467
+
468
+ // Only 1 new download (existing was skipped)
469
+ expect(result.imagesDownloaded).toBe(1);
470
+ expect(result.imagesSkipped).toBe(1);
471
+ });
472
+
473
+ it("updates cover reference in frontmatter", async () => {
474
+ const uuid = "11111111-aaaa-bbbb-cccc-dddddddddddd";
475
+ const markdownContent = `---
476
+ title: "Article with Cover"
477
+ cover: "https://imagedelivery.net/xxx/prod/embed/${uuid}.jpeg/public"
478
+ ---
479
+
480
+ # Content
481
+ `;
482
+ ctx.filesystem.setFile(`${ctx.projectPath}/article.md`, markdownContent);
483
+
484
+ ctx.urlConfig.setResponse(`https://imagedelivery.net/xxx/prod/embed/${uuid}.jpeg/public`, {
485
+ status: 200,
486
+ ok: true,
487
+ contentType: "image/jpeg",
488
+ bytesWritten: 4096,
489
+ actualPath: `assets/${uuid}.jpg`,
490
+ });
491
+
492
+ const { downloadMediaAndUpdate } = await import("../downloader");
493
+ const result = await downloadMediaAndUpdate();
494
+
495
+ expect(result.imagesDownloaded).toBe(1);
496
+ expect(result.filesProcessed).toBe(1);
497
+
498
+ const updatedContent = ctx.filesystem.getFile(`${ctx.projectPath}/article.md`)?.content;
499
+ // Cover → bare filename (the shared asset resolver finds it); no path prefix.
500
+ expect(updatedContent).toContain(`${uuid}.jpg`);
501
+ expect(updatedContent).not.toContain("imagedelivery.net");
502
+ expect(updatedContent).not.toContain("assets/");
503
+ });
504
+
505
+ it("updates references when asset already exists (self-correcting)", async () => {
506
+ // This test reproduces the production bug:
507
+ // 1. Asset was downloaded in a previous run (exists on disk)
508
+ // 2. But the file still has remote URL (previous run was interrupted)
509
+ // 3. Running again should update the reference even though download is skipped
510
+ const uuid = "2ef1d558-bca4-4792-bb63-41ee12fa95ac";
511
+ const markdownContent = `---
512
+ title: "色达"
513
+ ---
514
+
515
+ ![](https://assets.matters.news/embed/${uuid}.jpeg)
516
+ `;
517
+ ctx.filesystem.setFile(`${ctx.projectPath}/文章/游记/色达.md`, markdownContent);
518
+
519
+ // Asset already exists on disk (from previous interrupted run)
520
+ ctx.filesystem.setFile(`${ctx.projectPath}/assets/${uuid}.jpg`, "[binary image data]");
521
+
522
+ const { downloadMediaAndUpdate } = await import("../downloader");
523
+ const result = await downloadMediaAndUpdate();
524
+
525
+ // Asset should be skipped (already exists), not downloaded
526
+ expect(result.imagesDownloaded).toBe(0);
527
+ expect(result.imagesSkipped).toBe(1);
528
+
529
+ // But file should still be updated with local path
530
+ expect(result.filesProcessed).toBe(1);
531
+
532
+ const updatedContent = ctx.filesystem.getFile(`${ctx.projectPath}/文章/游记/色达.md`)?.content;
533
+ expect(updatedContent).toBeDefined();
534
+ // Depth-independent wikilink (asset on disk is .jpg though the URL was .jpeg).
535
+ expect(updatedContent).toContain(`![[${uuid}.jpg]]`);
536
+ expect(updatedContent).not.toContain(`https://assets.matters.news/embed/${uuid}.jpeg`);
537
+ });
538
+
539
+ it("updates multiple references when multiple assets already exist", async () => {
540
+ // Same scenario but with multiple images
541
+ const uuid1 = "aaaa1111-1111-1111-1111-111111111111";
542
+ const uuid2 = "bbbb2222-2222-2222-2222-222222222222";
543
+ const uuid3 = "cccc3333-3333-3333-3333-333333333333";
544
+
545
+ const markdownContent = `---
546
+ title: "Multi Image Article"
547
+ cover: "https://assets.matters.news/embed/${uuid1}.jpg"
548
+ ---
549
+
550
+ ![](https://assets.matters.news/embed/${uuid2}.png)
551
+
552
+ Some text
553
+
554
+ ![](https://assets.matters.news/embed/${uuid3}.jpeg)
555
+ `;
556
+ ctx.filesystem.setFile(`${ctx.projectPath}/nested/dir/article.md`, markdownContent);
557
+
558
+ // All 3 assets exist on disk
559
+ ctx.filesystem.setFile(`${ctx.projectPath}/assets/${uuid1}.jpg`, "[image 1]");
560
+ ctx.filesystem.setFile(`${ctx.projectPath}/assets/${uuid2}.png`, "[image 2]");
561
+ ctx.filesystem.setFile(`${ctx.projectPath}/assets/${uuid3}.jpeg`, "[image 3]");
562
+
563
+ const { downloadMediaAndUpdate } = await import("../downloader");
564
+ const result = await downloadMediaAndUpdate();
565
+
566
+ // All 3 skipped, none downloaded
567
+ expect(result.imagesDownloaded).toBe(0);
568
+ expect(result.imagesSkipped).toBe(3);
569
+
570
+ // File should be updated
571
+ expect(result.filesProcessed).toBe(1);
572
+
573
+ const updatedContent = ctx.filesystem.getFile(`${ctx.projectPath}/nested/dir/article.md`)?.content;
574
+ expect(updatedContent).toBeDefined();
575
+
576
+ // cover → bare filename; body images → depth-independent wikilinks.
577
+ expect(updatedContent).toContain(`${uuid1}.jpg`);
578
+ expect(updatedContent).toContain(`![[${uuid2}.png]]`);
579
+ expect(updatedContent).toContain(`![[${uuid3}.jpeg]]`);
580
+ expect(updatedContent).not.toContain("../");
581
+
582
+ // None should have remote URLs
583
+ expect(updatedContent).not.toContain("https://assets.matters.news");
584
+ });
585
+
586
+ it("should NOT write file when all references are already local paths", async () => {
587
+ // This is the key test for Issue #5: Media download writes files unnecessarily
588
+ // If all image references are already local, the file should NOT be written
589
+ const uuid = "12345678-1234-1234-1234-123456789abc";
590
+
591
+ // Markdown file with ALREADY LOCAL references (no remote URLs at all)
592
+ const markdownContent = `---
593
+ title: "Already Localized Article"
594
+ cover: "assets/${uuid}.jpg"
595
+ ---
596
+
597
+ ![](assets/${uuid}.jpg)
598
+
599
+ Already using local path.
600
+ `;
601
+ ctx.filesystem.setFile(`${ctx.projectPath}/article.md`, markdownContent);
602
+
603
+ // Asset exists on disk
604
+ ctx.filesystem.setFile(`${ctx.projectPath}/assets/${uuid}.jpg`, "[binary image data]");
605
+
606
+ const { downloadMediaAndUpdate } = await import("../downloader");
607
+ const result = await downloadMediaAndUpdate();
608
+
609
+ // No downloads (no remote URLs to download)
610
+ expect(result.imagesDownloaded).toBe(0);
611
+ // No files should be skipped (no remote URLs to check)
612
+ expect(result.imagesSkipped).toBe(0);
613
+ // File should NOT be processed (no changes needed)
614
+ expect(result.filesProcessed).toBe(0);
615
+ });
616
+
617
+ it("localizes a legacy non-UUID CDN image (B6)", async () => {
618
+ // A legacy cloudfront URL with NO UUID segment — previously Phase 3 skipped
619
+ // it (`if (!media.uuid) continue;`), leaving the dead CDN URL in the body.
620
+ const legacyUrl = "https://d1y0vy6cjcgwlk.cloudfront.net/legacy/photo.jpg";
621
+ const markdownContent = `---
622
+ title: "Legacy Image Article"
623
+ ---
624
+
625
+ ![a caption](${legacyUrl})
626
+ `;
627
+ ctx.filesystem.setFile(`${ctx.projectPath}/文章/old-post.md`, markdownContent);
628
+
629
+ // The download succeeds; moss derives a local filename for the non-UUID asset.
630
+ ctx.urlConfig.setResponse(legacyUrl, {
631
+ status: 200,
632
+ ok: true,
633
+ contentType: "image/jpeg",
634
+ bytesWritten: 1024,
635
+ actualPath: "assets/photo.jpg",
636
+ });
637
+
638
+ const { downloadMediaAndUpdate } = await import("../downloader");
639
+ const result = await downloadMediaAndUpdate();
640
+
641
+ expect(result.imagesDownloaded).toBe(1);
642
+ expect(result.filesProcessed).toBe(1);
643
+
644
+ const updatedContent = ctx.filesystem.getFile(`${ctx.projectPath}/文章/old-post.md`)?.content;
645
+ expect(updatedContent).toBeDefined();
646
+ // The legacy image is now a depth-independent wikilink; the CDN URL is gone.
647
+ expect(updatedContent).toContain("![[photo.jpg]]");
648
+ expect(updatedContent).not.toContain("cloudfront.net");
649
+ expect(updatedContent).not.toContain("../");
650
+ });
651
+
652
+ it("should not write file if replacement results in identical content", async () => {
653
+ // This tests the scenario where:
654
+ // 1. File still has remote URL
655
+ // 2. Asset exists on disk
656
+ // 3. Replacement would result in same content (edge case)
657
+ const uuid = "87654321-4321-4321-4321-987654321abc";
658
+
659
+ // File with remote URL, but the "replacement" local path is identical to what's there
660
+ // This is a contrived case but tests the comparison logic
661
+ const markdownContent = `---
662
+ title: "Article"
663
+ cover: "../../assets/${uuid}.jpg"
664
+ ---
665
+
666
+ ![](../../assets/${uuid}.jpg)
667
+ `;
668
+ ctx.filesystem.setFile(`${ctx.projectPath}/nested/dir/article.md`, markdownContent);
669
+
670
+ // Asset exists on disk
671
+ ctx.filesystem.setFile(`${ctx.projectPath}/assets/${uuid}.jpg`, "[binary image data]");
672
+
673
+ const { downloadMediaAndUpdate } = await import("../downloader");
674
+ const result = await downloadMediaAndUpdate();
675
+
676
+ // File should NOT be processed (content unchanged after any attempted replacements)
677
+ expect(result.filesProcessed).toBe(0);
678
+ });
679
+ });