@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.
- package/CHANGELOG.md +88 -0
- package/README.md +18 -0
- package/assets/icon.svg +1 -0
- package/assets/manifest.json +36 -0
- package/codegen.ts +26 -0
- package/e2e/moss-cli.test.ts +338 -0
- package/features/api/fetch-articles.feature +39 -0
- package/features/auth/wallet-auth.feature +27 -0
- package/features/download/retry-logic.feature +36 -0
- package/features/download/self-correcting.feature +83 -0
- package/features/download/worker-pool.feature +29 -0
- package/features/social/fetch-social-data.feature +40 -0
- package/features/steps/api.steps.ts +180 -0
- package/features/steps/download.steps.ts +423 -0
- package/features/steps/incremental-sync.steps.ts +105 -0
- package/features/steps/self-correcting.steps.ts +575 -0
- package/features/steps/social.steps.ts +257 -0
- package/features/steps/syndication.steps.ts +264 -0
- package/features/steps/wallet-auth.steps.ts +185 -0
- package/features/sync/article-sync.feature +49 -0
- package/features/sync/homepage-grid.feature +43 -0
- package/features/sync/incremental-sync.feature +28 -0
- package/features/syndication/create-draft.feature +35 -0
- package/package.json +58 -0
- package/src/__generated__/schema.graphql +4289 -0
- package/src/__generated__/types.ts +5355 -0
- package/src/__tests__/api.test.ts +678 -0
- package/src/__tests__/auth-route.test.ts +38 -0
- package/src/__tests__/auth-routing.test.ts +462 -0
- package/src/__tests__/auto-detect.test.ts +412 -0
- package/src/__tests__/binding-guard.test.ts +256 -0
- package/src/__tests__/config.test.ts +212 -0
- package/src/__tests__/converter.test.ts +289 -0
- package/src/__tests__/credential.test.ts +332 -0
- package/src/__tests__/domain.test.ts +341 -0
- package/src/__tests__/downloader.test.ts +679 -0
- package/src/__tests__/folder-detection.test.ts +289 -0
- package/src/__tests__/force-fresh-login.test.ts +236 -0
- package/src/__tests__/main.test.ts +2437 -0
- package/src/__tests__/progress.test.ts +93 -0
- package/src/__tests__/session.test.ts +375 -0
- package/src/__tests__/social-integration.test.ts +386 -0
- package/src/__tests__/social-sync-logic.test.ts +107 -0
- package/src/__tests__/social.test.ts +788 -0
- package/src/__tests__/sync.test.ts +1273 -0
- package/src/__tests__/syndication-toast-law.test.ts +649 -0
- package/src/__tests__/syndication.test.ts +125 -0
- package/src/__tests__/test-profile-escape.test.ts +209 -0
- package/src/__tests__/url-detect.test.ts +79 -0
- package/src/__tests__/utils.test.ts +226 -0
- package/src/api.ts +1366 -0
- package/src/auth-route.ts +38 -0
- package/src/config.ts +80 -0
- package/src/converter.ts +305 -0
- package/src/credential.ts +329 -0
- package/src/domain.ts +183 -0
- package/src/downloader.ts +761 -0
- package/src/main.ts +2092 -0
- package/src/progress.ts +89 -0
- package/src/queries/user.graphql +85 -0
- package/src/queries/viewer.graphql +104 -0
- package/src/social.ts +413 -0
- package/src/sync.ts +818 -0
- package/src/types.ts +477 -0
- package/src/url-detect.ts +49 -0
- package/src/utils.ts +305 -0
- package/test-fixtures/syndication-test-site/input/index.md +8 -0
- package/test-fixtures/syndication-test-site/input/posts/rich-test-article.md +90 -0
- package/test-helpers/TEST_ACCOUNT.md +151 -0
- package/test-helpers/api-client.ts +252 -0
- package/test-helpers/fixtures/articles.ts +147 -0
- package/test-helpers/wallet-auth.ts +305 -0
- package/test-setup/e2e.ts +93 -0
- package/tsconfig.json +23 -0
- 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 = "*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 = "";
|
|
136
|
+
const result = replaceAssetUrls(content, assetId, localPath);
|
|
137
|
+
expect(result.replaced).toBe(true);
|
|
138
|
+
expect(result.content).toBe(``);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("replaces imagedelivery.net URL in markdown", () => {
|
|
142
|
+
const content = "";
|
|
143
|
+
const result = replaceAssetUrls(content, assetId, localPath);
|
|
144
|
+
expect(result.replaced).toBe(true);
|
|
145
|
+
expect(result.content).toBe(``);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("replaces multiple occurrences", () => {
|
|
149
|
+
const content = `
|
|
150
|
+

|
|
151
|
+
Some text
|
|
152
|
+

|
|
153
|
+
`.trim();
|
|
154
|
+
const result = replaceAssetUrls(content, assetId, localPath);
|
|
155
|
+
expect(result.replaced).toBe(true);
|
|
156
|
+
expect(result.content).toBe(`
|
|
157
|
+

|
|
158
|
+
Some text
|
|
159
|
+

|
|
160
|
+
`.trim());
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("returns replaced=false when no match", () => {
|
|
164
|
+
const content = "";
|
|
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 *caption* After";
|
|
172
|
+
const result = replaceAssetUrls(content, assetId, localPath);
|
|
173
|
+
expect(result.replaced).toBe(true);
|
|
174
|
+
expect(result.content).toBe(`Before *caption* After`);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("handles URL without extension", () => {
|
|
178
|
+
const content = "";
|
|
179
|
+
const result = replaceAssetUrls(content, assetId, localPath);
|
|
180
|
+
expect(result.replaced).toBe(true);
|
|
181
|
+
expect(result.content).toBe(``);
|
|
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 = "";
|
|
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  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  (htmd emits these)", () => {
|
|
206
|
+
const content = ``;
|
|
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 = "";
|
|
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  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 ", () => {
|
|
235
|
+
const content = ``;
|
|
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 = "";
|
|
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
|
+

|
|
313
|
+
|
|
314
|
+
Some text
|
|
315
|
+
|
|
316
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
449
|
+

|
|
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
|
+

|
|
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
|
+

|
|
551
|
+
|
|
552
|
+
Some text
|
|
553
|
+
|
|
554
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+
});
|