@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,2437 @@
1
+ /**
2
+ * Tests for main.ts
3
+ *
4
+ * Covers:
5
+ * - syndicateArticle uploads cover (bytes) when article.frontmatter.cover exists
6
+ * - syndicateArticle skips cover upload when no cover in frontmatter
7
+ * - syndicateArticle continues gracefully when cover upload fails
8
+ * - siteRelativePathFromSrc path resolution and cross-origin/data-uri rejection
9
+ * - imageMimeForPath / audioMimeForPath MIME mapping
10
+ * - normalizeHtmlForMatters heading transformation and image pass-through
11
+ * - addCanonicalLinkToContent with lang parameter
12
+ * - syndicateArticle passing summary and lang
13
+ * - Draft tracking: getDraftMap, saveDraftMap, getDraftId, saveDraftId, removeDraftId
14
+ * - syndicateArticle reusing existing draft
15
+ * - syndicateArticle falling back on API error with stale draft ID
16
+ * - syndicateArticle removing draft on publish
17
+ * - syndicateArticle saving draft on timeout/no-publish
18
+ * - uploadAndReplaceLocalImages byte-upload flow
19
+ * - uploadAndReplaceLocalAudio byte-upload flow
20
+ */
21
+
22
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
23
+ import type { ArticleInfo } from "../types";
24
+
25
+ // ============================================================================
26
+ // Mocks
27
+ // ============================================================================
28
+
29
+ // Mock @symbiosis-lab/moss-api before importing the modules under test
30
+ vi.mock("@symbiosis-lab/moss-api", () => ({
31
+ getPluginCookie: vi.fn(),
32
+ httpPost: vi.fn(),
33
+ httpPostMultipart: vi.fn(),
34
+ readFile: vi.fn(),
35
+ writeFile: vi.fn(),
36
+ readSiteFile: vi.fn(),
37
+ showToast: vi.fn().mockResolvedValue(undefined),
38
+ dismissToast: vi.fn().mockResolvedValue(undefined),
39
+ openBrowser: vi.fn().mockResolvedValue({ closed: new Promise(() => {}) }),
40
+ closeBrowser: vi.fn().mockResolvedValue(undefined),
41
+ // emitEvent / onEvent used by waitForPublishOrClose (matters-room-published,
42
+ // browser-url-changed). Both must be in the mock or vitest rejects the call.
43
+ emitEvent: vi.fn().mockResolvedValue(undefined),
44
+ onEvent: vi.fn().mockResolvedValue(() => { /* unlisten no-op */ }),
45
+ getPluginEnvVar: vi.fn().mockResolvedValue(null),
46
+ // clearPluginCookies — called by promptLogin() before opening the browser.
47
+ clearPluginCookies: vi.fn().mockResolvedValue(undefined),
48
+ readPluginFile: vi.fn(),
49
+ writePluginFile: vi.fn().mockResolvedValue(undefined),
50
+ pluginFileExists: vi.fn(),
51
+ // startTask mock — process hook needs a TaskHandle even when tests
52
+ // only exercise syndicate helpers; keep it a no-op here.
53
+ startTask: vi.fn().mockResolvedValue({
54
+ id: "0",
55
+ progress: vi.fn().mockResolvedValue(undefined),
56
+ awaiting: vi.fn().mockResolvedValue(undefined),
57
+ advise: vi.fn().mockResolvedValue(undefined),
58
+ succeeded: vi.fn().mockResolvedValue(undefined),
59
+ failed: vi.fn().mockResolvedValue(undefined),
60
+ cancelled: vi.fn().mockResolvedValue(undefined),
61
+ }),
62
+ }));
63
+
64
+ // Mock the credential module (moved symbols from api.ts)
65
+ vi.mock("../credential", () => ({
66
+ clearTokenCache: vi.fn(),
67
+ loadStoredToken: vi.fn().mockResolvedValue(null),
68
+ saveStoredToken: vi.fn().mockResolvedValue(undefined),
69
+ clearStoredToken: vi.fn().mockResolvedValue(undefined),
70
+ getSessionState: vi.fn().mockResolvedValue("valid"),
71
+ shouldNudgeSessionExpired: vi.fn().mockResolvedValue(false),
72
+ markSessionInvalidated: vi.fn().mockResolvedValue(undefined),
73
+ authHeaderToken: vi.fn().mockResolvedValue("test-token"),
74
+ captureLogin: vi.fn().mockResolvedValue("test-token"),
75
+ prepareWebviewAuth: vi.fn().mockResolvedValue(undefined),
76
+ beginFreshLogin: vi.fn().mockResolvedValue(undefined),
77
+ }));
78
+
79
+ // Mock the api module
80
+ vi.mock("../api", () => ({
81
+ createDraft: vi.fn(),
82
+ fetchDraft: vi.fn(),
83
+ uploadAssetMultipart: vi.fn(),
84
+ apiConfig: { queryMode: "viewer", testUserName: "Matty", endpoint: "https://server.matters.town/graphql" },
85
+ SINGLE_FILE_UPLOAD_MUTATION: `
86
+ mutation SingleFileUpload($input: SingleFileUploadInput!) {
87
+ singleFileUpload(input: $input) { id path }
88
+ }
89
+ `,
90
+ }));
91
+
92
+ // Mock other modules that main.ts depends on
93
+ vi.mock("../config", () => ({
94
+ getConfig: vi.fn().mockResolvedValue({ userName: "testuser" }),
95
+ saveConfig: vi.fn().mockResolvedValue(undefined),
96
+ }));
97
+
98
+ vi.mock("../domain", () => ({
99
+ initializeDomain: vi.fn().mockResolvedValue(undefined),
100
+ loginUrl: vi.fn().mockReturnValue("https://matters.town/login"),
101
+ draftUrl: vi.fn().mockImplementation((id: string) => `https://matters.town/drafts/${id}`),
102
+ articleUrl: vi.fn().mockImplementation((_user: string, slug: string, hash: string) => `https://matters.town/@testuser/${slug}-${hash}`),
103
+ isMattersUrl: vi.fn().mockImplementation((url: string) => url.includes("matters.town")),
104
+ // scanLocalArticles (in ../sync) resolves extractShortHash from ../domain; this
105
+ // mock must provide it or any future main.ts test reaching scanLocalArticles would
106
+ // call undefined(). Mirror the real /a/ + canonical parsing.
107
+ extractShortHash: vi.fn().mockImplementation((url: string) => {
108
+ try {
109
+ const segments = new URL(url, "https://matters.town").pathname.split("/").filter(Boolean);
110
+ if (segments.length === 0) return null;
111
+ if (segments[0] === "a" && segments.length >= 2) return segments[1] || null;
112
+ const last = segments[segments.length - 1];
113
+ const hyphen = last.lastIndexOf("-");
114
+ return hyphen === -1 ? null : last.substring(hyphen + 1) || null;
115
+ } catch {
116
+ return null;
117
+ }
118
+ }),
119
+ }));
120
+
121
+ vi.mock("../utils", () => ({
122
+ reportError: vi.fn().mockResolvedValue(undefined),
123
+ setCurrentHookName: vi.fn(),
124
+ sleep: vi.fn().mockResolvedValue(undefined),
125
+ }));
126
+
127
+ vi.mock("../converter", () => ({
128
+ parseFrontmatter: vi.fn(),
129
+ regenerateFrontmatter: vi.fn(),
130
+ }));
131
+
132
+ // Now import the modules under test
133
+ import {
134
+ syndicateArticle,
135
+ waitForPublishOrClose,
136
+ normalizeHtmlForMatters,
137
+ wrapImagesForMatters,
138
+ wrapAudioForMatters,
139
+ stripHeadingAnchors,
140
+ stripArticleTitleH1,
141
+ absolutizeRelativeHrefs,
142
+ addCanonicalLinkToContent,
143
+ uploadAndReplaceLocalImages,
144
+ uploadAndReplaceLocalAudio,
145
+ siteRelativePathFromSrc,
146
+ imageMimeForPath,
147
+ audioMimeForPath,
148
+ getDraftMap,
149
+ saveDraftMap,
150
+ getDraftId,
151
+ saveDraftId,
152
+ removeDraftId,
153
+ type DraftMap,
154
+ } from "../main";
155
+ import { uploadAssetMultipart, createDraft, fetchDraft, SINGLE_FILE_UPLOAD_MUTATION } from "../api";
156
+ import { readSiteFile } from "@symbiosis-lab/moss-api";
157
+ import { readPluginFile, writePluginFile, pluginFileExists } from "@symbiosis-lab/moss-api";
158
+
159
+ // ============================================================================
160
+ // Global setup
161
+ // ============================================================================
162
+
163
+ // `isArticleLive` (called from the syndicate flow) probes the deployed URL with
164
+ // fetch. Stub fetch globally so tests don't hit the network.
165
+ beforeEach(() => {
166
+ vi.stubGlobal(
167
+ "fetch",
168
+ vi.fn().mockResolvedValue({ ok: true, status: 200 } as unknown as Response),
169
+ );
170
+ });
171
+
172
+ afterEach(() => {
173
+ vi.unstubAllGlobals();
174
+ });
175
+
176
+ // ============================================================================
177
+ // Test Helpers
178
+ // ============================================================================
179
+
180
+ function makeArticle(overrides: Partial<ArticleInfo> = {}): ArticleInfo {
181
+ return {
182
+ source_path: "posts/test.md",
183
+ title: "Test Article",
184
+ content: "# Test\n\nContent here.",
185
+ html_content: "<h1>Test</h1><p>Content here.</p>",
186
+ frontmatter: {},
187
+ url_path: "posts/test/",
188
+ date: "2024-01-01",
189
+ tags: ["tag1", "tag2"],
190
+ ...overrides,
191
+ };
192
+ }
193
+
194
+ function makeDraftResponse(id = "draft-123") {
195
+ return {
196
+ id,
197
+ title: "Test Article",
198
+ content: "<h1>Test</h1>",
199
+ createdAt: "2024-01-01T00:00:00Z",
200
+ publishState: "unpublished" as const,
201
+ };
202
+ }
203
+
204
+ function makePublishedDraftResponse(id = "draft-123") {
205
+ return {
206
+ id,
207
+ title: "Test Article",
208
+ content: "<h1>Test</h1>",
209
+ createdAt: "2024-01-01T00:00:00Z",
210
+ publishState: "published" as const,
211
+ article: {
212
+ id: "article-456",
213
+ shortHash: "abc123",
214
+ slug: "test-article",
215
+ },
216
+ };
217
+ }
218
+
219
+ // ============================================================================
220
+ // Shared task handle mock for syndicateArticle (Task M-2: threads task into
221
+ // syndicateArticle so per-article awaiting + advisory signals can reach L1)
222
+ // ============================================================================
223
+ //
224
+ // Deviation note (Blocker 2 — plan line 13):
225
+ // The plan listed main.test.ts as a modification target for:
226
+ // (a) "assert showToast NOT called per-article"
227
+ // (b) "timeout-advisory assertion"
228
+ // These invariants are fully covered by syndication-toast-law.test.ts
229
+ // (Law 1 + Law 2 describe blocks) which test exactly this on multi-article
230
+ // fixtures. Duplicating the assertion here would add noise without additional
231
+ // coverage — the invariant is tested in the dedicated law suite, not here.
232
+ // This is a deliberate deviation from the plan table's modification target.
233
+
234
+ const mockTask = {
235
+ id: "0",
236
+ progress: vi.fn().mockResolvedValue(undefined),
237
+ awaiting: vi.fn().mockResolvedValue(undefined),
238
+ advise: vi.fn().mockResolvedValue(undefined),
239
+ succeeded: vi.fn().mockResolvedValue(undefined),
240
+ failed: vi.fn().mockResolvedValue(undefined),
241
+ cancelled: vi.fn().mockResolvedValue(undefined),
242
+ };
243
+
244
+ // ============================================================================
245
+ // Tests: syndicateArticle cover upload
246
+ // ============================================================================
247
+
248
+ describe("syndicateArticle - cover upload", () => {
249
+ const siteUrl = "https://example.com";
250
+ const userName = "testuser";
251
+ const options = { addCanonicalLink: false, lang: "en" };
252
+
253
+ beforeEach(async () => {
254
+ vi.clearAllMocks();
255
+
256
+ // Restore browser/event mocks after clearAllMocks() (they return undefined otherwise,
257
+ // causing closeBrowser().catch() / onEvent().then() to throw).
258
+ const sdk = await import("@symbiosis-lab/moss-api");
259
+ vi.mocked(sdk.openBrowser).mockResolvedValue({ closed: new Promise(() => {}) } as any);
260
+ vi.mocked(sdk.closeBrowser).mockResolvedValue(undefined);
261
+ vi.mocked(sdk.onEvent).mockResolvedValue(() => {});
262
+ vi.mocked(sdk.emitEvent).mockResolvedValue(undefined);
263
+
264
+ // Default: createDraft succeeds
265
+ vi.mocked(createDraft).mockResolvedValue(makeDraftResponse());
266
+
267
+ // Default: fetchDraft immediately returns published draft
268
+ // This causes waitForPublishOrClose to exit on first poll iteration,
269
+ // preventing the OOM loop caused by mocked sleep running instantly.
270
+ vi.mocked(fetchDraft).mockResolvedValue(makePublishedDraftResponse());
271
+
272
+ // Default: readSiteFile returns fake base64 bytes, uploadAssetMultipart returns asset
273
+ vi.mocked(readSiteFile).mockResolvedValue("ZmFrZQ==");
274
+ vi.mocked(uploadAssetMultipart).mockResolvedValue({ id: "cover-asset-id-1", path: "https://assets.matters.news/cover/uploaded-cover.jpg" });
275
+ });
276
+
277
+ it("uploads cover bytes AFTER draft creation and updates draft with cover asset ID", async () => {
278
+ vi.mocked(createDraft).mockResolvedValue(makeDraftResponse("draft-123"));
279
+
280
+ const article = makeArticle({
281
+ frontmatter: { cover: "assets/covers/book.jpg" },
282
+ });
283
+
284
+ await syndicateArticle(article, siteUrl, userName, options, mockTask);
285
+
286
+ // readSiteFile called with the site-relative path (leading slash stripped)
287
+ expect(readSiteFile).toHaveBeenCalledWith("assets/covers/book.jpg");
288
+
289
+ // uploadAssetMultipart called with correct type "cover" and entityId
290
+ expect(uploadAssetMultipart).toHaveBeenCalledWith(
291
+ expect.any(String),
292
+ "book.jpg",
293
+ "image/jpeg",
294
+ "cover",
295
+ "draft-123"
296
+ );
297
+
298
+ // First createDraft: without cover (draft doesn't exist yet)
299
+ expect(createDraft).toHaveBeenNthCalledWith(1,
300
+ expect.not.objectContaining({ cover: expect.anything() })
301
+ );
302
+
303
+ // Second createDraft: updates draft with cover asset ID (not path) AND preserves title
304
+ expect(createDraft).toHaveBeenNthCalledWith(2,
305
+ expect.objectContaining({ id: "draft-123", cover: "cover-asset-id-1", title: "Test Article" })
306
+ );
307
+ });
308
+
309
+ it("strips leading slash from cover path when reading site file", async () => {
310
+ vi.mocked(createDraft).mockResolvedValue(makeDraftResponse("draft-123"));
311
+
312
+ const article = makeArticle({
313
+ frontmatter: { cover: "/assets/covers/hero.png" },
314
+ });
315
+
316
+ await syndicateArticle(article, siteUrl, userName, options, mockTask);
317
+
318
+ // Leading slash stripped for readSiteFile
319
+ expect(readSiteFile).toHaveBeenCalledWith("assets/covers/hero.png");
320
+ expect(uploadAssetMultipart).toHaveBeenCalledWith(
321
+ expect.any(String),
322
+ "hero.png",
323
+ "image/png",
324
+ "cover",
325
+ "draft-123"
326
+ );
327
+ });
328
+
329
+ it("skips cover upload and does not update draft when no cover in frontmatter", async () => {
330
+ const article = makeArticle({ frontmatter: {} });
331
+
332
+ await syndicateArticle(article, siteUrl, userName, options, mockTask);
333
+
334
+ // No cover upload at all
335
+ expect(uploadAssetMultipart).not.toHaveBeenCalledWith(
336
+ expect.anything(), expect.anything(), expect.anything(), "cover", expect.anything()
337
+ );
338
+ // Only one createDraft call (no cover update)
339
+ expect(createDraft).toHaveBeenCalledTimes(1);
340
+ });
341
+
342
+ it("continues without cover when readSiteFile throws (graceful failure)", async () => {
343
+ vi.mocked(readSiteFile).mockRejectedValueOnce(new Error("File not found"));
344
+ vi.mocked(createDraft).mockResolvedValue(makeDraftResponse("draft-123"));
345
+
346
+ const article = makeArticle({
347
+ frontmatter: { cover: "assets/covers/book.jpg" },
348
+ });
349
+
350
+ // Should not throw
351
+ await expect(syndicateArticle(article, siteUrl, userName, options, mockTask)).resolves.toBeDefined();
352
+
353
+ // createDraft called only once (no cover update since upload failed)
354
+ expect(createDraft).toHaveBeenCalledTimes(1);
355
+ expect(createDraft).toHaveBeenCalledWith(
356
+ expect.not.objectContaining({ cover: expect.anything() })
357
+ );
358
+ });
359
+
360
+ it("continues without cover when uploadAssetMultipart throws (graceful failure)", async () => {
361
+ vi.mocked(uploadAssetMultipart).mockRejectedValueOnce(new Error("Upload failed: 500"));
362
+ vi.mocked(createDraft).mockResolvedValue(makeDraftResponse("draft-123"));
363
+
364
+ const article = makeArticle({
365
+ frontmatter: { cover: "assets/covers/book.jpg" },
366
+ });
367
+
368
+ // Should not throw
369
+ await expect(syndicateArticle(article, siteUrl, userName, options, mockTask)).resolves.toBeDefined();
370
+
371
+ // createDraft called only once (no cover update since upload failed)
372
+ expect(createDraft).toHaveBeenCalledTimes(1);
373
+ expect(createDraft).toHaveBeenCalledWith(
374
+ expect.not.objectContaining({ cover: expect.anything() })
375
+ );
376
+ });
377
+
378
+ it("returns publishedUrl when draft is published", async () => {
379
+ const article = makeArticle({ frontmatter: {} });
380
+ vi.mocked(createDraft).mockResolvedValue(makeDraftResponse("draft-xyz"));
381
+ vi.mocked(fetchDraft).mockResolvedValue(makePublishedDraftResponse("draft-xyz"));
382
+
383
+ const result = await syndicateArticle(article, siteUrl, userName, options, mockTask);
384
+
385
+ expect(result.draftId).toBe("draft-xyz");
386
+ expect(result.publishedUrl).toBeDefined();
387
+ });
388
+ });
389
+
390
+ // ============================================================================
391
+ // Tests: SINGLE_FILE_UPLOAD_MUTATION shape (api.ts)
392
+ // ============================================================================
393
+
394
+ describe("SINGLE_FILE_UPLOAD_MUTATION", () => {
395
+ it("contains the correct mutation name", () => {
396
+ expect(SINGLE_FILE_UPLOAD_MUTATION).toContain("SingleFileUpload");
397
+ });
398
+
399
+ it("uses singleFileUpload field name", () => {
400
+ expect(SINGLE_FILE_UPLOAD_MUTATION).toContain("singleFileUpload");
401
+ });
402
+
403
+ it("uses SingleFileUploadInput type for the input argument", () => {
404
+ expect(SINGLE_FILE_UPLOAD_MUTATION).toContain("SingleFileUploadInput");
405
+ });
406
+
407
+ it("requests id and path fields from the response", () => {
408
+ expect(SINGLE_FILE_UPLOAD_MUTATION).toContain("id");
409
+ expect(SINGLE_FILE_UPLOAD_MUTATION).toContain("path");
410
+ });
411
+
412
+ it("has correct mutation signature", () => {
413
+ expect(SINGLE_FILE_UPLOAD_MUTATION).toMatch(/mutation\s+SingleFileUpload/);
414
+ expect(SINGLE_FILE_UPLOAD_MUTATION).toMatch(/\$input:\s*SingleFileUploadInput!/);
415
+ });
416
+ });
417
+
418
+ // ============================================================================
419
+ // Tests: stripArticleTitleH1
420
+ // ============================================================================
421
+
422
+ describe("stripArticleTitleH1", () => {
423
+ it("strips moss-article-title h1 when text matches", () => {
424
+ const html = '<h1 class="moss-article-title">My Title</h1><p>Body.</p>';
425
+ expect(stripArticleTitleH1(html, "My Title")).toBe("<p>Body.</p>");
426
+ });
427
+
428
+ it("strips when class is one of several", () => {
429
+ const html = '<h1 class="foo moss-article-title bar">My Title</h1><p>Body.</p>';
430
+ expect(stripArticleTitleH1(html, "My Title")).toBe("<p>Body.</p>");
431
+ });
432
+
433
+ it("ignores h1 with the moss class but mismatched text (author-edited)", () => {
434
+ const html = '<h1 class="moss-article-title">Different</h1>';
435
+ expect(stripArticleTitleH1(html, "My Title")).toBe(html);
436
+ });
437
+
438
+ it("ignores plain h1 (no moss-article-title class)", () => {
439
+ const html = '<h1>My Title</h1><p>Body.</p>';
440
+ expect(stripArticleTitleH1(html, "My Title")).toBe(html);
441
+ });
442
+
443
+ it("ignores h2/h3 even when text matches and class is present", () => {
444
+ const html = '<h2 class="moss-article-title">My Title</h2>';
445
+ expect(stripArticleTitleH1(html, "My Title")).toBe(html);
446
+ });
447
+
448
+ it("tolerates inline tags inside the h1", () => {
449
+ const html = '<h1 class="moss-article-title">My <em>fancy</em> Title</h1>';
450
+ expect(stripArticleTitleH1(html, "My fancy Title")).toBe("");
451
+ });
452
+
453
+ it("collapses whitespace before comparing", () => {
454
+ const html = '<h1 class="moss-article-title"> My\n Title </h1>';
455
+ expect(stripArticleTitleH1(html, "My Title")).toBe("");
456
+ });
457
+
458
+ it("does not affect non-leading h1s on its own (multiple h1s case)", () => {
459
+ // The strip is class-gated, so a body-internal plain <h1> survives.
460
+ const html = '<h1 class="moss-article-title">My Title</h1><p>x</p><h1>Also Title</h1>';
461
+ expect(stripArticleTitleH1(html, "My Title")).toBe('<p>x</p><h1>Also Title</h1>');
462
+ });
463
+ });
464
+
465
+ // ============================================================================
466
+ // Tests: absolutizeRelativeHrefs
467
+ // ============================================================================
468
+
469
+ describe("absolutizeRelativeHrefs", () => {
470
+ const baseUrl = "https://example.com/posts/foo/";
471
+
472
+ it("resolves a deep-relative href against the article URL", () => {
473
+ const html = '<a href="../../scale-compare.html">link</a>';
474
+ expect(absolutizeRelativeHrefs(html, baseUrl)).toBe(
475
+ '<a href="https://example.com/scale-compare.html">link</a>',
476
+ );
477
+ });
478
+
479
+ it("resolves a sibling-relative href", () => {
480
+ const html = '<a href="other.html">link</a>';
481
+ expect(absolutizeRelativeHrefs(html, baseUrl)).toBe(
482
+ '<a href="https://example.com/posts/foo/other.html">link</a>',
483
+ );
484
+ });
485
+
486
+ it("resolves a root-relative href against the site origin", () => {
487
+ const html = '<a href="/about/">link</a>';
488
+ expect(absolutizeRelativeHrefs(html, baseUrl)).toBe(
489
+ '<a href="https://example.com/about/">link</a>',
490
+ );
491
+ });
492
+
493
+ it("leaves http URLs untouched", () => {
494
+ const html = '<a href="https://other.com/x">link</a>';
495
+ expect(absolutizeRelativeHrefs(html, baseUrl)).toBe(html);
496
+ });
497
+
498
+ it("leaves mailto: untouched", () => {
499
+ const html = '<a href="mailto:a@b.com">email</a>';
500
+ expect(absolutizeRelativeHrefs(html, baseUrl)).toBe(html);
501
+ });
502
+
503
+ it("leaves fragment-only links untouched (intra-document)", () => {
504
+ const html = '<a href="#section">link</a>';
505
+ expect(absolutizeRelativeHrefs(html, baseUrl)).toBe(html);
506
+ });
507
+
508
+ it("leaves protocol-relative URLs untouched", () => {
509
+ const html = '<a href="//cdn.example.com/x.js">link</a>';
510
+ expect(absolutizeRelativeHrefs(html, baseUrl)).toBe(html);
511
+ });
512
+
513
+ it("preserves attributes around the href", () => {
514
+ const html = '<a class="x" href="../foo.html" target="_blank" rel="noopener">link</a>';
515
+ const result = absolutizeRelativeHrefs(html, baseUrl);
516
+ expect(result).toContain('class="x"');
517
+ expect(result).toContain('target="_blank"');
518
+ expect(result).toContain('rel="noopener"');
519
+ expect(result).toContain('href="https://example.com/posts/foo.html"');
520
+ });
521
+
522
+ it("handles multiple anchors in one document", () => {
523
+ const html = '<a href="a.html">a</a><p>x</p><a href="../b.html">b</a>';
524
+ const result = absolutizeRelativeHrefs(html, baseUrl);
525
+ expect(result).toContain('href="https://example.com/posts/foo/a.html"');
526
+ expect(result).toContain('href="https://example.com/posts/b.html"');
527
+ });
528
+
529
+ it("does not touch <img src> attributes", () => {
530
+ const html = '<img src="../foo.gif">';
531
+ expect(absolutizeRelativeHrefs(html, baseUrl)).toBe(html);
532
+ });
533
+
534
+ it("preserves &amp;-encoded query strings in resolved hrefs", () => {
535
+ // moss-generated HTML uses HTML entities for & in attributes. Make sure
536
+ // the regex captures and re-emits them unchanged so the resulting href
537
+ // is still well-formed HTML.
538
+ const html = '<a href="../foo?a=1&amp;b=2">link</a>';
539
+ const result = absolutizeRelativeHrefs(html, baseUrl);
540
+ expect(result).toContain('href="https://example.com/posts/foo?a=1&amp;b=2"');
541
+ });
542
+
543
+ it("leaves empty href untouched", () => {
544
+ // The regex requires at least one character inside the quotes, so empty
545
+ // hrefs fall through unchanged. That's the safer default — rewriting
546
+ // empty hrefs to the article URL would silently turn a broken link into
547
+ // a self-link, which is worse than leaving it broken.
548
+ const html = '<a href="">link</a>';
549
+ expect(absolutizeRelativeHrefs(html, baseUrl)).toBe(html);
550
+ });
551
+ });
552
+
553
+ // ============================================================================
554
+ // Tests: normalizeHtmlForMatters — heading transformation
555
+ // ============================================================================
556
+
557
+ describe("normalizeHtmlForMatters - heading transformation", () => {
558
+ it("downgrades h1 to h2", () => {
559
+ const html = "<h1>Title</h1><p>Content</p>";
560
+ const result = normalizeHtmlForMatters(html);
561
+ expect(result).toBe("<h2>Title</h2><p>Content</p>");
562
+ });
563
+
564
+ it("keeps h2 as h2", () => {
565
+ const html = "<h2>Subtitle</h2>";
566
+ const result = normalizeHtmlForMatters(html);
567
+ expect(result).toBe("<h2>Subtitle</h2>");
568
+ });
569
+
570
+ it("keeps h3 as h3", () => {
571
+ const html = "<h3>Section</h3>";
572
+ const result = normalizeHtmlForMatters(html);
573
+ expect(result).toBe("<h3>Section</h3>");
574
+ });
575
+
576
+ it("collapses h4 to h3", () => {
577
+ const html = "<h4>Sub-section</h4>";
578
+ const result = normalizeHtmlForMatters(html);
579
+ expect(result).toBe("<h3>Sub-section</h3>");
580
+ });
581
+
582
+ it("collapses h5 to h3", () => {
583
+ const html = "<h5>Deep</h5>";
584
+ const result = normalizeHtmlForMatters(html);
585
+ expect(result).toBe("<h3>Deep</h3>");
586
+ });
587
+
588
+ it("collapses h6 to h3", () => {
589
+ const html = "<h6>Deepest</h6>";
590
+ const result = normalizeHtmlForMatters(html);
591
+ expect(result).toBe("<h3>Deepest</h3>");
592
+ });
593
+
594
+ it("handles h1 with attributes", () => {
595
+ const html = '<h1 id="top" class="title">Title</h1>';
596
+ const result = normalizeHtmlForMatters(html);
597
+ expect(result).toBe('<h2 id="top" class="title">Title</h2>');
598
+ });
599
+
600
+ it("handles h4 with attributes", () => {
601
+ const html = '<h4 id="sub">Sub</h4>';
602
+ const result = normalizeHtmlForMatters(html);
603
+ expect(result).toBe('<h3 id="sub">Sub</h3>');
604
+ });
605
+
606
+ it("processes multiple heading levels in the same document", () => {
607
+ const html = "<h1>H1</h1><h2>H2</h2><h3>H3</h3><h4>H4</h4><h5>H5</h5><h6>H6</h6>";
608
+ const result = normalizeHtmlForMatters(html);
609
+ expect(result).toBe("<h2>H1</h2><h2>H2</h2><h3>H3</h3><h3>H4</h3><h3>H5</h3><h3>H6</h3>");
610
+ });
611
+
612
+ it("does not double-shift h4-h6 (processes h4-h6 before h1)", () => {
613
+ // If h1→h2 ran first, then h4→h3 would still be fine,
614
+ // but we want to ensure the order is correct:
615
+ // h4-h6→h3 first, then h1→h2
616
+ const html = "<h1>Title</h1><h4>Detail</h4>";
617
+ const result = normalizeHtmlForMatters(html);
618
+ expect(result).toBe("<h2>Title</h2><h3>Detail</h3>");
619
+ });
620
+
621
+ it("handles content with no headings", () => {
622
+ const html = "<p>Just a paragraph</p>";
623
+ const result = normalizeHtmlForMatters(html);
624
+ expect(result).toBe("<p>Just a paragraph</p>");
625
+ });
626
+ });
627
+
628
+ // ============================================================================
629
+ // Tests: wrapImagesForMatters
630
+ // ============================================================================
631
+ //
632
+ // matters' server-side sanitizer requires images to be wrapped in
633
+ // `<figure class="image"><img src="..."><figcaption>...</figcaption></figure>`.
634
+ // Anything else (`<figure class="moss-image">`, `<picture>` standalone, bare
635
+ // `<img>`) gets stripped on draft storage. Empirically verified 2026-05-27
636
+ // via smoke test against `server.matters.icu`.
637
+ //
638
+ // `wrapImagesForMatters` is matters-specific — moss's emission stays as-is
639
+ // for the web; this transform only applies on the syndication path.
640
+
641
+ describe("wrapImagesForMatters", () => {
642
+ it("wraps a bare img inside a <p> in figure.image", () => {
643
+ const html = '<p><img src="photo.jpg" alt="Photo"></p>';
644
+ const result = wrapImagesForMatters(html);
645
+ expect(result).toBe(
646
+ '<figure class="image"><img src="photo.jpg" alt="Photo"><figcaption></figcaption></figure>',
647
+ );
648
+ });
649
+
650
+ it("wraps a self-closing img alone in <p>", () => {
651
+ const html = '<p><img src="photo.jpg" /></p>';
652
+ const result = wrapImagesForMatters(html);
653
+ expect(result).toBe(
654
+ '<figure class="image"><img src="photo.jpg" /><figcaption></figcaption></figure>',
655
+ );
656
+ });
657
+
658
+ it("renames figure.moss-image → figure.image and adds empty figcaption when missing", () => {
659
+ const html = '<figure class="moss-image"><img src="photo.jpg"></figure>';
660
+ const result = wrapImagesForMatters(html);
661
+ expect(result).toBe(
662
+ '<figure class="image"><img src="photo.jpg"><figcaption></figcaption></figure>',
663
+ );
664
+ });
665
+
666
+ it("renames figure.moss-image → figure.image and preserves existing figcaption", () => {
667
+ const html =
668
+ '<figure class="moss-image"><img src="photo.jpg"><figcaption>A caption</figcaption></figure>';
669
+ const result = wrapImagesForMatters(html);
670
+ expect(result).toBe(
671
+ '<figure class="image"><img src="photo.jpg"><figcaption>A caption</figcaption></figure>',
672
+ );
673
+ });
674
+
675
+ it("wraps standalone <picture> (variant pattern from moss raster output) in figure.image", () => {
676
+ const html =
677
+ '<picture><source srcset="photo.webp" type="image/webp"><img src="photo.jpg"></picture>';
678
+ const result = wrapImagesForMatters(html);
679
+ expect(result).toBe(
680
+ '<figure class="image"><picture><source srcset="photo.webp" type="image/webp"><img src="photo.jpg"></picture><figcaption></figcaption></figure>',
681
+ );
682
+ });
683
+
684
+ it("hoists <p><picture></p> out of the <p> (figure-in-p is invalid HTML)", () => {
685
+ const html =
686
+ '<p><picture><source srcset="photo.webp"><img src="photo.jpg"></picture></p>';
687
+ const result = wrapImagesForMatters(html);
688
+ expect(result).toBe(
689
+ '<figure class="image"><picture><source srcset="photo.webp"><img src="photo.jpg"></picture><figcaption></figcaption></figure>',
690
+ );
691
+ });
692
+
693
+ it("does not double-wrap <picture> that's already inside figure.image", () => {
694
+ const html =
695
+ '<figure class="moss-image"><picture><source srcset="photo.webp"><img src="photo.jpg"></picture><figcaption>Cap</figcaption></figure>';
696
+ const result = wrapImagesForMatters(html);
697
+ expect(result).toBe(
698
+ '<figure class="image"><picture><source srcset="photo.webp"><img src="photo.jpg"></picture><figcaption>Cap</figcaption></figure>',
699
+ );
700
+ });
701
+
702
+ it("wraps multiple standalone images independently", () => {
703
+ const html = '<p><img src="a.jpg"></p><p>text</p><p><img src="b.jpg"></p>';
704
+ const result = wrapImagesForMatters(html);
705
+ expect(result).toBe(
706
+ '<figure class="image"><img src="a.jpg"><figcaption></figcaption></figure>' +
707
+ "<p>text</p>" +
708
+ '<figure class="image"><img src="b.jpg"><figcaption></figcaption></figure>',
709
+ );
710
+ });
711
+
712
+ it("leaves figure.image untouched (already in matters shape)", () => {
713
+ const html =
714
+ '<figure class="image"><img src="photo.jpg"><figcaption>cap</figcaption></figure>';
715
+ const result = wrapImagesForMatters(html);
716
+ expect(result).toBe(
717
+ '<figure class="image"><img src="photo.jpg"><figcaption>cap</figcaption></figure>',
718
+ );
719
+ });
720
+
721
+ it("wraps bare <img> between block elements", () => {
722
+ const html = '<p>Before</p><img src="photo.jpg"><p>After</p>';
723
+ const result = wrapImagesForMatters(html);
724
+ expect(result).toBe(
725
+ '<p>Before</p><figure class="image"><img src="photo.jpg"><figcaption></figcaption></figure><p>After</p>',
726
+ );
727
+ });
728
+ });
729
+
730
+ // ============================================================================
731
+ // Tests: stripHeadingAnchors
732
+ // ============================================================================
733
+ //
734
+ // moss appends a permalink anchor to every heading:
735
+ // <h2 id="1.">1.<a class="moss-heading-anchor" href="#1." aria-label="…">
736
+ // <span aria-hidden="true">#</span></a></h2>
737
+ // On the web the `#` is hover-only chrome, but matters' sanitizer keeps the
738
+ // anchor's text — so headings render as "1.#" (a stray `#`, linked). This is
739
+ // web-only chrome, not content, so we strip the whole anchor on syndication.
740
+ // Verified 2026-06-16 against server.matters.icu (the `#` survives without this).
741
+
742
+ describe("stripHeadingAnchors", () => {
743
+ it("removes the moss-heading-anchor permalink from a heading", () => {
744
+ const html =
745
+ '<h1 id="1." data-source-line="8">1.<a class="moss-heading-anchor" href="#1." aria-label="Permalink to this section"><span aria-hidden="true">#</span></a></h1>';
746
+ expect(stripHeadingAnchors(html)).toBe('<h1 id="1." data-source-line="8">1.</h1>');
747
+ });
748
+
749
+ it("strips anchors from multiple headings, leaving heading text intact", () => {
750
+ const html =
751
+ '<h2>Intro<a class="moss-heading-anchor" href="#intro"><span aria-hidden="true">#</span></a></h2>' +
752
+ "<p>body</p>" +
753
+ '<h3>详情<a class="moss-heading-anchor" href="#详情"><span aria-hidden="true">#</span></a></h3>';
754
+ expect(stripHeadingAnchors(html)).toBe("<h2>Intro</h2><p>body</p><h3>详情</h3>");
755
+ });
756
+
757
+ it("tolerates attribute order and extra classes on the anchor", () => {
758
+ const html = '<h2>T<a href="#t" class="foo moss-heading-anchor bar"><span>#</span></a></h2>';
759
+ expect(stripHeadingAnchors(html)).toBe("<h2>T</h2>");
760
+ });
761
+
762
+ it("leaves ordinary anchors (non-heading-anchor) untouched", () => {
763
+ const html = '<h2>See <a href="/other">link</a></h2>';
764
+ expect(stripHeadingAnchors(html)).toBe(html);
765
+ });
766
+
767
+ it("leaves content without heading anchors unchanged", () => {
768
+ const html = "<h2>Title</h2><p>text</p>";
769
+ expect(stripHeadingAnchors(html)).toBe(html);
770
+ });
771
+ });
772
+
773
+ // Also exercised via the full normalizeHtmlForMatters pipeline.
774
+ describe("normalizeHtmlForMatters - strips heading anchors", () => {
775
+ it("removes the permalink # and downgrades h1→h2 in one pass", () => {
776
+ const html =
777
+ '<h1 id="1.">1.<a class="moss-heading-anchor" href="#1."><span aria-hidden="true">#</span></a></h1>';
778
+ const result = normalizeHtmlForMatters(html);
779
+ expect(result).toBe('<h2 id="1.">1.</h2>');
780
+ expect(result).not.toContain("#");
781
+ expect(result).not.toContain("moss-heading-anchor");
782
+ });
783
+ });
784
+
785
+ // ============================================================================
786
+ // Tests: wrapAudioForMatters
787
+ // ============================================================================
788
+ //
789
+ // moss emits audio as a bare `<audio class="moss-embed moss-embed-audio"
790
+ // controls preload="metadata"><source src="..." type="..."></audio>`. matters'
791
+ // server-side sanitizer STRIPS that shape entirely (the whole <audio> vanishes;
792
+ // only the fallback text leaks out as a stray <p>). Empirically verified
793
+ // 2026-06-16 against `server.matters.icu`: the only audio shape matters keeps is
794
+ // <figure class="audio"><audio controls><source src="URL" type="MIME"></audio>
795
+ // <figcaption>…</figcaption></figure>
796
+ // where (a) the URL MUST live on a <source> child (a `src` on <audio> itself is
797
+ // dropped), (b) a <figcaption> child is REQUIRED — its absence triggers a server
798
+ // error ("Cannot read properties of undefined (reading 'firstChild')"), empty is
799
+ // fine, and (c) matters keeps an EXTERNAL <source src> verbatim, so the audio can
800
+ // stream straight from the deployed site — no upload to matters is needed (and
801
+ // matters' `embedaudio` asset type rejects url-upload anyway).
802
+ //
803
+ // So this transform restructures moss's audio into matters' figure shape and
804
+ // absolutizes the <source src> against the article URL (same rule as
805
+ // absolutizeRelativeHrefs), so matters' player streams from the live site.
806
+
807
+ describe("wrapAudioForMatters", () => {
808
+ const base = "https://example.com/posts/test/";
809
+
810
+ it("wraps moss audio into figure.audio and absolutizes a relative src", () => {
811
+ const html =
812
+ '<audio class="moss-embed moss-embed-audio" controls preload="metadata"><source src="song.mp3" type="audio/mpeg">Your browser does not support the audio tag.</audio>';
813
+ expect(wrapAudioForMatters(html, base)).toBe(
814
+ '<figure class="audio"><audio controls><source src="https://example.com/posts/test/song.mp3" type="audio/mpeg"></audio><figcaption></figcaption></figure>',
815
+ );
816
+ });
817
+
818
+ it("resolves deep-relative srcs against the article URL", () => {
819
+ const html =
820
+ '<audio class="moss-embed moss-embed-audio" controls preload="metadata"><source src="../assets/song.mp3" type="audio/mpeg">fallback</audio>';
821
+ expect(wrapAudioForMatters(html, base)).toBe(
822
+ '<figure class="audio"><audio controls><source src="https://example.com/posts/assets/song.mp3" type="audio/mpeg"></audio><figcaption></figcaption></figure>',
823
+ );
824
+ });
825
+
826
+ it("leaves an already-absolute src unchanged", () => {
827
+ const html =
828
+ '<audio class="moss-embed moss-embed-audio" controls preload="metadata"><source src="https://cdn.example.com/a.mp3" type="audio/mpeg">x</audio>';
829
+ expect(wrapAudioForMatters(html, base)).toBe(
830
+ '<figure class="audio"><audio controls><source src="https://cdn.example.com/a.mp3" type="audio/mpeg"></audio><figcaption></figcaption></figure>',
831
+ );
832
+ });
833
+
834
+ it("hoists audio out of a wrapping <p> (figure-in-p is invalid HTML)", () => {
835
+ const html =
836
+ '<p><audio class="moss-embed moss-embed-audio" controls preload="metadata"><source src="song.mp3" type="audio/mpeg">x</audio></p>';
837
+ expect(wrapAudioForMatters(html, base)).toBe(
838
+ '<figure class="audio"><audio controls><source src="https://example.com/posts/test/song.mp3" type="audio/mpeg"></audio><figcaption></figcaption></figure>',
839
+ );
840
+ });
841
+
842
+ it("drops the <audio> fallback text (no leak into figcaption or siblings)", () => {
843
+ const html =
844
+ '<p>Before</p><audio class="moss-embed moss-embed-audio" controls preload="metadata"><source src="song.mp3" type="audio/mpeg">Your browser does not support the audio tag.</audio><p>After</p>';
845
+ const result = wrapAudioForMatters(html, base);
846
+ expect(result).not.toContain("does not support");
847
+ expect(result).toBe(
848
+ '<p>Before</p><figure class="audio"><audio controls><source src="https://example.com/posts/test/song.mp3" type="audio/mpeg"></audio><figcaption></figcaption></figure><p>After</p>',
849
+ );
850
+ });
851
+
852
+ it("omits the type attr when moss emitted none", () => {
853
+ const html =
854
+ '<audio class="moss-embed moss-embed-audio" controls preload="metadata"><source src="song.mp3">x</audio>';
855
+ expect(wrapAudioForMatters(html, base)).toBe(
856
+ '<figure class="audio"><audio controls><source src="https://example.com/posts/test/song.mp3"></audio><figcaption></figcaption></figure>',
857
+ );
858
+ });
859
+
860
+ it("wraps multiple audio embeds independently", () => {
861
+ const html =
862
+ '<audio class="moss-embed moss-embed-audio" controls preload="metadata"><source src="a.mp3" type="audio/mpeg">x</audio>' +
863
+ "<p>mid</p>" +
864
+ '<audio class="moss-embed moss-embed-audio" controls preload="metadata"><source src="b.wav" type="audio/wav">y</audio>';
865
+ expect(wrapAudioForMatters(html, base)).toBe(
866
+ '<figure class="audio"><audio controls><source src="https://example.com/posts/test/a.mp3" type="audio/mpeg"></audio><figcaption></figcaption></figure>' +
867
+ "<p>mid</p>" +
868
+ '<figure class="audio"><audio controls><source src="https://example.com/posts/test/b.wav" type="audio/wav"></audio><figcaption></figcaption></figure>',
869
+ );
870
+ });
871
+
872
+ it("leaves content without audio untouched", () => {
873
+ const html = "<h2>Title</h2><p>No audio here</p>";
874
+ expect(wrapAudioForMatters(html, base)).toBe(html);
875
+ });
876
+ });
877
+
878
+ // Also called via normalizeHtmlForMatters (the full pipeline)
879
+ describe("normalizeHtmlForMatters - image wrap (via pipeline)", () => {
880
+ it("wraps a standalone img while leaving headings alone", () => {
881
+ const html = '<h2>Title</h2><p><img src="photo.jpg"></p>';
882
+ const result = normalizeHtmlForMatters(html);
883
+ expect(result).toContain('<figure class="image"><img src="photo.jpg"><figcaption></figcaption></figure>');
884
+ expect(result).toContain("<h2>Title</h2>");
885
+ });
886
+
887
+ it("downgrades h1 and wraps img in the same pass", () => {
888
+ const html = '<h1>Title</h1><p><img src="photo.jpg"></p>';
889
+ const result = normalizeHtmlForMatters(html);
890
+ expect(result).toContain("<h2>Title</h2>");
891
+ expect(result).toContain('<figure class="image"><img src="photo.jpg"><figcaption></figcaption></figure>');
892
+ });
893
+ });
894
+
895
+ // ============================================================================
896
+ // Tests: addCanonicalLinkToContent with lang parameter
897
+ // ============================================================================
898
+
899
+ describe("addCanonicalLinkToContent - lang parameter", () => {
900
+ const url = "https://example.com/posts/test/";
901
+
902
+ it("uses Chinese text for zh lang (HTML)", () => {
903
+ const result = addCanonicalLinkToContent("<p>Content</p>", url, true, "zh");
904
+ expect(result).toContain("原文链接");
905
+ expect(result).toContain(`href="${url}"`);
906
+ expect(result).toContain("<hr>");
907
+ expect(result).not.toContain("Originally published");
908
+ });
909
+
910
+ it("uses Chinese text for zh_hans lang (HTML)", () => {
911
+ const result = addCanonicalLinkToContent("<p>Content</p>", url, true, "zh_hans");
912
+ expect(result).toContain("原文链接");
913
+ });
914
+
915
+ it("uses Chinese text for zh_hant lang (HTML)", () => {
916
+ const result = addCanonicalLinkToContent("<p>Content</p>", url, true, "zh_hant");
917
+ expect(result).toContain("原文链接");
918
+ });
919
+
920
+ it("uses English text for en lang (HTML)", () => {
921
+ const result = addCanonicalLinkToContent("<p>Content</p>", url, true, "en");
922
+ expect(result).toContain("Original link");
923
+ expect(result).toContain(`href="${url}"`);
924
+ expect(result).not.toContain("原文链接");
925
+ });
926
+
927
+ it("uses English text when lang is undefined (HTML)", () => {
928
+ const result = addCanonicalLinkToContent("<p>Content</p>", url, true);
929
+ expect(result).toContain("Original link");
930
+ });
931
+
932
+ it("uses Chinese text for zh lang (Markdown)", () => {
933
+ const result = addCanonicalLinkToContent("Content", url, false, "zh");
934
+ expect(result).toContain("[原文链接]");
935
+ expect(result).toContain(`(${url})`);
936
+ expect(result).toContain("---");
937
+ });
938
+
939
+ it("uses English text for en lang (Markdown)", () => {
940
+ const result = addCanonicalLinkToContent("Content", url, false, "en");
941
+ expect(result).toContain("[Original link]");
942
+ expect(result).toContain(`(${url})`);
943
+ });
944
+
945
+ it("uses English text when lang is undefined (Markdown)", () => {
946
+ const result = addCanonicalLinkToContent("Content", url, false);
947
+ expect(result).toContain("[Original link]");
948
+ });
949
+
950
+ it("preserves original content before the canonical link", () => {
951
+ const original = "<p>My article content</p>";
952
+ const result = addCanonicalLinkToContent(original, url, true, "en");
953
+ expect(result.startsWith(original)).toBe(true);
954
+ });
955
+ });
956
+
957
+ // ============================================================================
958
+ // Tests: syndicateArticle — summary and lang
959
+ // ============================================================================
960
+
961
+ describe("syndicateArticle - summary and lang", () => {
962
+ const siteUrl = "https://example.com";
963
+ const userName = "testuser";
964
+
965
+ beforeEach(async () => {
966
+ vi.clearAllMocks();
967
+ const sdk = await import("@symbiosis-lab/moss-api");
968
+ vi.mocked(sdk.openBrowser).mockResolvedValue({ closed: new Promise(() => {}) } as any);
969
+ vi.mocked(sdk.closeBrowser).mockResolvedValue(undefined);
970
+ vi.mocked(sdk.onEvent).mockResolvedValue(() => {});
971
+ vi.mocked(sdk.emitEvent).mockResolvedValue(undefined);
972
+ vi.mocked(createDraft).mockResolvedValue(makeDraftResponse());
973
+ vi.mocked(fetchDraft).mockResolvedValue(makePublishedDraftResponse());
974
+ vi.mocked(readSiteFile).mockResolvedValue("ZmFrZQ==");
975
+ vi.mocked(uploadAssetMultipart).mockResolvedValue({ id: "asset-id-1", path: "https://assets.matters.news/embed/uploaded.jpg" });
976
+ });
977
+
978
+ it("passes summary from frontmatter.description to createDraft", async () => {
979
+ const article = makeArticle({
980
+ frontmatter: { description: "A short summary of the article" },
981
+ });
982
+
983
+ await syndicateArticle(article, siteUrl, userName, {
984
+ addCanonicalLink: false,
985
+ lang: "en",
986
+ }, mockTask);
987
+
988
+ expect(createDraft).toHaveBeenCalledWith(
989
+ expect.objectContaining({ summary: "A short summary of the article" })
990
+ );
991
+ });
992
+
993
+ it("does not pass summary when frontmatter.description is absent", async () => {
994
+ const article = makeArticle({ frontmatter: {} });
995
+
996
+ await syndicateArticle(article, siteUrl, userName, {
997
+ addCanonicalLink: false,
998
+ lang: "en",
999
+ }, mockTask);
1000
+
1001
+ expect(createDraft).toHaveBeenCalledWith(
1002
+ expect.not.objectContaining({ summary: expect.anything() })
1003
+ );
1004
+ });
1005
+
1006
+ it("passes lang to addCanonicalLinkToContent (zh produces Chinese text)", async () => {
1007
+ const article = makeArticle({
1008
+ html_content: "<p>Content</p>",
1009
+ frontmatter: {},
1010
+ });
1011
+
1012
+ await syndicateArticle(article, siteUrl, userName, {
1013
+ addCanonicalLink: true,
1014
+ lang: "zh_hans",
1015
+ }, mockTask);
1016
+
1017
+ // The content passed to createDraft should contain Chinese canonical text
1018
+ const callArgs = vi.mocked(createDraft).mock.calls[0][0];
1019
+ expect(callArgs.content).toContain("原文链接");
1020
+ });
1021
+
1022
+ it("normalizes HTML headings before creating draft", async () => {
1023
+ const article = makeArticle({
1024
+ html_content: "<h1>Title</h1><h4>Detail</h4><p>Body</p>",
1025
+ frontmatter: {},
1026
+ });
1027
+
1028
+ await syndicateArticle(article, siteUrl, userName, {
1029
+ addCanonicalLink: false,
1030
+ lang: "en",
1031
+ }, mockTask);
1032
+
1033
+ const callArgs = vi.mocked(createDraft).mock.calls[0][0];
1034
+ expect(callArgs.content).toContain("<h2>Title</h2>");
1035
+ expect(callArgs.content).toContain("<h3>Detail</h3>");
1036
+ expect(callArgs.content).not.toContain("<h1>");
1037
+ expect(callArgs.content).not.toContain("<h4>");
1038
+ });
1039
+
1040
+ it("wraps images in figure.image for matters compatibility", async () => {
1041
+ // matters' server-side sanitizer requires `<figure class="image">` with
1042
+ // a `<figcaption>` child or it strips the `<img>` entirely. Empirically
1043
+ // verified 2026-05-27. Phase 2A of the unified-image-emission migration
1044
+ // (2026-05-25) removed this wrap on the (incorrect) assumption that
1045
+ // moss's `<figure class="moss-image">` output would round-trip through
1046
+ // matters; it does not. So the plugin restores the wrap as a
1047
+ // matters-specific transform — moss-core's emission stays as-is.
1048
+ const article = makeArticle({
1049
+ html_content: '<p>Text</p><p><img src="photo.jpg" alt="Photo"></p><p>More</p>',
1050
+ frontmatter: {},
1051
+ });
1052
+
1053
+ await syndicateArticle(article, siteUrl, userName, {
1054
+ addCanonicalLink: false,
1055
+ lang: "en",
1056
+ }, mockTask);
1057
+
1058
+ const callArgs = vi.mocked(createDraft).mock.calls[0][0];
1059
+ expect(callArgs.content).toContain('<figure class="image">');
1060
+ expect(callArgs.content).toContain("<figcaption>");
1061
+ expect(callArgs.content).toContain('src="photo.jpg"');
1062
+ });
1063
+
1064
+ it("does not normalize HTML when content is markdown (not HTML)", async () => {
1065
+ const article = makeArticle({
1066
+ html_content: undefined,
1067
+ content: "# Title\n\nContent with h4-like text: <h4>not real</h4>",
1068
+ frontmatter: {},
1069
+ });
1070
+
1071
+ await syndicateArticle(article, siteUrl, userName, {
1072
+ addCanonicalLink: false,
1073
+ lang: "en",
1074
+ }, mockTask);
1075
+
1076
+ const callArgs = vi.mocked(createDraft).mock.calls[0][0];
1077
+ // Markdown content should not be normalized
1078
+ expect(callArgs.content).toBe("# Title\n\nContent with h4-like text: <h4>not real</h4>");
1079
+ });
1080
+ });
1081
+
1082
+ // ============================================================================
1083
+ // Tests: siteRelativePathFromSrc
1084
+ // ============================================================================
1085
+
1086
+ describe("siteRelativePathFromSrc", () => {
1087
+ const base = "https://liu-guo.com/posts/foo/";
1088
+
1089
+ it("resolves a site-absolute src to a relative site path", () => {
1090
+ expect(siteRelativePathFromSrc("/image/x.jpg", "https://liu-guo.com/posts/foo/"))
1091
+ .toBe("image/x.jpg");
1092
+ });
1093
+
1094
+ it("resolves a same-directory relative src", () => {
1095
+ expect(siteRelativePathFromSrc("photo.jpg", base)).toBe("posts/foo/photo.jpg");
1096
+ });
1097
+
1098
+ it("resolves a parent-traversal relative src", () => {
1099
+ expect(siteRelativePathFromSrc("../a.jpg", "https://s.com/p/q/")).toBe("p/a.jpg");
1100
+ });
1101
+
1102
+ it("resolves a deep-relative src (../../) against the article URL", () => {
1103
+ expect(siteRelativePathFromSrc("../../assets/x.gif", "https://example.com/writings/foo-bar/"))
1104
+ .toBe("assets/x.gif");
1105
+ });
1106
+
1107
+ it("returns null for a data: URI", () => {
1108
+ expect(siteRelativePathFromSrc("data:image/png;base64,abc=", base)).toBeNull();
1109
+ });
1110
+
1111
+ it("returns null for a cross-origin absolute URL", () => {
1112
+ expect(siteRelativePathFromSrc("https://other.com/x.jpg", base)).toBeNull();
1113
+ });
1114
+
1115
+ it("returns null for a matters CDN URL (different origin)", () => {
1116
+ expect(siteRelativePathFromSrc("https://assets.matters.news/embed/abc.jpg", base)).toBeNull();
1117
+ });
1118
+
1119
+ it("decodes percent-encoded pathname characters", () => {
1120
+ expect(siteRelativePathFromSrc("/%E5%9B%BE%E7%89%87/photo.png", "https://example.com/"))
1121
+ .toBe("图片/photo.png");
1122
+ });
1123
+
1124
+ it("resolves same-origin absolute URL to its site path", () => {
1125
+ expect(siteRelativePathFromSrc("https://liu-guo.com/image/x.jpg", "https://liu-guo.com/posts/foo/"))
1126
+ .toBe("image/x.jpg");
1127
+ });
1128
+ });
1129
+
1130
+ // ============================================================================
1131
+ // Tests: imageMimeForPath
1132
+ // ============================================================================
1133
+
1134
+ describe("imageMimeForPath", () => {
1135
+ it("maps .jpg to image/jpeg", () => {
1136
+ expect(imageMimeForPath("photo.jpg")).toBe("image/jpeg");
1137
+ });
1138
+
1139
+ it("maps .jpeg to image/jpeg", () => {
1140
+ expect(imageMimeForPath("photo.jpeg")).toBe("image/jpeg");
1141
+ });
1142
+
1143
+ it("maps .png to image/png", () => {
1144
+ expect(imageMimeForPath("cover.png")).toBe("image/png");
1145
+ });
1146
+
1147
+ it("maps .webp to image/webp", () => {
1148
+ expect(imageMimeForPath("img.webp")).toBe("image/webp");
1149
+ });
1150
+
1151
+ it("maps .gif to image/gif", () => {
1152
+ expect(imageMimeForPath("anim.gif")).toBe("image/gif");
1153
+ });
1154
+
1155
+ it("maps .avif to image/avif", () => {
1156
+ expect(imageMimeForPath("photo.avif")).toBe("image/avif");
1157
+ });
1158
+
1159
+ it("maps .svg to image/svg+xml", () => {
1160
+ expect(imageMimeForPath("icon.svg")).toBe("image/svg+xml");
1161
+ });
1162
+
1163
+ it("returns application/octet-stream for unknown extensions", () => {
1164
+ expect(imageMimeForPath("file.bmp")).toBe("application/octet-stream");
1165
+ });
1166
+ });
1167
+
1168
+ // ============================================================================
1169
+ // Tests: audioMimeForPath
1170
+ // ============================================================================
1171
+
1172
+ describe("audioMimeForPath", () => {
1173
+ it("maps .mp3 to audio/mpeg", () => {
1174
+ expect(audioMimeForPath("song.mp3")).toBe("audio/mpeg");
1175
+ });
1176
+
1177
+ it("maps .wav to audio/wav", () => {
1178
+ expect(audioMimeForPath("sound.wav")).toBe("audio/wav");
1179
+ });
1180
+
1181
+ it("maps .ogg to audio/ogg", () => {
1182
+ expect(audioMimeForPath("track.ogg")).toBe("audio/ogg");
1183
+ });
1184
+
1185
+ it("maps .flac to audio/flac", () => {
1186
+ expect(audioMimeForPath("lossless.flac")).toBe("audio/flac");
1187
+ });
1188
+
1189
+ it("maps .m4a to audio/mp4", () => {
1190
+ expect(audioMimeForPath("podcast.m4a")).toBe("audio/mp4");
1191
+ });
1192
+
1193
+ it("maps .opus to audio/opus", () => {
1194
+ expect(audioMimeForPath("voice.opus")).toBe("audio/opus");
1195
+ });
1196
+
1197
+ it("returns application/octet-stream for unknown extensions", () => {
1198
+ expect(audioMimeForPath("file.aiff")).toBe("application/octet-stream");
1199
+ });
1200
+ });
1201
+
1202
+ // ============================================================================
1203
+ // Tests: uploadAndReplaceLocalImages
1204
+ // ============================================================================
1205
+
1206
+ describe("uploadAndReplaceLocalImages", () => {
1207
+ const baseUrl = "https://example.com/posts/foo/";
1208
+ const entityId = "draft-test";
1209
+
1210
+ beforeEach(() => {
1211
+ vi.clearAllMocks();
1212
+ vi.mocked(readSiteFile).mockResolvedValue("ZmFrZQ==");
1213
+ vi.mocked(uploadAssetMultipart).mockResolvedValue({
1214
+ id: "asset-id-1",
1215
+ path: "https://assets.matters.news/embed/uploaded.jpg",
1216
+ });
1217
+ });
1218
+
1219
+ it("reads site file and uploads bytes, replaces src with CDN URL", async () => {
1220
+ const html = '<p>Text</p><img src="images/photo.jpg" alt="Photo"><p>More</p>';
1221
+
1222
+ const result = await uploadAndReplaceLocalImages(html, baseUrl, entityId);
1223
+
1224
+ expect(readSiteFile).toHaveBeenCalledWith("posts/foo/images/photo.jpg");
1225
+ expect(uploadAssetMultipart).toHaveBeenCalledWith(
1226
+ expect.any(String),
1227
+ "photo.jpg",
1228
+ "image/jpeg",
1229
+ "embed",
1230
+ entityId
1231
+ );
1232
+ expect(result).toContain('src="https://assets.matters.news/embed/uploaded.jpg"');
1233
+ expect(result).not.toContain('src="images/photo.jpg"');
1234
+ });
1235
+
1236
+ it("resolves site-absolute src to correct site path", async () => {
1237
+ const html = '<img src="/assets/hero.png" alt="Hero">';
1238
+
1239
+ await uploadAndReplaceLocalImages(html, baseUrl, entityId);
1240
+
1241
+ expect(readSiteFile).toHaveBeenCalledWith("assets/hero.png");
1242
+ expect(uploadAssetMultipart).toHaveBeenCalledWith(
1243
+ expect.any(String), "hero.png", "image/png", "embed", entityId
1244
+ );
1245
+ });
1246
+
1247
+ it("leaves cross-origin absolute URLs unchanged (no upload)", async () => {
1248
+ const html = '<img src="https://cdn.example.com/photo.jpg"><img src="http://other.com/img.png">';
1249
+
1250
+ const result = await uploadAndReplaceLocalImages(html, baseUrl, entityId);
1251
+
1252
+ expect(uploadAssetMultipart).not.toHaveBeenCalled();
1253
+ expect(readSiteFile).not.toHaveBeenCalled();
1254
+ expect(result).toContain('src="https://cdn.example.com/photo.jpg"');
1255
+ expect(result).toContain('src="http://other.com/img.png"');
1256
+ });
1257
+
1258
+ it("skips data: URIs (no upload, no replacement)", async () => {
1259
+ const html = '<img src="data:image/png;base64,iVBORw0KGgo=">';
1260
+
1261
+ const result = await uploadAndReplaceLocalImages(html, baseUrl, entityId);
1262
+
1263
+ expect(uploadAssetMultipart).not.toHaveBeenCalled();
1264
+ expect(result).toContain("data:image/png;base64,iVBORw0KGgo=");
1265
+ });
1266
+
1267
+ it("deduplicates: same src used twice is only uploaded once", async () => {
1268
+ const html = '<img src="images/photo.jpg"><p>text</p><img src="images/photo.jpg">';
1269
+
1270
+ const result = await uploadAndReplaceLocalImages(html, baseUrl, entityId);
1271
+
1272
+ expect(uploadAssetMultipart).toHaveBeenCalledTimes(1);
1273
+ // Both occurrences should be replaced
1274
+ const matches = result.match(/src="https:\/\/assets\.matters\.news\/embed\/uploaded\.jpg"/g);
1275
+ expect(matches).toHaveLength(2);
1276
+ });
1277
+
1278
+ it("handles multiple different local images", async () => {
1279
+ vi.mocked(uploadAssetMultipart)
1280
+ .mockResolvedValueOnce({ id: "id-a", path: "https://assets.matters.news/embed/a.jpg" })
1281
+ .mockResolvedValueOnce({ id: "id-b", path: "https://assets.matters.news/embed/b.jpg" });
1282
+
1283
+ const html = '<img src="a.jpg"><img src="b.jpg">';
1284
+
1285
+ const result = await uploadAndReplaceLocalImages(html, baseUrl, entityId);
1286
+
1287
+ expect(uploadAssetMultipart).toHaveBeenCalledTimes(2);
1288
+ expect(result).toContain('src="https://assets.matters.news/embed/a.jpg"');
1289
+ expect(result).toContain('src="https://assets.matters.news/embed/b.jpg"');
1290
+ });
1291
+
1292
+ it("falls back to absolutized deployed URL when upload fails (graceful failure)", async () => {
1293
+ vi.mocked(uploadAssetMultipart).mockRejectedValueOnce(new Error("Upload failed"));
1294
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
1295
+
1296
+ const html = '<img src="images/photo.jpg" alt="Photo">';
1297
+
1298
+ const result = await uploadAndReplaceLocalImages(html, baseUrl, entityId);
1299
+
1300
+ // Fallback: src becomes the absolutized deployed URL, not the original relative src
1301
+ expect(result).toContain('src="https://example.com/posts/foo/images/photo.jpg"');
1302
+ expect(result).not.toContain('src="images/photo.jpg"');
1303
+ expect(warnSpy).toHaveBeenCalled();
1304
+
1305
+ warnSpy.mockRestore();
1306
+ });
1307
+
1308
+ it("falls back to absolutized deployed URL when readSiteFile fails", async () => {
1309
+ vi.mocked(readSiteFile).mockRejectedValueOnce(new Error("File not found"));
1310
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
1311
+
1312
+ const html = '<img src="images/photo.jpg" alt="Photo">';
1313
+
1314
+ const result = await uploadAndReplaceLocalImages(html, baseUrl, entityId);
1315
+
1316
+ // Fallback: absolutized deployed URL
1317
+ expect(result).toContain('src="https://example.com/posts/foo/images/photo.jpg"');
1318
+ expect(warnSpy).toHaveBeenCalled();
1319
+
1320
+ warnSpy.mockRestore();
1321
+ });
1322
+
1323
+ it("replaces successful uploads while falling back on failed ones", async () => {
1324
+ vi.mocked(uploadAssetMultipart)
1325
+ .mockResolvedValueOnce({ id: "id-good", path: "https://assets.matters.news/embed/good.jpg" })
1326
+ .mockRejectedValueOnce(new Error("Failed"));
1327
+ vi.spyOn(console, "warn").mockImplementation(() => {});
1328
+
1329
+ const html = '<img src="good.jpg"><img src="bad.jpg">';
1330
+
1331
+ const result = await uploadAndReplaceLocalImages(html, baseUrl, entityId);
1332
+
1333
+ expect(result).toContain('src="https://assets.matters.news/embed/good.jpg"');
1334
+ // bad.jpg gets absolutized fallback URL
1335
+ expect(result).toContain('src="https://example.com/posts/foo/bad.jpg"');
1336
+
1337
+ vi.restoreAllMocks();
1338
+ });
1339
+
1340
+ it("returns content unchanged when no images are present", async () => {
1341
+ const html = "<p>Just text, no images</p>";
1342
+
1343
+ const result = await uploadAndReplaceLocalImages(html, baseUrl, entityId);
1344
+
1345
+ expect(uploadAssetMultipart).not.toHaveBeenCalled();
1346
+ expect(result).toBe(html);
1347
+ });
1348
+
1349
+ it("returns content unchanged when all images are already-uploaded matters URLs (cross-origin)", async () => {
1350
+ const html = '<img src="https://assets.matters.news/embed/abc.jpg">';
1351
+
1352
+ const result = await uploadAndReplaceLocalImages(html, baseUrl, entityId);
1353
+
1354
+ expect(uploadAssetMultipart).not.toHaveBeenCalled();
1355
+ expect(readSiteFile).not.toHaveBeenCalled();
1356
+ // Cross-origin absolute URL stays exactly as-is
1357
+ expect(result).toBe(html);
1358
+ });
1359
+
1360
+ it("preserves other attributes when replacing src", async () => {
1361
+ const html = '<img src="photo.jpg" alt="A photo" width="500" height="300" class="hero">';
1362
+
1363
+ const result = await uploadAndReplaceLocalImages(html, baseUrl, entityId);
1364
+
1365
+ expect(result).toContain('src="https://assets.matters.news/embed/uploaded.jpg"');
1366
+ expect(result).toContain('alt="A photo"');
1367
+ expect(result).toContain('width="500"');
1368
+ });
1369
+ });
1370
+
1371
+ // ============================================================================
1372
+ // Tests: uploadAndReplaceLocalAudio
1373
+ // ============================================================================
1374
+
1375
+ describe("uploadAndReplaceLocalAudio", () => {
1376
+ // After wrapAudioForMatters, the <source src> is already an absolutized
1377
+ // deployed URL (e.g. "https://liu-guo.com/posts/foo/song.mp3"). The audio
1378
+ // upload step tries to read it from the local build and replace it with a
1379
+ // durable matters CDN URL; on failure the absolutized deployed URL stays.
1380
+ const baseUrl = "https://liu-guo.com/posts/foo/";
1381
+ const entityId = "draft-audio-test";
1382
+
1383
+ beforeEach(() => {
1384
+ vi.clearAllMocks();
1385
+ vi.mocked(readSiteFile).mockResolvedValue("ZmFrZQ==");
1386
+ vi.mocked(uploadAssetMultipart).mockResolvedValue({
1387
+ id: "audio-asset-id",
1388
+ path: "https://assets.matters.news/embedaudio/uploaded.mp3",
1389
+ });
1390
+ });
1391
+
1392
+ it("uploads audio bytes and replaces <source src> with CDN URL", async () => {
1393
+ const html =
1394
+ '<figure class="audio"><audio controls>' +
1395
+ '<source src="https://liu-guo.com/posts/foo/song.mp3" type="audio/mpeg">' +
1396
+ '</audio><figcaption></figcaption></figure>';
1397
+
1398
+ const result = await uploadAndReplaceLocalAudio(html, baseUrl, entityId);
1399
+
1400
+ expect(readSiteFile).toHaveBeenCalledWith("posts/foo/song.mp3");
1401
+ expect(uploadAssetMultipart).toHaveBeenCalledWith(
1402
+ expect.any(String),
1403
+ "song.mp3",
1404
+ "audio/mpeg",
1405
+ "embedaudio",
1406
+ entityId
1407
+ );
1408
+ expect(result).toContain('src="https://assets.matters.news/embedaudio/uploaded.mp3"');
1409
+ expect(result).not.toContain('src="https://liu-guo.com/posts/foo/song.mp3"');
1410
+ });
1411
+
1412
+ it("leaves <source src> unchanged on upload error (deployed URL streams fine)", async () => {
1413
+ vi.mocked(uploadAssetMultipart).mockRejectedValueOnce(new Error("Upload failed"));
1414
+ vi.spyOn(console, "warn").mockImplementation(() => {});
1415
+
1416
+ const html =
1417
+ '<figure class="audio"><audio controls>' +
1418
+ '<source src="https://liu-guo.com/posts/foo/song.mp3" type="audio/mpeg">' +
1419
+ '</audio><figcaption></figcaption></figure>';
1420
+
1421
+ const result = await uploadAndReplaceLocalAudio(html, baseUrl, entityId);
1422
+
1423
+ // src stays unchanged — the deployed URL lets matters stream it
1424
+ expect(result).toContain('src="https://liu-guo.com/posts/foo/song.mp3"');
1425
+
1426
+ vi.restoreAllMocks();
1427
+ });
1428
+
1429
+ it("leaves <source src> unchanged on readSiteFile error", async () => {
1430
+ vi.mocked(readSiteFile).mockRejectedValueOnce(new Error("File not found"));
1431
+ vi.spyOn(console, "warn").mockImplementation(() => {});
1432
+
1433
+ const html =
1434
+ '<figure class="audio"><audio controls>' +
1435
+ '<source src="https://liu-guo.com/posts/foo/song.mp3" type="audio/mpeg">' +
1436
+ '</audio><figcaption></figcaption></figure>';
1437
+
1438
+ const result = await uploadAndReplaceLocalAudio(html, baseUrl, entityId);
1439
+
1440
+ expect(result).toContain('src="https://liu-guo.com/posts/foo/song.mp3"');
1441
+
1442
+ vi.restoreAllMocks();
1443
+ });
1444
+
1445
+ it("does NOT touch <img src> attributes — only matches <source>", async () => {
1446
+ const html =
1447
+ '<figure class="image"><img src="https://liu-guo.com/posts/foo/photo.jpg"></figure>' +
1448
+ '<figure class="audio"><audio controls>' +
1449
+ '<source src="https://liu-guo.com/posts/foo/song.mp3" type="audio/mpeg">' +
1450
+ '</audio><figcaption></figcaption></figure>';
1451
+
1452
+ const result = await uploadAndReplaceLocalAudio(html, baseUrl, entityId);
1453
+
1454
+ // img src is not changed
1455
+ expect(result).toContain('src="https://liu-guo.com/posts/foo/photo.jpg"');
1456
+ // source src is replaced
1457
+ expect(result).toContain('src="https://assets.matters.news/embedaudio/uploaded.mp3"');
1458
+ // readSiteFile only called once (for the audio, not the image)
1459
+ expect(readSiteFile).toHaveBeenCalledTimes(1);
1460
+ expect(readSiteFile).toHaveBeenCalledWith("posts/foo/song.mp3");
1461
+ });
1462
+
1463
+ it("returns content unchanged when no <source> elements are present", async () => {
1464
+ const html = "<p>Just text, no audio</p>";
1465
+
1466
+ const result = await uploadAndReplaceLocalAudio(html, baseUrl, entityId);
1467
+
1468
+ expect(uploadAssetMultipart).not.toHaveBeenCalled();
1469
+ expect(result).toBe(html);
1470
+ });
1471
+
1472
+ it("leaves cross-origin <source src> unchanged (external CDN audio)", async () => {
1473
+ const html =
1474
+ '<figure class="audio"><audio controls>' +
1475
+ '<source src="https://cdn.other.com/song.mp3" type="audio/mpeg">' +
1476
+ '</audio><figcaption></figcaption></figure>';
1477
+
1478
+ const result = await uploadAndReplaceLocalAudio(html, baseUrl, entityId);
1479
+
1480
+ // Cross-origin src → no upload, no change
1481
+ expect(readSiteFile).not.toHaveBeenCalled();
1482
+ expect(uploadAssetMultipart).not.toHaveBeenCalled();
1483
+ expect(result).toBe(html);
1484
+ });
1485
+ });
1486
+
1487
+ // ============================================================================
1488
+ // Tests: syndicateArticle - local image upload integration
1489
+ // ============================================================================
1490
+
1491
+ describe("syndicateArticle - audio upload (integration)", () => {
1492
+ const siteUrl = "https://example.com";
1493
+ const userName = "testuser";
1494
+ const options = { addCanonicalLink: false, lang: "en" };
1495
+
1496
+ beforeEach(async () => {
1497
+ vi.clearAllMocks();
1498
+ const sdk = await import("@symbiosis-lab/moss-api");
1499
+ vi.mocked(sdk.openBrowser).mockResolvedValue({ closed: new Promise(() => {}) } as any);
1500
+ vi.mocked(sdk.closeBrowser).mockResolvedValue(undefined);
1501
+ vi.mocked(sdk.onEvent).mockResolvedValue(() => {});
1502
+ vi.mocked(sdk.emitEvent).mockResolvedValue(undefined);
1503
+ vi.mocked(createDraft).mockResolvedValue(makeDraftResponse("draft-aud"));
1504
+ vi.mocked(fetchDraft).mockResolvedValue(makePublishedDraftResponse());
1505
+ vi.mocked(readSiteFile).mockResolvedValue("ZmFrZQ==");
1506
+ vi.mocked(uploadAssetMultipart).mockResolvedValue({
1507
+ id: "audio-asset-1",
1508
+ path: "https://assets-develop.matters.news/embedaudio/uploaded.mpga",
1509
+ });
1510
+ });
1511
+
1512
+ it("wraps moss audio into figure.audio, uploads bytes (embedaudio), re-puts draft with CDN url", async () => {
1513
+ const article = makeArticle({
1514
+ html_content:
1515
+ '<p>Intro</p><audio class="moss-embed moss-embed-audio" controls preload="metadata"><source src="song.mp3" type="audio/mpeg">Your browser does not support the audio tag.</audio>',
1516
+ frontmatter: {},
1517
+ url_path: "posts/test/",
1518
+ });
1519
+
1520
+ await syndicateArticle(article, siteUrl, userName, options, mockTask);
1521
+
1522
+ // Audio bytes read from the deployed site path resolved against the article URL.
1523
+ expect(readSiteFile).toHaveBeenCalledWith("posts/test/song.mp3");
1524
+ // Uploaded as embedaudio against the draft id.
1525
+ expect(uploadAssetMultipart).toHaveBeenCalledWith(
1526
+ expect.any(String),
1527
+ "song.mp3",
1528
+ "audio/mpeg",
1529
+ "embedaudio",
1530
+ "draft-aud",
1531
+ );
1532
+
1533
+ // Final draft body: figure.audio wrap with the matters CDN url on the <source>,
1534
+ // and NO surviving bare moss <audio> / relative src / fallback text.
1535
+ const lastPut = vi.mocked(createDraft).mock.calls.at(-1)![0];
1536
+ const finalContent = String(lastPut.content);
1537
+ expect(finalContent).toContain('<figure class="audio">');
1538
+ expect(finalContent).toContain(
1539
+ '<source src="https://assets-develop.matters.news/embedaudio/uploaded.mpga"',
1540
+ );
1541
+ expect(finalContent).toContain("<figcaption></figcaption>");
1542
+ expect(finalContent).not.toContain("moss-embed-audio");
1543
+ expect(finalContent).not.toContain("does not support");
1544
+ });
1545
+
1546
+ it("falls back to the streamed deployed URL when audio byte-upload fails", async () => {
1547
+ vi.mocked(uploadAssetMultipart).mockRejectedValue(new Error("upload 500"));
1548
+ vi.spyOn(console, "warn").mockImplementation(() => {});
1549
+ const article = makeArticle({
1550
+ html_content:
1551
+ '<audio class="moss-embed moss-embed-audio" controls><source src="song.mp3" type="audio/mpeg">x</audio>',
1552
+ frontmatter: {},
1553
+ url_path: "posts/test/",
1554
+ });
1555
+
1556
+ await syndicateArticle(article, siteUrl, userName, options, mockTask);
1557
+
1558
+ // The figure.audio survives with the absolutized deployed URL (matters streams it).
1559
+ const calls = vi.mocked(createDraft).mock.calls.map((c) => String(c[0]?.content ?? ""));
1560
+ const withAudio = calls.find((c) => c.includes('<figure class="audio">'));
1561
+ expect(withAudio).toContain('<source src="https://example.com/posts/test/song.mp3"');
1562
+ vi.restoreAllMocks();
1563
+ });
1564
+ });
1565
+
1566
+ describe("syndicateArticle - local image upload", () => {
1567
+ const siteUrl = "https://example.com";
1568
+ const userName = "testuser";
1569
+ const options = { addCanonicalLink: false, lang: "en" };
1570
+
1571
+ beforeEach(async () => {
1572
+ vi.clearAllMocks();
1573
+ const sdk = await import("@symbiosis-lab/moss-api");
1574
+ vi.mocked(sdk.openBrowser).mockResolvedValue({ closed: new Promise(() => {}) } as any);
1575
+ vi.mocked(sdk.closeBrowser).mockResolvedValue(undefined);
1576
+ vi.mocked(sdk.onEvent).mockResolvedValue(() => {});
1577
+ vi.mocked(sdk.emitEvent).mockResolvedValue(undefined);
1578
+ vi.mocked(createDraft).mockResolvedValue(makeDraftResponse());
1579
+ vi.mocked(fetchDraft).mockResolvedValue(makePublishedDraftResponse());
1580
+ vi.mocked(readSiteFile).mockResolvedValue("ZmFrZQ==");
1581
+ vi.mocked(uploadAssetMultipart).mockResolvedValue({
1582
+ id: "asset-id-1",
1583
+ path: "https://assets.matters.news/embed/uploaded.jpg",
1584
+ });
1585
+ });
1586
+
1587
+ it("reads site file bytes and uploads AFTER draft creation with entityId, then re-puts draft", async () => {
1588
+ // Regression: Matters' singleFileUpload mutation requires `entityId` for
1589
+ // type:"embed", just as it does for cover. Uploading before the draft
1590
+ // exists fails with "Entity id needs to be specified.", leaving the body
1591
+ // <img> srcs as relative paths (which 404 inside matters.town).
1592
+ vi.mocked(createDraft).mockResolvedValue(makeDraftResponse("draft-123"));
1593
+
1594
+ const article = makeArticle({
1595
+ html_content: '<p>Text</p><img src="images/photo.jpg" alt="Photo">',
1596
+ frontmatter: {},
1597
+ url_path: "posts/test/",
1598
+ });
1599
+
1600
+ await syndicateArticle(article, siteUrl, userName, options, mockTask);
1601
+
1602
+ // readSiteFile called with the decoded site path resolved against the article URL
1603
+ expect(readSiteFile).toHaveBeenCalledWith("posts/test/images/photo.jpg");
1604
+
1605
+ // uploadAssetMultipart called with embed type and the draft's entityId
1606
+ expect(uploadAssetMultipart).toHaveBeenCalledWith(
1607
+ expect.any(String),
1608
+ "photo.jpg",
1609
+ "image/jpeg",
1610
+ "embed",
1611
+ "draft-123"
1612
+ );
1613
+
1614
+ // First putDraft: original content (still has the relative src — the
1615
+ // draft must exist before we have an entityId to upload against).
1616
+ expect(createDraft).toHaveBeenNthCalledWith(
1617
+ 1,
1618
+ expect.objectContaining({ content: expect.stringContaining('src="images/photo.jpg"') }),
1619
+ );
1620
+
1621
+ // Second putDraft: rewrites content with CDN URLs, preserves title.
1622
+ expect(createDraft).toHaveBeenNthCalledWith(
1623
+ 2,
1624
+ expect.objectContaining({
1625
+ id: "draft-123",
1626
+ title: "Test Article",
1627
+ content: expect.stringContaining('src="https://assets.matters.news/embed/uploaded.jpg"'),
1628
+ }),
1629
+ );
1630
+ });
1631
+
1632
+ it("resolves deep-relative srcs (../../) against article path, not site root", async () => {
1633
+ // Regression: a post at /writings/foo-bar/ with `<img src="../../assets/x.gif">`
1634
+ // must read from assets/x.gif (resolved via parent traversal), not be
1635
+ // dropped/mis-resolved. Earlier code passed bare siteUrl as the base, so
1636
+ // `../../` clamped to root and the per-article directory was never used.
1637
+ vi.mocked(createDraft).mockResolvedValue(makeDraftResponse("draft-deep"));
1638
+ const article = makeArticle({
1639
+ html_content: '<img src="../../assets/scale-compare-recording.gif">',
1640
+ frontmatter: {},
1641
+ url_path: "writings/foo-bar/",
1642
+ });
1643
+
1644
+ await syndicateArticle(article, siteUrl, userName, options, mockTask);
1645
+
1646
+ expect(readSiteFile).toHaveBeenCalledWith("assets/scale-compare-recording.gif");
1647
+ expect(uploadAssetMultipart).toHaveBeenCalledWith(
1648
+ expect.any(String),
1649
+ "scale-compare-recording.gif",
1650
+ expect.any(String),
1651
+ "embed",
1652
+ "draft-deep"
1653
+ );
1654
+ });
1655
+
1656
+ it("does not upload images when content is markdown (not HTML)", async () => {
1657
+ const article = makeArticle({
1658
+ html_content: undefined,
1659
+ content: '# Title\n\n![photo](images/photo.jpg)',
1660
+ frontmatter: {},
1661
+ });
1662
+
1663
+ await syndicateArticle(article, siteUrl, userName, options, mockTask);
1664
+
1665
+ expect(readSiteFile).not.toHaveBeenCalled();
1666
+ expect(uploadAssetMultipart).not.toHaveBeenCalled();
1667
+ });
1668
+
1669
+ it("does not upload absolute cross-origin URL images in HTML content", async () => {
1670
+ const article = makeArticle({
1671
+ html_content: '<img src="https://cdn.example.com/already-hosted.jpg">',
1672
+ frontmatter: {},
1673
+ });
1674
+
1675
+ await syndicateArticle(article, siteUrl, userName, options, mockTask);
1676
+
1677
+ expect(readSiteFile).not.toHaveBeenCalled();
1678
+ expect(uploadAssetMultipart).not.toHaveBeenCalled();
1679
+ });
1680
+
1681
+ it("continues gracefully when image upload step throws — does not re-put draft with relative src", async () => {
1682
+ // When readSiteFile + uploadAssetMultipart both fail, the src becomes
1683
+ // the absolutized fallback URL (not the original relative one). No
1684
+ // re-put happens only when the rewritten content equals original content.
1685
+ vi.mocked(readSiteFile).mockRejectedValue(new Error("File not found"));
1686
+ vi.spyOn(console, "warn").mockImplementation(() => {});
1687
+
1688
+ const article = makeArticle({
1689
+ html_content: '<p>Text</p><img src="images/photo.jpg">',
1690
+ frontmatter: {},
1691
+ url_path: "posts/test/",
1692
+ });
1693
+
1694
+ await expect(syndicateArticle(article, siteUrl, userName, options, mockTask)).resolves.toBeDefined();
1695
+
1696
+ vi.restoreAllMocks();
1697
+ });
1698
+
1699
+ it("continues gracefully when re-put after image upload throws (matches cover's failure semantics)", async () => {
1700
+ // Regression: a single 5xx on the embed re-put used to kill syndicateArticle,
1701
+ // skipping the toast/openBrowser path while the draft itself was already
1702
+ // created. Cover survives the same failure (try/catch around its block),
1703
+ // and embed should too — the user is still better off seeing the draft.
1704
+ vi.mocked(createDraft)
1705
+ .mockResolvedValueOnce(makeDraftResponse("draft-rep")) // first putDraft: ok
1706
+ .mockRejectedValueOnce(new Error("matters 503")) // re-put: fails
1707
+ .mockResolvedValue(makeDraftResponse("draft-rep")); // any later calls: ok
1708
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
1709
+
1710
+ const article = makeArticle({
1711
+ html_content: '<p>Text</p><img src="images/photo.jpg">',
1712
+ frontmatter: {},
1713
+ });
1714
+
1715
+ await expect(syndicateArticle(article, siteUrl, userName, options, mockTask)).resolves.toBeDefined();
1716
+
1717
+ expect(warnSpy).toHaveBeenCalledWith(
1718
+ expect.stringContaining("Asset upload step failed"),
1719
+ );
1720
+
1721
+ vi.restoreAllMocks();
1722
+ });
1723
+
1724
+ it("absolutizes relative <a href> links against the article URL", async () => {
1725
+ // Regression: matters.town serves whatever URL we send. Relative hrefs in
1726
+ // the draft (e.g. `../../scale-compare.html`) end up resolved against
1727
+ // matters.town/me/drafts/... and 404 every internal link from the source.
1728
+ const article = makeArticle({
1729
+ html_content: '<p>See <a href="../../scale-compare.html">the demo</a>.</p>',
1730
+ frontmatter: {},
1731
+ url_path: "writings/foo-bar/",
1732
+ });
1733
+
1734
+ await syndicateArticle(article, siteUrl, userName, options, mockTask);
1735
+
1736
+ const draftContent = vi.mocked(createDraft).mock.calls[0][0].content!;
1737
+ expect(draftContent).toContain('href="https://example.com/scale-compare.html"');
1738
+ expect(draftContent).not.toContain('href="../../scale-compare.html"');
1739
+ });
1740
+
1741
+ it("strips the moss-injected article-title h1 to avoid duplicating the matters title", async () => {
1742
+ // Regression: moss's pipeline auto-injects <h1 class="moss-article-title">
1743
+ // into html_content. matters.town has its own title field, so leaving the
1744
+ // h1 in the body shows the title twice in the published draft.
1745
+ const article = makeArticle({
1746
+ title: "My Title",
1747
+ html_content:
1748
+ '<h1 class="moss-article-title">My Title</h1><p>Body.</p>',
1749
+ frontmatter: {},
1750
+ url_path: "posts/test/",
1751
+ });
1752
+
1753
+ await syndicateArticle(article, siteUrl, userName, options, mockTask);
1754
+
1755
+ const draftContent = vi.mocked(createDraft).mock.calls[0][0].content!;
1756
+ expect(draftContent).not.toContain('moss-article-title');
1757
+ // The matters title field carries the title.
1758
+ expect(vi.mocked(createDraft).mock.calls[0][0].title).toBe("My Title");
1759
+ // The first non-whitespace content should not be a title heading.
1760
+ expect(draftContent.trim().startsWith("<p>Body.</p>")).toBe(true);
1761
+ });
1762
+ });
1763
+
1764
+ // ============================================================================
1765
+ // Tests: Draft tracking functions
1766
+ // ============================================================================
1767
+
1768
+ describe("Draft tracking - getDraftMap", () => {
1769
+ beforeEach(() => {
1770
+ vi.clearAllMocks();
1771
+ });
1772
+
1773
+ it("returns empty object when file does not exist", async () => {
1774
+ vi.mocked(pluginFileExists).mockResolvedValue(false);
1775
+
1776
+ const map = await getDraftMap();
1777
+ expect(map).toEqual({});
1778
+ });
1779
+
1780
+ it("returns parsed map when file exists", async () => {
1781
+ const stored: DraftMap = {
1782
+ "articles/review.md": { draftId: "abc123", createdAt: "2026-03-16T10:00:00Z" },
1783
+ };
1784
+ vi.mocked(pluginFileExists).mockResolvedValue(true);
1785
+ vi.mocked(readPluginFile).mockResolvedValue(JSON.stringify(stored));
1786
+
1787
+ const map = await getDraftMap();
1788
+ expect(map).toEqual(stored);
1789
+ });
1790
+
1791
+ it("returns empty object on parse error", async () => {
1792
+ vi.mocked(pluginFileExists).mockResolvedValue(true);
1793
+ vi.mocked(readPluginFile).mockResolvedValue("not valid json {{{");
1794
+
1795
+ const map = await getDraftMap();
1796
+ expect(map).toEqual({});
1797
+ });
1798
+
1799
+ it("returns empty object when readPluginFile throws", async () => {
1800
+ vi.mocked(pluginFileExists).mockResolvedValue(true);
1801
+ vi.mocked(readPluginFile).mockRejectedValue(new Error("read error"));
1802
+
1803
+ const map = await getDraftMap();
1804
+ expect(map).toEqual({});
1805
+ });
1806
+ });
1807
+
1808
+ describe("Draft tracking - saveDraftMap", () => {
1809
+ beforeEach(() => {
1810
+ vi.clearAllMocks();
1811
+ });
1812
+
1813
+ it("writes JSON to plugin storage", async () => {
1814
+ const map: DraftMap = {
1815
+ "posts/test.md": { draftId: "draft-1", createdAt: "2026-03-16T10:00:00Z" },
1816
+ };
1817
+
1818
+ await saveDraftMap(map);
1819
+
1820
+ expect(writePluginFile).toHaveBeenCalledWith(
1821
+ "drafts.json",
1822
+ JSON.stringify(map, null, 2)
1823
+ );
1824
+ });
1825
+
1826
+ it("writes empty object for empty map", async () => {
1827
+ await saveDraftMap({});
1828
+
1829
+ expect(writePluginFile).toHaveBeenCalledWith(
1830
+ "drafts.json",
1831
+ JSON.stringify({}, null, 2)
1832
+ );
1833
+ });
1834
+ });
1835
+
1836
+ describe("Draft tracking - getDraftId", () => {
1837
+ beforeEach(() => {
1838
+ vi.clearAllMocks();
1839
+ });
1840
+
1841
+ it("returns draftId when entry exists for sourcePath", async () => {
1842
+ vi.mocked(pluginFileExists).mockResolvedValue(true);
1843
+ vi.mocked(readPluginFile).mockResolvedValue(
1844
+ JSON.stringify({
1845
+ "posts/test.md": { draftId: "draft-abc", createdAt: "2026-03-16T10:00:00Z" },
1846
+ })
1847
+ );
1848
+
1849
+ const id = await getDraftId("posts/test.md");
1850
+ expect(id).toBe("draft-abc");
1851
+ });
1852
+
1853
+ it("returns undefined when no entry for sourcePath", async () => {
1854
+ vi.mocked(pluginFileExists).mockResolvedValue(true);
1855
+ vi.mocked(readPluginFile).mockResolvedValue(
1856
+ JSON.stringify({
1857
+ "posts/other.md": { draftId: "draft-abc", createdAt: "2026-03-16T10:00:00Z" },
1858
+ })
1859
+ );
1860
+
1861
+ const id = await getDraftId("posts/test.md");
1862
+ expect(id).toBeUndefined();
1863
+ });
1864
+
1865
+ it("returns undefined when drafts file is empty", async () => {
1866
+ vi.mocked(pluginFileExists).mockResolvedValue(false);
1867
+
1868
+ const id = await getDraftId("posts/test.md");
1869
+ expect(id).toBeUndefined();
1870
+ });
1871
+ });
1872
+
1873
+ describe("Draft tracking - saveDraftId", () => {
1874
+ beforeEach(() => {
1875
+ vi.clearAllMocks();
1876
+ });
1877
+
1878
+ it("adds entry to empty map", async () => {
1879
+ vi.mocked(pluginFileExists).mockResolvedValue(false);
1880
+
1881
+ await saveDraftId("posts/test.md", "draft-new");
1882
+
1883
+ expect(writePluginFile).toHaveBeenCalledTimes(1);
1884
+ const written = JSON.parse(vi.mocked(writePluginFile).mock.calls[0][1]);
1885
+ expect(written["posts/test.md"].draftId).toBe("draft-new");
1886
+ expect(written["posts/test.md"].createdAt).toBeDefined();
1887
+ });
1888
+
1889
+ it("adds entry to existing map without overwriting others", async () => {
1890
+ vi.mocked(pluginFileExists).mockResolvedValue(true);
1891
+ vi.mocked(readPluginFile).mockResolvedValue(
1892
+ JSON.stringify({
1893
+ "posts/existing.md": { draftId: "draft-old", createdAt: "2026-01-01T00:00:00Z" },
1894
+ })
1895
+ );
1896
+
1897
+ await saveDraftId("posts/new.md", "draft-new");
1898
+
1899
+ const written = JSON.parse(vi.mocked(writePluginFile).mock.calls[0][1]);
1900
+ expect(written["posts/existing.md"].draftId).toBe("draft-old");
1901
+ expect(written["posts/new.md"].draftId).toBe("draft-new");
1902
+ });
1903
+
1904
+ it("overwrites existing entry for the same source path", async () => {
1905
+ vi.mocked(pluginFileExists).mockResolvedValue(true);
1906
+ vi.mocked(readPluginFile).mockResolvedValue(
1907
+ JSON.stringify({
1908
+ "posts/test.md": { draftId: "draft-old", createdAt: "2026-01-01T00:00:00Z" },
1909
+ })
1910
+ );
1911
+
1912
+ await saveDraftId("posts/test.md", "draft-updated");
1913
+
1914
+ const written = JSON.parse(vi.mocked(writePluginFile).mock.calls[0][1]);
1915
+ expect(written["posts/test.md"].draftId).toBe("draft-updated");
1916
+ });
1917
+ });
1918
+
1919
+ describe("Draft tracking - removeDraftId", () => {
1920
+ beforeEach(() => {
1921
+ vi.clearAllMocks();
1922
+ });
1923
+
1924
+ it("removes entry from map", async () => {
1925
+ vi.mocked(pluginFileExists).mockResolvedValue(true);
1926
+ vi.mocked(readPluginFile).mockResolvedValue(
1927
+ JSON.stringify({
1928
+ "posts/test.md": { draftId: "draft-abc", createdAt: "2026-01-01T00:00:00Z" },
1929
+ "posts/other.md": { draftId: "draft-def", createdAt: "2026-01-02T00:00:00Z" },
1930
+ })
1931
+ );
1932
+
1933
+ await removeDraftId("posts/test.md");
1934
+
1935
+ const written = JSON.parse(vi.mocked(writePluginFile).mock.calls[0][1]);
1936
+ expect(written["posts/test.md"]).toBeUndefined();
1937
+ expect(written["posts/other.md"].draftId).toBe("draft-def");
1938
+ });
1939
+
1940
+ it("no-ops when entry does not exist (still writes the map)", async () => {
1941
+ vi.mocked(pluginFileExists).mockResolvedValue(true);
1942
+ vi.mocked(readPluginFile).mockResolvedValue(JSON.stringify({}));
1943
+
1944
+ await removeDraftId("posts/nonexistent.md");
1945
+
1946
+ expect(writePluginFile).toHaveBeenCalledTimes(1);
1947
+ const written = JSON.parse(vi.mocked(writePluginFile).mock.calls[0][1]);
1948
+ expect(Object.keys(written)).toHaveLength(0);
1949
+ });
1950
+ });
1951
+
1952
+ // ============================================================================
1953
+ // Tests: syndicateArticle — draft reuse integration
1954
+ // ============================================================================
1955
+
1956
+ describe("syndicateArticle - draft tracking integration", () => {
1957
+ const siteUrl = "https://example.com";
1958
+ const userName = "testuser";
1959
+ const options = { addCanonicalLink: false, lang: "en" };
1960
+
1961
+ beforeEach(async () => {
1962
+ vi.clearAllMocks();
1963
+
1964
+ // Reset sleep to default no-op (timeout tests override it with clock-advancing mock)
1965
+ const { sleep } = await import("../utils");
1966
+ vi.mocked(sleep).mockResolvedValue(undefined);
1967
+
1968
+ // Re-establish SDK mocks (vi.restoreAllMocks() in earlier tests may have wiped them)
1969
+ const sdk = await import("@symbiosis-lab/moss-api");
1970
+ vi.mocked(sdk.showToast).mockResolvedValue(undefined);
1971
+ vi.mocked(sdk.openBrowser).mockResolvedValue({ closed: new Promise(() => {}) } as any);
1972
+ vi.mocked(sdk.closeBrowser).mockResolvedValue(undefined);
1973
+ vi.mocked(sdk.onEvent).mockResolvedValue(() => {});
1974
+ vi.mocked(sdk.emitEvent).mockResolvedValue(undefined);
1975
+
1976
+ // Re-establish domain mocks
1977
+ const domain = await import("../domain");
1978
+ vi.mocked(domain.draftUrl).mockImplementation((id: string) => `https://matters.town/drafts/${id}`);
1979
+ vi.mocked(domain.articleUrl).mockImplementation((_user: string, slug: string, hash: string) => `https://matters.town/@testuser/${slug}-${hash}`);
1980
+
1981
+ // Default: no existing drafts tracked
1982
+ vi.mocked(pluginFileExists).mockResolvedValue(false);
1983
+
1984
+ // Default: createDraft succeeds
1985
+ vi.mocked(createDraft).mockResolvedValue(makeDraftResponse());
1986
+
1987
+ // Default: fetchDraft immediately returns published draft
1988
+ vi.mocked(fetchDraft).mockResolvedValue(makePublishedDraftResponse());
1989
+
1990
+ vi.mocked(readSiteFile).mockResolvedValue("ZmFrZQ==");
1991
+ vi.mocked(uploadAssetMultipart).mockResolvedValue({ id: "asset-id-1", path: "https://assets.matters.news/embed/uploaded.jpg" });
1992
+ });
1993
+
1994
+ it("passes existing draft ID to createDraft when one is tracked", async () => {
1995
+ // Set up existing draft tracking
1996
+ vi.mocked(pluginFileExists).mockResolvedValue(true);
1997
+ vi.mocked(readPluginFile).mockResolvedValue(
1998
+ JSON.stringify({
1999
+ "posts/test.md": { draftId: "existing-draft-99", createdAt: "2026-03-16T10:00:00Z" },
2000
+ })
2001
+ );
2002
+
2003
+ const article = makeArticle({ source_path: "posts/test.md", frontmatter: {} });
2004
+
2005
+ await syndicateArticle(article, siteUrl, userName, options, mockTask);
2006
+
2007
+ expect(createDraft).toHaveBeenCalledWith(
2008
+ expect.objectContaining({ id: "existing-draft-99" })
2009
+ );
2010
+ });
2011
+
2012
+ it("does not pass id to createDraft when no draft is tracked", async () => {
2013
+ vi.mocked(pluginFileExists).mockResolvedValue(false);
2014
+
2015
+ const article = makeArticle({ source_path: "posts/test.md", frontmatter: {} });
2016
+
2017
+ await syndicateArticle(article, siteUrl, userName, options, mockTask);
2018
+
2019
+ expect(createDraft).toHaveBeenCalledWith(
2020
+ expect.not.objectContaining({ id: expect.anything() })
2021
+ );
2022
+ });
2023
+
2024
+ it("falls back to new draft when existing draft ID causes API error", async () => {
2025
+ // Set up existing stale draft tracking
2026
+ vi.mocked(pluginFileExists).mockResolvedValue(true);
2027
+ vi.mocked(readPluginFile).mockResolvedValue(
2028
+ JSON.stringify({
2029
+ "posts/test.md": { draftId: "stale-draft-id", createdAt: "2026-01-01T00:00:00Z" },
2030
+ })
2031
+ );
2032
+
2033
+ // First call (with id) fails, second call (without id) succeeds
2034
+ vi.mocked(createDraft)
2035
+ .mockRejectedValueOnce(new Error("Draft not found"))
2036
+ .mockResolvedValueOnce(makeDraftResponse("new-draft-456"));
2037
+
2038
+ const article = makeArticle({ source_path: "posts/test.md", frontmatter: {} });
2039
+
2040
+ const result = await syndicateArticle(article, siteUrl, userName, options, mockTask);
2041
+
2042
+ // Should have been called twice: once with id, once without
2043
+ expect(createDraft).toHaveBeenCalledTimes(2);
2044
+ expect(createDraft).toHaveBeenNthCalledWith(1,
2045
+ expect.objectContaining({ id: "stale-draft-id" })
2046
+ );
2047
+ expect(createDraft).toHaveBeenNthCalledWith(2,
2048
+ expect.not.objectContaining({ id: expect.anything() })
2049
+ );
2050
+ expect(result.draftId).toBe("new-draft-456");
2051
+ });
2052
+
2053
+ it("throws when createDraft fails and no existing draft to fall back from", async () => {
2054
+ vi.mocked(pluginFileExists).mockResolvedValue(false);
2055
+ vi.mocked(createDraft).mockRejectedValue(new Error("API error"));
2056
+
2057
+ const article = makeArticle({ source_path: "posts/test.md", frontmatter: {} });
2058
+
2059
+ await expect(
2060
+ syndicateArticle(article, siteUrl, userName, options, mockTask)
2061
+ ).rejects.toThrow("API error");
2062
+ });
2063
+
2064
+ it("removes draft tracking on successful publish", async () => {
2065
+ // Set up existing draft
2066
+ vi.mocked(pluginFileExists).mockResolvedValue(true);
2067
+ vi.mocked(readPluginFile).mockResolvedValue(
2068
+ JSON.stringify({
2069
+ "posts/test.md": { draftId: "draft-123", createdAt: "2026-03-16T10:00:00Z" },
2070
+ "posts/other.md": { draftId: "draft-other", createdAt: "2026-03-16T10:00:00Z" },
2071
+ })
2072
+ );
2073
+
2074
+ // Draft is published
2075
+ vi.mocked(createDraft).mockResolvedValue(makeDraftResponse("draft-123"));
2076
+ vi.mocked(fetchDraft).mockResolvedValue(makePublishedDraftResponse("draft-123"));
2077
+
2078
+ const article = makeArticle({ source_path: "posts/test.md", frontmatter: {} });
2079
+
2080
+ const result = await syndicateArticle(article, siteUrl, userName, options, mockTask);
2081
+
2082
+ expect(result.publishedUrl).toBeDefined();
2083
+
2084
+ // writePluginFile should have been called to remove the draft entry
2085
+ // The last call to writePluginFile should be from removeDraftId
2086
+ const lastWriteCall = vi.mocked(writePluginFile).mock.calls;
2087
+ const lastWritten = JSON.parse(lastWriteCall[lastWriteCall.length - 1][1]);
2088
+ expect(lastWritten["posts/test.md"]).toBeUndefined();
2089
+ // Other entries should be preserved
2090
+ expect(lastWritten["posts/other.md"]).toBeDefined();
2091
+ });
2092
+
2093
+ it("saves draft ID when browser is closed without publishing", async () => {
2094
+ vi.mocked(pluginFileExists).mockResolvedValue(false);
2095
+
2096
+ // Draft created but NOT published
2097
+ vi.mocked(createDraft).mockResolvedValue(makeDraftResponse("draft-timeout"));
2098
+
2099
+ // fetchDraft always returns unpublished — poll loop would run forever
2100
+ // without the browser-close termination path.
2101
+ vi.mocked(fetchDraft).mockResolvedValue({
2102
+ id: "draft-timeout",
2103
+ title: "Test",
2104
+ content: "<p>Test</p>",
2105
+ createdAt: "2024-01-01T00:00:00Z",
2106
+ publishState: "unpublished" as const,
2107
+ });
2108
+
2109
+ // Simulate browser closing after the first poll sleep: resolve the
2110
+ // `closed` promise on the first sleep() call so the race terminates.
2111
+ const sdk = await import("@symbiosis-lab/moss-api");
2112
+ let resolveClose!: (reason: any) => void;
2113
+ const closedPromise = new Promise<any>((resolve) => { resolveClose = resolve; });
2114
+ vi.mocked(sdk.openBrowser).mockResolvedValue({ closed: closedPromise });
2115
+
2116
+ const { sleep } = await import("../utils");
2117
+ vi.mocked(sleep).mockImplementation(async () => {
2118
+ // On first sleep (5s poll) resolve the browser close so branch (b) fires.
2119
+ resolveClose({ type: "user" });
2120
+ // Yield for the closed promise to settle.
2121
+ await Promise.resolve();
2122
+ await Promise.resolve();
2123
+ });
2124
+
2125
+ const article = makeArticle({ source_path: "posts/test.md", frontmatter: {} });
2126
+
2127
+ const result = await syndicateArticle(article, siteUrl, userName, options, mockTask);
2128
+
2129
+ expect(result.publishedUrl).toBeUndefined();
2130
+
2131
+ // Draft ID should have been saved for reuse
2132
+ const writeCalls = vi.mocked(writePluginFile).mock.calls;
2133
+ expect(writeCalls.length).toBeGreaterThan(0);
2134
+ // Find the call that writes drafts.json
2135
+ const draftWriteCall = writeCalls.find(call => call[0] === "drafts.json");
2136
+ expect(draftWriteCall).toBeDefined();
2137
+ const written = JSON.parse(draftWriteCall![1]);
2138
+ expect(written["posts/test.md"].draftId).toBe("draft-timeout");
2139
+ });
2140
+
2141
+ it("does not track draft when article has no source_path (browser closed)", async () => {
2142
+ vi.mocked(pluginFileExists).mockResolvedValue(false);
2143
+ vi.mocked(createDraft).mockResolvedValue(makeDraftResponse("draft-no-path"));
2144
+
2145
+ // Terminate via browser close instead of a wall-clock timeout.
2146
+ const sdk = await import("@symbiosis-lab/moss-api");
2147
+ let resolveClose!: (reason: any) => void;
2148
+ const closedPromise = new Promise<any>((resolve) => { resolveClose = resolve; });
2149
+ vi.mocked(sdk.openBrowser).mockResolvedValue({ closed: closedPromise });
2150
+
2151
+ const { sleep } = await import("../utils");
2152
+ vi.mocked(sleep).mockImplementation(async () => {
2153
+ resolveClose({ type: "user" });
2154
+ await Promise.resolve();
2155
+ await Promise.resolve();
2156
+ });
2157
+
2158
+ vi.mocked(fetchDraft).mockResolvedValue({
2159
+ id: "draft-no-path",
2160
+ title: "Test",
2161
+ content: "<p>Test</p>",
2162
+ createdAt: "2024-01-01T00:00:00Z",
2163
+ publishState: "unpublished" as const,
2164
+ });
2165
+
2166
+ const article = makeArticle({ source_path: "", frontmatter: {} });
2167
+
2168
+ await syndicateArticle(article, siteUrl, userName, options, mockTask);
2169
+
2170
+ // writePluginFile should NOT have been called with drafts.json
2171
+ const draftWriteCalls = vi.mocked(writePluginFile).mock.calls.filter(
2172
+ call => call[0] === "drafts.json"
2173
+ );
2174
+ expect(draftWriteCalls).toHaveLength(0);
2175
+ });
2176
+ });
2177
+
2178
+ // ============================================================================
2179
+ // Tests: Cover path decoding for non-ASCII paths
2180
+ // ============================================================================
2181
+
2182
+ describe("syndicateArticle - cover path decoding", () => {
2183
+ const siteUrl = "https://example.com";
2184
+ const userName = "testuser";
2185
+ const options = { addCanonicalLink: false, lang: "en" };
2186
+
2187
+ beforeEach(async () => {
2188
+ vi.clearAllMocks();
2189
+ const sdk = await import("@symbiosis-lab/moss-api");
2190
+ vi.mocked(sdk.openBrowser).mockResolvedValue({ closed: new Promise(() => {}) } as any);
2191
+ vi.mocked(sdk.closeBrowser).mockResolvedValue(undefined);
2192
+ vi.mocked(sdk.onEvent).mockResolvedValue(() => {});
2193
+ vi.mocked(sdk.emitEvent).mockResolvedValue(undefined);
2194
+ vi.mocked(createDraft).mockResolvedValue(makeDraftResponse());
2195
+ vi.mocked(fetchDraft).mockResolvedValue(makePublishedDraftResponse());
2196
+ vi.mocked(readSiteFile).mockResolvedValue("ZmFrZQ==");
2197
+ vi.mocked(uploadAssetMultipart).mockResolvedValue({ id: "cover-id-1", path: "https://assets.matters.news/cover/cover.png" });
2198
+ });
2199
+
2200
+ it("decodes percent-encoded Chinese characters in cover path for readSiteFile", async () => {
2201
+ vi.mocked(createDraft).mockResolvedValue(makeDraftResponse("draft-cn"));
2202
+
2203
+ // frontmatter.cover may contain literal Chinese or percent-encoded chars
2204
+ const article = makeArticle({
2205
+ frontmatter: { cover: "%E5%9B%BE%E7%89%87/cover-image.png" },
2206
+ });
2207
+
2208
+ await syndicateArticle(article, siteUrl, userName, options, mockTask);
2209
+
2210
+ // readSiteFile should receive the DECODED path (filesystem uses decoded chars)
2211
+ const readCall = vi.mocked(readSiteFile).mock.calls[0][0];
2212
+ expect(readCall).toBe("图片/cover-image.png");
2213
+ expect(readCall).not.toContain("%");
2214
+ });
2215
+
2216
+ it("reads ASCII cover paths without modification", async () => {
2217
+ vi.mocked(createDraft).mockResolvedValue(makeDraftResponse("draft-ascii"));
2218
+
2219
+ const article = makeArticle({
2220
+ frontmatter: { cover: "assets/covers/book.jpg" },
2221
+ });
2222
+
2223
+ await syndicateArticle(article, siteUrl, userName, options, mockTask);
2224
+
2225
+ expect(readSiteFile).toHaveBeenCalledWith("assets/covers/book.jpg");
2226
+ expect(uploadAssetMultipart).toHaveBeenCalledWith(
2227
+ expect.any(String),
2228
+ "book.jpg",
2229
+ "image/jpeg",
2230
+ "cover",
2231
+ "draft-ascii"
2232
+ );
2233
+ });
2234
+ });
2235
+
2236
+ // ============================================================================
2237
+ // Tests: uploadAndReplaceLocalImages — non-ASCII path decoding for readSiteFile
2238
+ // ============================================================================
2239
+
2240
+ describe("uploadAndReplaceLocalImages - non-ASCII path decoding", () => {
2241
+ const baseUrl = "https://example.com/posts/foo/";
2242
+
2243
+ beforeEach(() => {
2244
+ vi.clearAllMocks();
2245
+ vi.mocked(readSiteFile).mockResolvedValue("ZmFrZQ==");
2246
+ vi.mocked(uploadAssetMultipart).mockResolvedValue({ id: "id-1", path: "https://assets.matters.news/embed/uploaded.jpg" });
2247
+ });
2248
+
2249
+ it("decodes percent-encoded Chinese characters in src for readSiteFile", async () => {
2250
+ // Browsers may percent-encode non-ASCII in src attributes; readSiteFile
2251
+ // needs the decoded filesystem path.
2252
+ const html = '<img src="%E5%9B%BE%E7%89%87/photo.png" alt="Photo">';
2253
+
2254
+ await uploadAndReplaceLocalImages(html, baseUrl, "draft-cn");
2255
+
2256
+ const readCall = vi.mocked(readSiteFile).mock.calls[0][0];
2257
+ expect(readCall).toContain("图片/photo.png");
2258
+ expect(readCall).not.toContain("%E5%9B%BE%E7%89%87");
2259
+ });
2260
+
2261
+ it("passes literal Chinese src characters to readSiteFile decoded", async () => {
2262
+ // src may contain raw non-ASCII if the author typed it directly
2263
+ const html = '<img src="图片/photo.png" alt="Photo">';
2264
+
2265
+ await uploadAndReplaceLocalImages(html, baseUrl, "draft-cn");
2266
+
2267
+ const readCall = vi.mocked(readSiteFile).mock.calls[0][0];
2268
+ expect(readCall).toContain("图片/photo.png");
2269
+ });
2270
+ });
2271
+
2272
+ // ============================================================================
2273
+ // Tests: waitForPublishOrClose detects browser close
2274
+ // ============================================================================
2275
+
2276
+ describe("syndicateArticle - browser close detection", () => {
2277
+ const siteUrl = "https://example.com";
2278
+ const userName = "testuser";
2279
+ const options = { addCanonicalLink: false, lang: "en" };
2280
+
2281
+ beforeEach(async () => {
2282
+ vi.clearAllMocks();
2283
+
2284
+ const { sleep } = await import("../utils");
2285
+ vi.mocked(sleep).mockResolvedValue(undefined);
2286
+
2287
+ const sdk = await import("@symbiosis-lab/moss-api");
2288
+ vi.mocked(sdk.showToast).mockResolvedValue(undefined);
2289
+ vi.mocked(sdk.closeBrowser).mockResolvedValue(undefined);
2290
+ vi.mocked(sdk.onEvent).mockResolvedValue(() => {});
2291
+ vi.mocked(sdk.emitEvent).mockResolvedValue(undefined);
2292
+
2293
+ const domain = await import("../domain");
2294
+ vi.mocked(domain.draftUrl).mockImplementation((id: string) => `https://matters.town/drafts/${id}`);
2295
+ vi.mocked(domain.articleUrl).mockImplementation((_user: string, slug: string, hash: string) => `https://matters.town/@testuser/${slug}-${hash}`);
2296
+
2297
+ vi.mocked(pluginFileExists).mockResolvedValue(false);
2298
+ vi.mocked(createDraft).mockResolvedValue(makeDraftResponse("draft-close-test"));
2299
+ vi.mocked(readSiteFile).mockResolvedValue("ZmFrZQ==");
2300
+ vi.mocked(uploadAssetMultipart).mockResolvedValue({ id: "asset-id-1", path: "https://assets.matters.news/embed/uploaded.jpg" });
2301
+ });
2302
+
2303
+ it("exits immediately when browser is closed, saves draft for reuse", async () => {
2304
+ const sdk = await import("@symbiosis-lab/moss-api");
2305
+
2306
+ // Simulate browser closing after first sleep call
2307
+ let resolveClose!: (reason: any) => void;
2308
+ const closedPromise = new Promise<any>((resolve) => { resolveClose = resolve; });
2309
+ vi.mocked(sdk.openBrowser).mockResolvedValue({ closed: closedPromise });
2310
+
2311
+ // Draft never gets published (no article field)
2312
+ vi.mocked(fetchDraft).mockResolvedValue({
2313
+ id: "draft-close-test",
2314
+ title: "Test",
2315
+ content: "<p>Test</p>",
2316
+ createdAt: "2024-01-01T00:00:00Z",
2317
+ publishState: "unpublished" as const,
2318
+ });
2319
+
2320
+ // Track how many times fetchDraft is polled.
2321
+ // Clock does NOT advance past timeout — so if waitForPublishOrClose
2322
+ // doesn't detect browser close, it will poll in an infinite loop.
2323
+ // We cap sleep calls to detect this.
2324
+ let sleepCallCount = 0;
2325
+ const MAX_SLEEP_CALLS = 5;
2326
+ const { sleep } = await import("../utils");
2327
+ vi.mocked(sleep).mockImplementation(async () => {
2328
+ sleepCallCount++;
2329
+ if (sleepCallCount === 1) {
2330
+ // Resolve browser close on first poll iteration
2331
+ resolveClose({ type: "user" });
2332
+ // Yield so the promise can settle
2333
+ await Promise.resolve();
2334
+ await Promise.resolve();
2335
+ }
2336
+ if (sleepCallCount > MAX_SLEEP_CALLS) {
2337
+ // Safety valve: prevent infinite loop in current (broken) code.
2338
+ // Force timeout by making Date.now() return a large value.
2339
+ vi.spyOn(Date, "now").mockReturnValue(Date.now() + 999999999);
2340
+ }
2341
+ });
2342
+
2343
+ const article = makeArticle({ source_path: "posts/test.md", frontmatter: {} });
2344
+
2345
+ const result = await syndicateArticle(article, siteUrl, userName, options, mockTask);
2346
+
2347
+ // Should NOT have published (browser was closed)
2348
+ expect(result.publishedUrl).toBeUndefined();
2349
+
2350
+ // Key assertion: browser close should stop polling after 1 sleep call.
2351
+ // If the code doesn't detect browser close, it will poll many times
2352
+ // until our safety valve kicks in at MAX_SLEEP_CALLS.
2353
+ expect(sleepCallCount).toBeLessThanOrEqual(2);
2354
+
2355
+ // Draft ID should be saved for reuse
2356
+ const draftWriteCalls = vi.mocked(writePluginFile).mock.calls.filter(
2357
+ call => call[0] === "drafts.json"
2358
+ );
2359
+ expect(draftWriteCalls.length).toBeGreaterThan(0);
2360
+ const written = JSON.parse(draftWriteCalls[draftWriteCalls.length - 1][1]);
2361
+ expect(written["posts/test.md"].draftId).toBe("draft-close-test");
2362
+ });
2363
+ });
2364
+
2365
+ // ============================================================================
2366
+ // Tests: waitForPublishOrClose — no wall-clock ceiling
2367
+ // ============================================================================
2368
+
2369
+ describe("waitForPublishOrClose - no wall-clock ceiling (resolves on publish or close only)", () => {
2370
+ beforeEach(async () => {
2371
+ vi.clearAllMocks();
2372
+ const { sleep } = await import("../utils");
2373
+ vi.mocked(sleep).mockResolvedValue(undefined);
2374
+ // onEvent / emitEvent are called inside waitForPublishOrClose — must be re-mocked
2375
+ // after clearAllMocks() or the .then()/.catch() chains will throw on undefined.
2376
+ const sdk = await import("@symbiosis-lab/moss-api");
2377
+ vi.mocked(sdk.onEvent).mockResolvedValue(() => {});
2378
+ vi.mocked(sdk.emitEvent).mockResolvedValue(undefined);
2379
+ vi.mocked(sdk.closeBrowser).mockResolvedValue(undefined);
2380
+ });
2381
+
2382
+ it("resolves with publish result when fetchDraft returns article", async () => {
2383
+ vi.mocked(fetchDraft).mockResolvedValueOnce({
2384
+ id: "d1",
2385
+ title: "T",
2386
+ content: "<p>T</p>",
2387
+ createdAt: "2024-01-01T00:00:00Z",
2388
+ publishState: "published" as const,
2389
+ article: { shortHash: "abc123", slug: "my-article" },
2390
+ });
2391
+
2392
+ const result = await waitForPublishOrClose("d1");
2393
+ expect(result).toEqual({ shortHash: "abc123", slug: "my-article" });
2394
+ });
2395
+
2396
+ it("resolves with null when browser handle closes (no publish)", async () => {
2397
+ // fetchDraft always returns unpublished — without browser close this would
2398
+ // loop forever. The test verifies the wall-clock-free path terminates on close.
2399
+ vi.mocked(fetchDraft).mockResolvedValue({
2400
+ id: "d2",
2401
+ title: "T",
2402
+ content: "<p>T</p>",
2403
+ createdAt: "2024-01-01T00:00:00Z",
2404
+ publishState: "unpublished" as const,
2405
+ });
2406
+
2407
+ let resolveClose!: (r: unknown) => void;
2408
+ const closedPromise = new Promise<unknown>((res) => { resolveClose = res; });
2409
+
2410
+ const { sleep } = await import("../utils");
2411
+ let sleepCalls = 0;
2412
+ vi.mocked(sleep).mockImplementation(async () => {
2413
+ sleepCalls++;
2414
+ // Trigger browser close on first poll so the race terminates.
2415
+ if (sleepCalls === 1) {
2416
+ resolveClose({ type: "user" });
2417
+ await Promise.resolve();
2418
+ await Promise.resolve();
2419
+ }
2420
+ // Safety: if close path is broken the poll would run forever; cap at 3.
2421
+ if (sleepCalls > 3) throw new Error("waitForPublishOrClose did not terminate on browser close");
2422
+ });
2423
+
2424
+ const result = await waitForPublishOrClose("d2", { closed: closedPromise } as any);
2425
+ expect(result).toBeNull();
2426
+ // Closed after first poll — should not have looped many times.
2427
+ expect(sleepCalls).toBeLessThanOrEqual(2);
2428
+ });
2429
+
2430
+ it("does NOT have a wall-clock timeout path — no setTimeout deadline", async () => {
2431
+ // Verify that the function signature no longer accepts a timeoutMs argument.
2432
+ // If someone accidentally re-adds it and uses it, this structural assertion
2433
+ // (function.length = parameter count) catches the regression.
2434
+ // waitForPublishOrClose(draftId, browserHandle?) → 2 formal params max.
2435
+ expect(waitForPublishOrClose.length).toBeLessThanOrEqual(2);
2436
+ });
2437
+ });