@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,575 @@
1
+ /**
2
+ * Step definitions for self-correcting reference updates
3
+ * Tests that image references are updated after download (or skip if already exists)
4
+ */
5
+ import { loadFeature, describeFeature } from "@amiceli/vitest-cucumber";
6
+ import { expect } from "vitest";
7
+ import {
8
+ setupMockTauri,
9
+ type MockTauriContext,
10
+ } from "@symbiosis-lab/moss-api/testing";
11
+
12
+ const feature = await loadFeature("features/download/self-correcting.feature");
13
+
14
+ describeFeature(feature, ({ Scenario }) => {
15
+ let ctx: MockTauriContext | null = null;
16
+ const projectPath = "/test/project";
17
+ let downloadResult: {
18
+ filesProcessed: number;
19
+ imagesDownloaded: number;
20
+ imagesSkipped: number;
21
+ errors: string[];
22
+ } | null = null;
23
+
24
+ // UUIDs used in tests
25
+ const uuid1 = "82ba1757-adc9-4a37-b097-8edbf38e9b9f";
26
+ const uuid2 = "12345678-1234-1234-1234-123456789abc";
27
+
28
+ // ============================================================================
29
+ // Scenario: Updates references when assets already exist
30
+ // ============================================================================
31
+ Scenario("Updates references when assets already exist", ({ Given, When, Then, And }) => {
32
+ Given("a mock Tauri environment", () => {
33
+ ctx = setupMockTauri();
34
+ downloadResult = null;
35
+ expect(ctx).toBeDefined();
36
+ });
37
+
38
+ And("an in-memory filesystem", () => {
39
+ expect(ctx!.filesystem).toBeDefined();
40
+ });
41
+
42
+ And("a markdown file with remote image URLs", () => {
43
+ const markdownContent = `---
44
+ title: "Test Article"
45
+ cover: "https://imagedelivery.net/xxx/prod/embed/${uuid1}/image.jpg/public"
46
+ ---
47
+
48
+ Some text here.
49
+
50
+ ![](https://assets.matters.news/embed/${uuid1}/image.jpg)
51
+ `;
52
+ ctx!.filesystem.setFile(`${projectPath}/文章/test.md`, markdownContent);
53
+ });
54
+
55
+ And("the assets already exist locally", () => {
56
+ // Asset already exists - simulating a previous download
57
+ ctx!.filesystem.setFile(`${projectPath}/assets/${uuid1}.jpg`, "[binary image data]");
58
+ });
59
+
60
+ When("I run downloadMediaAndUpdate", async () => {
61
+ const { downloadMediaAndUpdate } = await import("../../src/downloader");
62
+ downloadResult = await downloadMediaAndUpdate();
63
+ });
64
+
65
+ Then("no downloads should occur", () => {
66
+ expect(ctx!.downloadTracker.completedDownloads.length).toBe(0);
67
+ });
68
+
69
+ And("all image references should be updated to local paths", () => {
70
+ const content = ctx!.filesystem.getFile(`${projectPath}/文章/test.md`)?.content;
71
+ expect(content).toBeDefined();
72
+ // Cover should be updated to relative path
73
+ expect(content).toContain(`cover: "${uuid1}.jpg"`);
74
+ // Body image should be updated to relative path
75
+ expect(content).toContain(`![[${uuid1}.jpg]]`);
76
+ // Should NOT contain remote URLs
77
+ expect(content).not.toContain("https://assets.matters.news");
78
+ expect(content).not.toContain("https://imagedelivery.net");
79
+ ctx?.cleanup();
80
+ });
81
+ });
82
+
83
+ // ============================================================================
84
+ // Scenario: Downloads and updates in single pass
85
+ // ============================================================================
86
+ Scenario("Downloads and updates in single pass", ({ Given, When, Then, And }) => {
87
+ Given("a mock Tauri environment", () => {
88
+ ctx = setupMockTauri();
89
+ downloadResult = null;
90
+ expect(ctx).toBeDefined();
91
+ });
92
+
93
+ And("an in-memory filesystem", () => {
94
+ expect(ctx!.filesystem).toBeDefined();
95
+ });
96
+
97
+ And("a markdown file with remote image URLs", () => {
98
+ const markdownContent = `---
99
+ title: "Test Article"
100
+ ---
101
+
102
+ ![](https://assets.matters.news/embed/${uuid1}/image.jpg)
103
+ `;
104
+ ctx!.filesystem.setFile(`${projectPath}/文章/test.md`, markdownContent);
105
+
106
+ // Configure mock response for the download
107
+ ctx!.urlConfig.setResponse(`https://assets.matters.news/embed/${uuid1}/image.jpg`, {
108
+ status: 200,
109
+ ok: true,
110
+ contentType: "image/jpeg",
111
+ bytesWritten: 1024,
112
+ actualPath: `assets/${uuid1}.jpg`,
113
+ });
114
+ });
115
+
116
+ And("no assets exist locally", () => {
117
+ // No assets in the assets folder - fresh state
118
+ expect(ctx!.filesystem.listFiles(`${projectPath}/assets/*`).length).toBe(0);
119
+ });
120
+
121
+ When("I run downloadMediaAndUpdate", async () => {
122
+ const { downloadMediaAndUpdate } = await import("../../src/downloader");
123
+ downloadResult = await downloadMediaAndUpdate();
124
+ });
125
+
126
+ Then("all images should be downloaded", () => {
127
+ expect(ctx!.downloadTracker.completedDownloads.length).toBe(1);
128
+ expect(downloadResult!.imagesDownloaded).toBe(1);
129
+ });
130
+
131
+ And("all image references should be updated to local paths", () => {
132
+ const content = ctx!.filesystem.getFile(`${projectPath}/文章/test.md`)?.content;
133
+ expect(content).toBeDefined();
134
+ expect(content).toContain(`![[${uuid1}.jpg]]`);
135
+ expect(content).not.toContain("https://assets.matters.news");
136
+ ctx?.cleanup();
137
+ });
138
+ });
139
+
140
+ // ============================================================================
141
+ // Scenario: Resumes correctly after interruption
142
+ // ============================================================================
143
+ Scenario("Resumes correctly after interruption", ({ Given, When, Then, And }) => {
144
+ Given("a mock Tauri environment", () => {
145
+ ctx = setupMockTauri();
146
+ downloadResult = null;
147
+ expect(ctx).toBeDefined();
148
+ });
149
+
150
+ And("an in-memory filesystem", () => {
151
+ expect(ctx!.filesystem).toBeDefined();
152
+ });
153
+
154
+ And("a markdown file with multiple remote image URLs", () => {
155
+ const markdownContent = `---
156
+ title: "Test Article"
157
+ ---
158
+
159
+ ![](https://assets.matters.news/embed/${uuid1}/a.jpg)
160
+ ![](https://assets.matters.news/embed/${uuid2}/b.jpg)
161
+ `;
162
+ ctx!.filesystem.setFile(`${projectPath}/文章/test.md`, markdownContent);
163
+
164
+ // Only configure response for uuid2 (uuid1 already exists)
165
+ ctx!.urlConfig.setResponse(`https://assets.matters.news/embed/${uuid2}/b.jpg`, {
166
+ status: 200,
167
+ ok: true,
168
+ contentType: "image/jpeg",
169
+ bytesWritten: 1024,
170
+ actualPath: `assets/${uuid2}.jpg`,
171
+ });
172
+ });
173
+
174
+ And("some assets already exist locally", () => {
175
+ // uuid1 already downloaded (simulating interrupted run)
176
+ ctx!.filesystem.setFile(`${projectPath}/assets/${uuid1}.jpg`, "[binary image data]");
177
+ // uuid2 not downloaded yet
178
+ });
179
+
180
+ When("I run downloadMediaAndUpdate", async () => {
181
+ const { downloadMediaAndUpdate } = await import("../../src/downloader");
182
+ downloadResult = await downloadMediaAndUpdate();
183
+ });
184
+
185
+ Then("only missing assets should be downloaded", () => {
186
+ // Only uuid2 should be downloaded
187
+ expect(ctx!.downloadTracker.completedDownloads.length).toBe(1);
188
+ expect(downloadResult!.imagesDownloaded).toBe(1);
189
+ });
190
+
191
+ And("all image references should be updated to local paths", () => {
192
+ const content = ctx!.filesystem.getFile(`${projectPath}/文章/test.md`)?.content;
193
+ expect(content).toBeDefined();
194
+ expect(content).toContain(`![[${uuid1}.jpg]]`);
195
+ expect(content).toContain(`![[${uuid2}.jpg]]`);
196
+ expect(content).not.toContain("https://assets.matters.news");
197
+ ctx?.cleanup();
198
+ });
199
+ });
200
+
201
+ // ============================================================================
202
+ // Scenario: Handles cross-CDN URLs with same UUID
203
+ // ============================================================================
204
+ Scenario("Handles cross-CDN URLs with same UUID", ({ Given, When, Then, And }) => {
205
+ Given("a mock Tauri environment", () => {
206
+ ctx = setupMockTauri();
207
+ downloadResult = null;
208
+ expect(ctx).toBeDefined();
209
+ });
210
+
211
+ And("an in-memory filesystem", () => {
212
+ expect(ctx!.filesystem).toBeDefined();
213
+ });
214
+
215
+ And("a markdown file with cover and body images using different CDNs", () => {
216
+ // Cover uses imagedelivery.net, body uses assets.matters.news
217
+ const markdownContent = `---
218
+ title: "Test Article"
219
+ cover: "https://imagedelivery.net/kDRCweMmqLnTPNlbum-pYA/prod/embed/${uuid1}/image.jpg/public"
220
+ ---
221
+
222
+ ![](https://assets.matters.news/embed/${uuid1}/image.jpg)
223
+ `;
224
+ ctx!.filesystem.setFile(`${projectPath}/文章/test.md`, markdownContent);
225
+
226
+ // Configure response for one of the URLs (both have same UUID)
227
+ ctx!.urlConfig.setResponse(`https://assets.matters.news/embed/${uuid1}/image.jpg`, {
228
+ status: 200,
229
+ ok: true,
230
+ contentType: "image/jpeg",
231
+ bytesWritten: 1024,
232
+ actualPath: `assets/${uuid1}.jpg`,
233
+ });
234
+ ctx!.urlConfig.setResponse(`https://imagedelivery.net/kDRCweMmqLnTPNlbum-pYA/prod/embed/${uuid1}/image.jpg/public`, {
235
+ status: 200,
236
+ ok: true,
237
+ contentType: "image/jpeg",
238
+ bytesWritten: 1024,
239
+ actualPath: `assets/${uuid1}.jpg`,
240
+ });
241
+ });
242
+
243
+ And("both URLs contain the same UUID", () => {
244
+ // This is already set up above - both URLs contain uuid1
245
+ });
246
+
247
+ When("I run downloadMediaAndUpdate", async () => {
248
+ const { downloadMediaAndUpdate } = await import("../../src/downloader");
249
+ downloadResult = await downloadMediaAndUpdate();
250
+ });
251
+
252
+ Then("only one download should occur", () => {
253
+ // Both URLs have the same UUID, so only one download should happen
254
+ expect(ctx!.downloadTracker.completedDownloads.length).toBeLessThanOrEqual(1);
255
+ });
256
+
257
+ And("both cover and body references should point to the same local file", () => {
258
+ const content = ctx!.filesystem.getFile(`${projectPath}/文章/test.md`)?.content;
259
+ expect(content).toBeDefined();
260
+ // Both should point to the same local path
261
+ expect(content).toContain(`cover: "${uuid1}.jpg"`);
262
+ expect(content).toContain(`![[${uuid1}.jpg]]`);
263
+ expect(content).not.toContain("https://imagedelivery.net");
264
+ expect(content).not.toContain("https://assets.matters.news");
265
+ ctx?.cleanup();
266
+ });
267
+ });
268
+
269
+ // ============================================================================
270
+ // Scenario: Idempotent operation
271
+ // ============================================================================
272
+ Scenario("Idempotent operation", ({ Given, When, Then, And }) => {
273
+ let originalContent: string;
274
+
275
+ Given("a mock Tauri environment", () => {
276
+ ctx = setupMockTauri();
277
+ downloadResult = null;
278
+ expect(ctx).toBeDefined();
279
+ });
280
+
281
+ And("an in-memory filesystem", () => {
282
+ expect(ctx!.filesystem).toBeDefined();
283
+ });
284
+
285
+ And("a markdown file with local image references", () => {
286
+ const markdownContent = `---
287
+ title: "Test Article"
288
+ cover: "${uuid1}.jpg"
289
+ ---
290
+
291
+ ![[${uuid1}.jpg]]
292
+ `;
293
+ ctx!.filesystem.setFile(`${projectPath}/文章/test.md`, markdownContent);
294
+ originalContent = markdownContent;
295
+ });
296
+
297
+ And("all assets exist locally", () => {
298
+ ctx!.filesystem.setFile(`${projectPath}/assets/${uuid1}.jpg`, "[binary image data]");
299
+ });
300
+
301
+ When("I run downloadMediaAndUpdate twice", async () => {
302
+ const { downloadMediaAndUpdate } = await import("../../src/downloader");
303
+ await downloadMediaAndUpdate();
304
+ downloadResult = await downloadMediaAndUpdate();
305
+ });
306
+
307
+ Then("no downloads should occur", () => {
308
+ expect(ctx!.downloadTracker.completedDownloads.length).toBe(0);
309
+ });
310
+
311
+ And("the file should not be modified", () => {
312
+ const content = ctx!.filesystem.getFile(`${projectPath}/文章/test.md`)?.content;
313
+ expect(content).toBe(originalContent);
314
+ ctx?.cleanup();
315
+ });
316
+ });
317
+
318
+ // ============================================================================
319
+ // Incremental Write Behavior Tests
320
+ // These tests verify the core design principle: files are written immediately
321
+ // after processing, not batched at the end.
322
+ // ============================================================================
323
+
324
+ // Additional UUIDs for multi-file tests
325
+ const uuid3 = "33333333-3333-3333-3333-333333333333";
326
+
327
+ // ============================================================================
328
+ // Scenario: Files are written immediately after processing (not batched)
329
+ // ============================================================================
330
+ Scenario("Files are written immediately after processing (not batched)", ({ Given, When, Then, And }) => {
331
+ Given("a mock Tauri environment", () => {
332
+ ctx = setupMockTauri();
333
+ downloadResult = null;
334
+ expect(ctx).toBeDefined();
335
+ });
336
+
337
+ And("an in-memory filesystem", () => {
338
+ expect(ctx!.filesystem).toBeDefined();
339
+ });
340
+
341
+ And("three markdown files each containing a unique remote image", () => {
342
+ ctx!.filesystem.setFile(`${projectPath}/文章/file1.md`, `---
343
+ title: "File 1"
344
+ ---
345
+ ![](https://assets.matters.news/embed/${uuid1}/img1.jpg)
346
+ `);
347
+ ctx!.filesystem.setFile(`${projectPath}/文章/file2.md`, `---
348
+ title: "File 2"
349
+ ---
350
+ ![](https://assets.matters.news/embed/${uuid2}/img2.jpg)
351
+ `);
352
+ ctx!.filesystem.setFile(`${projectPath}/文章/file3.md`, `---
353
+ title: "File 3"
354
+ ---
355
+ ![](https://assets.matters.news/embed/${uuid3}/img3.jpg)
356
+ `);
357
+ });
358
+
359
+ And("downloads are configured to succeed for all files", () => {
360
+ ctx!.urlConfig.setResponse(`https://assets.matters.news/embed/${uuid1}/img1.jpg`, {
361
+ status: 200,
362
+ ok: true,
363
+ contentType: "image/jpeg",
364
+ bytesWritten: 1024,
365
+ actualPath: `assets/${uuid1}.jpg`,
366
+ });
367
+ ctx!.urlConfig.setResponse(`https://assets.matters.news/embed/${uuid2}/img2.jpg`, {
368
+ status: 200,
369
+ ok: true,
370
+ contentType: "image/jpeg",
371
+ bytesWritten: 1024,
372
+ actualPath: `assets/${uuid2}.jpg`,
373
+ });
374
+ ctx!.urlConfig.setResponse(`https://assets.matters.news/embed/${uuid3}/img3.jpg`, {
375
+ status: 200,
376
+ ok: true,
377
+ contentType: "image/jpeg",
378
+ bytesWritten: 1024,
379
+ actualPath: `assets/${uuid3}.jpg`,
380
+ });
381
+ });
382
+
383
+ When("I run downloadMediaAndUpdate", async () => {
384
+ const { downloadMediaAndUpdate } = await import("../../src/downloader");
385
+ downloadResult = await downloadMediaAndUpdate();
386
+ });
387
+
388
+ Then("all three files should have updated references", () => {
389
+ const content1 = ctx!.filesystem.getFile(`${projectPath}/文章/file1.md`)?.content;
390
+ const content2 = ctx!.filesystem.getFile(`${projectPath}/文章/file2.md`)?.content;
391
+ const content3 = ctx!.filesystem.getFile(`${projectPath}/文章/file3.md`)?.content;
392
+
393
+ expect(content1).toContain(`![[${uuid1}.jpg]]`);
394
+ expect(content2).toContain(`![[${uuid2}.jpg]]`);
395
+ expect(content3).toContain(`![[${uuid3}.jpg]]`);
396
+ });
397
+
398
+ And("all three files should be written to disk", () => {
399
+ // filesProcessed should be 3
400
+ expect(downloadResult!.filesProcessed).toBe(3);
401
+ ctx?.cleanup();
402
+ });
403
+ });
404
+
405
+ // ============================================================================
406
+ // Scenario: Early files are saved when later downloads fail
407
+ // ============================================================================
408
+ Scenario("Early files are saved when later downloads fail", ({ Given, When, Then, And }) => {
409
+ Given("a mock Tauri environment", () => {
410
+ ctx = setupMockTauri();
411
+ downloadResult = null;
412
+ expect(ctx).toBeDefined();
413
+ });
414
+
415
+ And("an in-memory filesystem", () => {
416
+ expect(ctx!.filesystem).toBeDefined();
417
+ });
418
+
419
+ And("three markdown files each containing a unique remote image", () => {
420
+ ctx!.filesystem.setFile(`${projectPath}/文章/file1.md`, `---
421
+ title: "File 1"
422
+ ---
423
+ ![](https://assets.matters.news/embed/${uuid1}/img1.jpg)
424
+ `);
425
+ ctx!.filesystem.setFile(`${projectPath}/文章/file2.md`, `---
426
+ title: "File 2"
427
+ ---
428
+ ![](https://assets.matters.news/embed/${uuid2}/img2.jpg)
429
+ `);
430
+ ctx!.filesystem.setFile(`${projectPath}/文章/file3.md`, `---
431
+ title: "File 3"
432
+ ---
433
+ ![](https://assets.matters.news/embed/${uuid3}/img3.jpg)
434
+ `);
435
+ });
436
+
437
+ And("the second file's download is configured to fail", () => {
438
+ ctx!.urlConfig.setResponse(`https://assets.matters.news/embed/${uuid1}/img1.jpg`, {
439
+ status: 200,
440
+ ok: true,
441
+ contentType: "image/jpeg",
442
+ bytesWritten: 1024,
443
+ actualPath: `assets/${uuid1}.jpg`,
444
+ });
445
+ // Second file's download fails with 404
446
+ ctx!.urlConfig.setResponse(`https://assets.matters.news/embed/${uuid2}/img2.jpg`, {
447
+ status: 404,
448
+ ok: false,
449
+ });
450
+ ctx!.urlConfig.setResponse(`https://assets.matters.news/embed/${uuid3}/img3.jpg`, {
451
+ status: 200,
452
+ ok: true,
453
+ contentType: "image/jpeg",
454
+ bytesWritten: 1024,
455
+ actualPath: `assets/${uuid3}.jpg`,
456
+ });
457
+ });
458
+
459
+ When("I run downloadMediaAndUpdate", async () => {
460
+ const { downloadMediaAndUpdate } = await import("../../src/downloader");
461
+ downloadResult = await downloadMediaAndUpdate();
462
+ });
463
+
464
+ Then("the first file should have updated references and be written", () => {
465
+ const content1 = ctx!.filesystem.getFile(`${projectPath}/文章/file1.md`)?.content;
466
+ expect(content1).toContain(`![[${uuid1}.jpg]]`);
467
+ expect(content1).not.toContain("https://assets.matters.news");
468
+ });
469
+
470
+ And("the second file should still have remote references", () => {
471
+ const content2 = ctx!.filesystem.getFile(`${projectPath}/文章/file2.md`)?.content;
472
+ // The remote URL should still be there since download failed
473
+ expect(content2).toContain(`https://assets.matters.news/embed/${uuid2}/img2.jpg`);
474
+ });
475
+
476
+ And("the third file should have updated references and be written", () => {
477
+ const content3 = ctx!.filesystem.getFile(`${projectPath}/文章/file3.md`)?.content;
478
+ expect(content3).toContain(`![[${uuid3}.jpg]]`);
479
+ expect(content3).not.toContain("https://assets.matters.news");
480
+ ctx?.cleanup();
481
+ });
482
+ });
483
+
484
+ // ============================================================================
485
+ // Scenario: Write happens per-file not per-image
486
+ // ============================================================================
487
+ Scenario("Write happens per-file not per-image", ({ Given, When, Then, And }) => {
488
+ let writeCount = 0;
489
+
490
+ Given("a mock Tauri environment", () => {
491
+ ctx = setupMockTauri();
492
+ downloadResult = null;
493
+ writeCount = 0;
494
+
495
+ // Intercept writes to count them
496
+ const originalWriteProjectFile = (ctx!.filesystem as { _originalSetFile?: typeof ctx.filesystem.setFile })._originalSetFile
497
+ || ctx!.filesystem.setFile.bind(ctx!.filesystem);
498
+
499
+ // Store original if not already stored
500
+ if (!(ctx!.filesystem as { _originalSetFile?: typeof ctx.filesystem.setFile })._originalSetFile) {
501
+ (ctx!.filesystem as { _originalSetFile?: typeof ctx.filesystem.setFile })._originalSetFile = originalWriteProjectFile;
502
+ }
503
+
504
+ // Wrap setFile to count writes to our test file
505
+ const wrappedSetFile = (path: string, content: string) => {
506
+ if (path === `${projectPath}/文章/multi-image.md`) {
507
+ writeCount++;
508
+ }
509
+ return originalWriteProjectFile(path, content);
510
+ };
511
+ ctx!.filesystem.setFile = wrappedSetFile;
512
+
513
+ expect(ctx).toBeDefined();
514
+ });
515
+
516
+ And("an in-memory filesystem", () => {
517
+ expect(ctx!.filesystem).toBeDefined();
518
+ });
519
+
520
+ And("a markdown file with three remote images", () => {
521
+ ctx!.filesystem.setFile(`${projectPath}/文章/multi-image.md`, `---
522
+ title: "Multi Image"
523
+ ---
524
+ ![](https://assets.matters.news/embed/${uuid1}/img1.jpg)
525
+ ![](https://assets.matters.news/embed/${uuid2}/img2.jpg)
526
+ ![](https://assets.matters.news/embed/${uuid3}/img3.jpg)
527
+ `);
528
+ // Reset count after initial setup
529
+ writeCount = 0;
530
+ });
531
+
532
+ And("all three downloads are configured to succeed", () => {
533
+ ctx!.urlConfig.setResponse(`https://assets.matters.news/embed/${uuid1}/img1.jpg`, {
534
+ status: 200,
535
+ ok: true,
536
+ contentType: "image/jpeg",
537
+ bytesWritten: 1024,
538
+ actualPath: `assets/${uuid1}.jpg`,
539
+ });
540
+ ctx!.urlConfig.setResponse(`https://assets.matters.news/embed/${uuid2}/img2.jpg`, {
541
+ status: 200,
542
+ ok: true,
543
+ contentType: "image/jpeg",
544
+ bytesWritten: 1024,
545
+ actualPath: `assets/${uuid2}.jpg`,
546
+ });
547
+ ctx!.urlConfig.setResponse(`https://assets.matters.news/embed/${uuid3}/img3.jpg`, {
548
+ status: 200,
549
+ ok: true,
550
+ contentType: "image/jpeg",
551
+ bytesWritten: 1024,
552
+ actualPath: `assets/${uuid3}.jpg`,
553
+ });
554
+ });
555
+
556
+ When("I run downloadMediaAndUpdate", async () => {
557
+ const { downloadMediaAndUpdate } = await import("../../src/downloader");
558
+ downloadResult = await downloadMediaAndUpdate();
559
+ });
560
+
561
+ Then("the file should be written exactly once with all three references updated", () => {
562
+ // File should be written exactly once (after all images processed)
563
+ expect(writeCount).toBe(1);
564
+
565
+ // All three references should be updated
566
+ const content = ctx!.filesystem.getFile(`${projectPath}/文章/multi-image.md`)?.content;
567
+ expect(content).toContain(`![[${uuid1}.jpg]]`);
568
+ expect(content).toContain(`![[${uuid2}.jpg]]`);
569
+ expect(content).toContain(`![[${uuid3}.jpg]]`);
570
+ expect(content).not.toContain("https://assets.matters.news");
571
+
572
+ ctx?.cleanup();
573
+ });
574
+ });
575
+ });