@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,412 @@
1
+ /**
2
+ * Task 2.3 — auto-detect tests for waitForPublishOrClose.
3
+ *
4
+ * These tests verify that a `browser-url-changed` event to a published-looking
5
+ * URL triggers an immediate fetchDraft verify. The URL-triggered path resolves
6
+ * the wait (poll is the fallback) — with `sleep` mocked, both paths are instant.
7
+ *
8
+ * Also verifies the negative case: a URL that doesn't look like a published
9
+ * article (or an article URL the API does NOT confirm) must NOT resolve.
10
+ */
11
+ // @vitest-environment node
12
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
13
+
14
+ // ── Mocks ────────────────────────────────────────────────────────────────────
15
+
16
+ // Capture the listener registered by onEvent so tests can invoke it directly
17
+ let capturedUrlListener: ((payload: { url: string }) => void) | null = null;
18
+
19
+ // The mock returns an unlisten function (vi.fn). We capture the registered
20
+ // callback so emitBrowserUrlChanged can invoke it in tests.
21
+ const mockUnlistenUrl = vi.fn();
22
+
23
+ const mockOnEvent = vi.fn().mockImplementation(
24
+ (eventName: string, cb: (payload: { url: string }) => void) => {
25
+ if (eventName === "browser-url-changed") {
26
+ capturedUrlListener = cb;
27
+ }
28
+ return Promise.resolve(mockUnlistenUrl);
29
+ }
30
+ );
31
+
32
+ vi.mock("@symbiosis-lab/moss-api", () => ({
33
+ getPluginCookie: vi.fn(),
34
+ httpPost: vi.fn(),
35
+ httpGet: vi.fn(),
36
+ fetchUrl: vi.fn(),
37
+ downloadAsset: vi.fn(),
38
+ readFile: vi.fn(),
39
+ writeFile: vi.fn(),
40
+ listFiles: vi.fn().mockResolvedValue([]),
41
+ showToast: vi.fn().mockResolvedValue(undefined),
42
+ dismissToast: vi.fn().mockResolvedValue(undefined),
43
+ // Never-resolving browser handle by default (poll-driven path)
44
+ openBrowser: vi.fn().mockResolvedValue({ closed: new Promise<void>(() => {}) }),
45
+ closeBrowser: vi.fn().mockResolvedValue(undefined),
46
+ readPluginFile: vi.fn().mockResolvedValue("{}"),
47
+ writePluginFile: vi.fn().mockResolvedValue(undefined),
48
+ pluginFileExists: vi.fn().mockResolvedValue(false),
49
+ getPluginEnvVar: vi.fn().mockResolvedValue(undefined),
50
+ startTask: vi.fn().mockResolvedValue({
51
+ id: "42",
52
+ progress: vi.fn().mockResolvedValue(undefined),
53
+ awaiting: vi.fn().mockResolvedValue(undefined),
54
+ advise: vi.fn().mockResolvedValue(undefined),
55
+ succeeded: vi.fn().mockResolvedValue(undefined),
56
+ failed: vi.fn().mockResolvedValue(undefined),
57
+ cancelled: vi.fn().mockResolvedValue(undefined),
58
+ }),
59
+ setMessageContext: vi.fn(),
60
+ sendMessage: vi.fn().mockResolvedValue(undefined),
61
+ reportProgress: vi.fn().mockResolvedValue(undefined),
62
+ reportError: vi.fn().mockResolvedValue(undefined),
63
+ reportComplete: vi.fn().mockResolvedValue(undefined),
64
+ emitEvent: vi.fn().mockResolvedValue(undefined),
65
+ // R7: onEvent mock that captures the callback for emitBrowserUrlChanged
66
+ onEvent: (...args: unknown[]) => mockOnEvent(...args),
67
+ }));
68
+
69
+ vi.mock("../config", () => ({
70
+ getConfig: vi.fn().mockResolvedValue({ userName: "guo" }),
71
+ saveConfig: vi.fn().mockResolvedValue(undefined),
72
+ }));
73
+
74
+ vi.mock("../credential", () => ({
75
+ clearTokenCache: vi.fn(),
76
+ loadStoredToken: vi.fn().mockResolvedValue(null),
77
+ saveStoredToken: vi.fn().mockResolvedValue(undefined),
78
+ clearStoredToken: vi.fn().mockResolvedValue(undefined),
79
+ getSessionState: vi.fn().mockResolvedValue("valid"),
80
+ shouldNudgeSessionExpired: vi.fn().mockResolvedValue(false),
81
+ markSessionInvalidated: vi.fn().mockResolvedValue(undefined),
82
+ authHeaderToken: vi.fn().mockResolvedValue("tok"),
83
+ captureLogin: vi.fn().mockResolvedValue("tok"),
84
+ prepareWebviewAuth: vi.fn().mockResolvedValue(undefined),
85
+ beginFreshLogin: vi.fn().mockResolvedValue(undefined),
86
+ }));
87
+
88
+ vi.mock("../api", () => ({
89
+ createDraft: vi.fn().mockResolvedValue({
90
+ id: "d1",
91
+ title: "Post",
92
+ content: "",
93
+ createdAt: "2026-01-01T00:00:00Z",
94
+ publishState: "unpublished",
95
+ article: null,
96
+ }),
97
+ fetchDraft: vi.fn().mockResolvedValue({
98
+ id: "d1",
99
+ title: "Post",
100
+ content: "",
101
+ createdAt: "2026-01-01T00:00:00Z",
102
+ publishState: "published",
103
+ article: { id: "a1", shortHash: "a1b2c3", slug: "post" },
104
+ }),
105
+ uploadCoverByUrl: vi.fn().mockResolvedValue("asset-1"),
106
+ uploadEmbedByUrl: vi.fn().mockResolvedValue("https://cdn/img.jpg"),
107
+ fetchUserProfile: vi.fn().mockResolvedValue({ userName: "guo", displayName: "Guo" }),
108
+ fetchAllArticlesSince: vi.fn().mockResolvedValue({ articles: [], userName: "guo" }),
109
+ fetchAllDraftsSince: vi.fn().mockResolvedValue([]),
110
+ fetchAllCollections: vi.fn().mockResolvedValue([]),
111
+ fetchArticleComments: vi.fn().mockResolvedValue({ comments: [], donations: [], appreciations: [] }),
112
+ fetchAllArticleCommentCounts: vi.fn().mockResolvedValue([]),
113
+ MattersAuthError: class MattersAuthError extends Error {},
114
+ apiConfig: { queryMode: "viewer", testUserName: "Matty", endpoint: "https://server.matters.town/graphql" },
115
+ }));
116
+
117
+ vi.mock("../domain", () => ({
118
+ initializeDomain: vi.fn().mockResolvedValue(undefined),
119
+ loginUrl: vi.fn().mockReturnValue("https://matters.town/login"),
120
+ draftUrl: vi.fn().mockImplementation((id: string) => `https://matters.town/drafts/${id}`),
121
+ articleUrl: vi.fn().mockImplementation((_u: string, slug: string, hash: string) => `https://matters.town/@guo/${slug}-${hash}`),
122
+ isMattersUrl: vi.fn().mockImplementation((url: string) => url.includes("matters.town")),
123
+ }));
124
+
125
+ vi.mock("../sync", () => ({
126
+ detectBoundUser: vi.fn().mockResolvedValue({ userName: "guo" }),
127
+ syncToLocalFiles: vi.fn().mockResolvedValue({ result: { created: 0, updated: 0, skipped: 0, errors: [] }, articlePathMap: new Map() }),
128
+ scanLocalArticles: vi.fn().mockResolvedValue([]),
129
+ }));
130
+
131
+ vi.mock("../utils", () => ({
132
+ reportError: vi.fn().mockResolvedValue(undefined),
133
+ setCurrentHookName: vi.fn(),
134
+ sleep: vi.fn().mockResolvedValue(undefined),
135
+ }));
136
+
137
+ vi.mock("../converter", () => ({
138
+ parseFrontmatter: vi.fn().mockReturnValue(null),
139
+ regenerateFrontmatter: vi.fn().mockReturnValue(""),
140
+ }));
141
+
142
+ vi.mock("../progress", () => ({
143
+ overallProgress: vi.fn().mockReturnValue(50),
144
+ }));
145
+
146
+ vi.mock("../social", () => ({
147
+ loadSocialData: vi.fn().mockResolvedValue({ articles: {} }),
148
+ saveSocialData: vi.fn().mockResolvedValue(undefined),
149
+ mergeSocialData: vi.fn().mockReturnValue({ articles: {} }),
150
+ reconcileLegacySocialData: vi.fn().mockResolvedValue(undefined),
151
+ }));
152
+
153
+ vi.mock("../downloader", () => ({
154
+ downloadMediaAndUpdate: vi.fn().mockResolvedValue(undefined),
155
+ rewriteAllInternalLinks: vi.fn().mockResolvedValue({ linksRewritten: 0 }),
156
+ }));
157
+
158
+ vi.mock("../auth-route", () => ({
159
+ resolveAuthRoute: vi.fn().mockResolvedValue({ kind: "skip" }),
160
+ isUserPresent: vi.fn().mockResolvedValue(true),
161
+ }));
162
+
163
+ // ── Test helper ───────────────────────────────────────────────────────────────
164
+
165
+ /**
166
+ * Simulates a browser-url-changed event reaching the plugin listener.
167
+ * Invokes the captured callback and flushes microtasks so the async
168
+ * fetchDraft inside the listener has time to resolve.
169
+ */
170
+ async function emitBrowserUrlChanged(url: string): Promise<void> {
171
+ if (!capturedUrlListener) {
172
+ throw new Error("No URL listener registered — is waitForPublishOrClose wired up?");
173
+ }
174
+ capturedUrlListener({ url });
175
+ // Flush pending microtasks (the fetchDraft await inside the listener)
176
+ await Promise.resolve();
177
+ await Promise.resolve();
178
+ await Promise.resolve();
179
+ }
180
+
181
+ // ── Tests ─────────────────────────────────────────────────────────────────────
182
+
183
+ import { waitForPublishOrClose } from "../main";
184
+ import { fetchDraft } from "../api";
185
+ import { emitEvent, closeBrowser } from "@symbiosis-lab/moss-api";
186
+
187
+ beforeEach(() => {
188
+ vi.clearAllMocks();
189
+ capturedUrlListener = null;
190
+ // Restore default onEvent implementation after clearAllMocks wipes it
191
+ mockOnEvent.mockImplementation(
192
+ (eventName: string, cb: (payload: { url: string }) => void) => {
193
+ if (eventName === "browser-url-changed") {
194
+ capturedUrlListener = cb;
195
+ }
196
+ return Promise.resolve(mockUnlistenUrl);
197
+ }
198
+ );
199
+ });
200
+
201
+ afterEach(() => {
202
+ vi.unstubAllGlobals();
203
+ });
204
+
205
+ describe("waitForPublishOrClose — URL auto-detect (Task 2.3)", () => {
206
+ it("resolves with published article when browser-url-changed fires a published URL and API confirms", async () => {
207
+ // API confirms the article was published
208
+ vi.mocked(fetchDraft).mockResolvedValue({
209
+ id: "d1",
210
+ title: "Post",
211
+ content: "",
212
+ createdAt: "2026-01-01T00:00:00Z",
213
+ publishState: "published",
214
+ article: { id: "a1", shortHash: "a1b2c3", slug: "post" },
215
+ } as never);
216
+
217
+ // Start waiting. The URL-triggered path resolves the wait; the poll is the
218
+ // fallback (both instant here because sleep is mocked).
219
+ const browserHandle = { closed: new Promise<void>(() => {}) };
220
+ const p = waitForPublishOrClose("d1", browserHandle);
221
+
222
+ // Give the function time to register the onEvent listener
223
+ await Promise.resolve();
224
+ await Promise.resolve();
225
+
226
+ // Trigger the URL change event with a valid published-article URL
227
+ await emitBrowserUrlChanged("https://matters.town/@guo/post-a1b2c3");
228
+
229
+ // URL-triggered path resolves the wait (poll is the fallback)
230
+ await expect(p).resolves.toEqual({ shortHash: "a1b2c3", slug: "post" });
231
+ });
232
+
233
+ it("does NOT resolve when browser-url-changed fires a non-article URL", async () => {
234
+ // API returns null (publish not confirmed)
235
+ vi.mocked(fetchDraft).mockResolvedValue({
236
+ id: "d1",
237
+ title: "Post",
238
+ content: "",
239
+ createdAt: "2026-01-01T00:00:00Z",
240
+ publishState: "unpublished",
241
+ article: null,
242
+ } as never);
243
+
244
+ // Browser closes immediately, so the close branch resolves the wait → null.
245
+ const browserHandle = { closed: Promise.resolve() };
246
+ const p = waitForPublishOrClose("d1", browserHandle);
247
+
248
+ await Promise.resolve();
249
+ await Promise.resolve();
250
+
251
+ // Fire a non-article URL — should not resolve the promise
252
+ await emitBrowserUrlChanged("https://matters.town/@guo/followers");
253
+
254
+ // Resolves null (browser closed, URL didn't trigger a publish)
255
+ await expect(p).resolves.toBeNull();
256
+ });
257
+
258
+ it("does NOT resolve when the API returns null even if the URL looks like a published article", async () => {
259
+ // The URL LOOKS like an article but the API does NOT confirm it
260
+ vi.mocked(fetchDraft).mockResolvedValue({
261
+ id: "d1",
262
+ title: "Post",
263
+ content: "",
264
+ createdAt: "2026-01-01T00:00:00Z",
265
+ publishState: "unpublished",
266
+ article: null,
267
+ } as never);
268
+
269
+ // Browser closes immediately so we get a null (not stuck waiting)
270
+ const browserHandle = { closed: Promise.resolve() };
271
+ const p = waitForPublishOrClose("d1", browserHandle);
272
+
273
+ await Promise.resolve();
274
+ await Promise.resolve();
275
+
276
+ // URL looks valid but API doesn't confirm → must NOT resolve as published
277
+ await emitBrowserUrlChanged("https://matters.town/@guo/some-other-post-a1b2c3");
278
+
279
+ await expect(p).resolves.toBeNull();
280
+ });
281
+
282
+ it("cleans up the URL listener (unlisten called) after resolution", async () => {
283
+ vi.mocked(fetchDraft).mockResolvedValue({
284
+ id: "d1",
285
+ title: "Post",
286
+ content: "",
287
+ createdAt: "2026-01-01T00:00:00Z",
288
+ publishState: "published",
289
+ article: { id: "a1", shortHash: "a1b2c3", slug: "post" },
290
+ } as never);
291
+
292
+ const browserHandle = { closed: new Promise<void>(() => {}) };
293
+ const p = waitForPublishOrClose("d1", browserHandle);
294
+
295
+ await Promise.resolve();
296
+ await Promise.resolve();
297
+ await emitBrowserUrlChanged("https://matters.town/@guo/post-a1b2c3");
298
+ await p;
299
+
300
+ // The unlisten function returned by onEvent must have been called
301
+ expect(mockUnlistenUrl).toHaveBeenCalled();
302
+ });
303
+
304
+ it("leaked-listener race: URL event fired after close-before-unlisten does NOT call fetchDraft again", async () => {
305
+ // Arrange: API confirms unpublished (so only the close branch can settle null)
306
+ vi.mocked(fetchDraft).mockResolvedValue({
307
+ id: "d1",
308
+ title: "Post",
309
+ content: "",
310
+ createdAt: "2026-01-01T00:00:00Z",
311
+ publishState: "unpublished",
312
+ article: null,
313
+ } as never);
314
+
315
+ // browserHandle.closed is already-resolved → settle(null) fires synchronously
316
+ // in the next microtask, before the onEvent .then() storing unlistenUrl runs.
317
+ const browserHandle = { closed: Promise.resolve() };
318
+ const p = waitForPublishOrClose("d1", browserHandle);
319
+
320
+ // Drain microtasks so settle(null) fires (browser close branch)
321
+ await Promise.resolve();
322
+ await Promise.resolve();
323
+ await Promise.resolve();
324
+
325
+ // Wait should already be resolved as null
326
+ await expect(p).resolves.toBeNull();
327
+
328
+ // Record how many times fetchDraft was called up to this point
329
+ const callsBefore = vi.mocked(fetchDraft).mock.calls.length;
330
+
331
+ // Now simulate the leaked listener firing (as if unlisten hadn't run yet)
332
+ // capturedUrlListener may or may not be set depending on microtask ordering;
333
+ // if it is set, the callback must short-circuit on `settled` and not call fetchDraft.
334
+ if (capturedUrlListener) {
335
+ capturedUrlListener({ url: "https://matters.town/@guo/post-a1b2c3" });
336
+ await Promise.resolve();
337
+ await Promise.resolve();
338
+ await Promise.resolve();
339
+ }
340
+
341
+ // fetchDraft must NOT have been called an additional time
342
+ expect(vi.mocked(fetchDraft).mock.calls.length).toBe(callsBefore);
343
+ });
344
+ });
345
+
346
+ // ── R19 — Held confirmation beat ──────────────────────────────────────────────
347
+
348
+ describe("R19 — matters-room-published emitted before closeBrowser on confirmed publish", () => {
349
+ it("emits matters-room-published before calling closeBrowser on the confirmed-publish path", async () => {
350
+ // API confirms the article was published
351
+ vi.mocked(fetchDraft).mockResolvedValue({
352
+ id: "d1",
353
+ title: "Post",
354
+ content: "",
355
+ createdAt: "2026-01-01T00:00:00Z",
356
+ publishState: "published",
357
+ article: { id: "a1", shortHash: "a1b2c3", slug: "post" },
358
+ } as never);
359
+
360
+ // Track call order via an explicit ordering array
361
+ const callOrder: string[] = [];
362
+ vi.mocked(emitEvent).mockImplementation(async (name: string) => {
363
+ callOrder.push(`emit:${name}`);
364
+ });
365
+ vi.mocked(closeBrowser).mockImplementation(async () => {
366
+ callOrder.push("closeBrowser");
367
+ });
368
+
369
+ const browserHandle = { closed: new Promise<void>(() => {}) };
370
+ const p = waitForPublishOrClose("d1", browserHandle);
371
+
372
+ await Promise.resolve();
373
+ await Promise.resolve();
374
+
375
+ // Trigger the URL change — the settle(published) path fires
376
+ await emitBrowserUrlChanged("https://matters.town/@guo/post-a1b2c3");
377
+
378
+ // Wait for resolution (sleep is mocked to no-op so the 800ms is instant)
379
+ await p;
380
+
381
+ // matters-room-published MUST appear before closeBrowser in the call order
382
+ expect(callOrder).toContain("emit:matters-room-published");
383
+ expect(callOrder).toContain("closeBrowser");
384
+ const publishedIdx = callOrder.indexOf("emit:matters-room-published");
385
+ const closeIdx = callOrder.indexOf("closeBrowser");
386
+ expect(publishedIdx).toBeLessThan(closeIdx);
387
+ });
388
+
389
+ it("does NOT emit matters-room-published on the close (null) path", async () => {
390
+ // API never confirms → browser closes → settle(null)
391
+ vi.mocked(fetchDraft).mockResolvedValue({
392
+ id: "d1",
393
+ title: "Post",
394
+ content: "",
395
+ createdAt: "2026-01-01T00:00:00Z",
396
+ publishState: "unpublished",
397
+ article: null,
398
+ } as never);
399
+
400
+ const browserHandle = { closed: Promise.resolve() };
401
+ const p = waitForPublishOrClose("d1", browserHandle);
402
+
403
+ await Promise.resolve();
404
+ await Promise.resolve();
405
+ await Promise.resolve();
406
+ await p;
407
+
408
+ // matters-room-published must NOT be emitted on the skip/close path
409
+ const emittedNames = vi.mocked(emitEvent).mock.calls.map((c) => c[0]);
410
+ expect(emittedNames).not.toContain("matters-room-published");
411
+ });
412
+ });
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Tests for the process hook binding guard.
3
+ *
4
+ * The binding guard prevents the Matters plugin from syncing articles
5
+ * into projects that haven't been explicitly connected to a Matters account.
6
+ */
7
+
8
+ import { describe, it, expect, vi, beforeEach } from "vitest";
9
+
10
+ // ============================================================================
11
+ // Mocks
12
+ // ============================================================================
13
+
14
+ const mockGetConfig = vi.fn();
15
+ const mockSaveConfig = vi.fn().mockResolvedValue(undefined);
16
+ const mockDetectBoundUser = vi.fn();
17
+ const mockGetAccessToken = vi.fn();
18
+ const mockFetchUserProfile = vi.fn();
19
+ const mockOpenBrowser = vi.fn();
20
+ const mockCloseBrowser = vi.fn().mockResolvedValue(undefined);
21
+ // vi.hoisted: the ../api factory passes this object through by VALUE
22
+ // (`apiConfig: mockApiConfig`), which evaluates at factory time — a plain
23
+ // top-level const would hit the vi.mock hoisting TDZ. Shared and MUTATED
24
+ // across tests (public_fallback flips it), so beforeEach resets it.
25
+ const mockApiConfig = vi.hoisted(() => ({
26
+ queryMode: "viewer",
27
+ testUserName: "Matty",
28
+ endpoint: "https://server.matters.town/graphql",
29
+ }));
30
+
31
+ vi.mock("@symbiosis-lab/moss-api", () => ({
32
+ getPluginCookie: vi.fn(),
33
+ httpPost: vi.fn(),
34
+ readFile: vi.fn(),
35
+ writeFile: vi.fn(),
36
+ listFiles: vi.fn().mockResolvedValue([]),
37
+ showToast: vi.fn().mockResolvedValue(undefined),
38
+ openBrowser: (...args: unknown[]) => mockOpenBrowser(...args),
39
+ closeBrowser: (...args: unknown[]) => mockCloseBrowser(...args),
40
+ returnToEditor: vi.fn().mockResolvedValue(undefined),
41
+ readPluginFile: vi.fn(),
42
+ writePluginFile: vi.fn().mockResolvedValue(undefined),
43
+ pluginFileExists: vi.fn(),
44
+ // T8a escape hatch — undefined return = no test profile = production
45
+ // path (which is what these binding-guard tests exercise).
46
+ getPluginEnvVar: vi.fn().mockResolvedValue(undefined),
47
+ // clearPluginCookies — called by promptLogin() before opening the browser.
48
+ clearPluginCookies: vi.fn().mockResolvedValue(undefined),
49
+ // startTask mock — returns a no-op TaskHandle so process hook can drive
50
+ // the PanelTask lifecycle without a real Tauri context.
51
+ startTask: vi.fn().mockResolvedValue({
52
+ id: "0",
53
+ progress: vi.fn().mockResolvedValue(undefined),
54
+ awaiting: vi.fn().mockResolvedValue(undefined),
55
+ succeeded: vi.fn().mockResolvedValue(undefined),
56
+ failed: vi.fn().mockResolvedValue(undefined),
57
+ cancelled: vi.fn().mockResolvedValue(undefined),
58
+ }),
59
+ }));
60
+
61
+ vi.mock("../config", () => ({
62
+ getConfig: (...args: unknown[]) => mockGetConfig(...args),
63
+ saveConfig: (...args: unknown[]) => mockSaveConfig(...args),
64
+ }));
65
+
66
+ vi.mock("../sync", () => ({
67
+ detectBoundUser: (...args: unknown[]) => mockDetectBoundUser(...args),
68
+ syncToLocalFiles: vi.fn().mockResolvedValue({ result: { created: 0, updated: 0, skipped: 0 }, articlePathMap: new Map() }),
69
+ scanLocalArticles: vi.fn().mockResolvedValue([]),
70
+ }));
71
+
72
+ vi.mock("../credential", () => ({
73
+ clearTokenCache: vi.fn(),
74
+ loadStoredToken: vi.fn().mockResolvedValue(null),
75
+ saveStoredToken: vi.fn().mockResolvedValue(undefined),
76
+ clearStoredToken: vi.fn().mockResolvedValue(undefined),
77
+ getSessionState: vi.fn().mockResolvedValue("none"),
78
+ shouldNudgeSessionExpired: vi.fn().mockResolvedValue(false),
79
+ markSessionInvalidated: vi.fn().mockResolvedValue(undefined),
80
+ authHeaderToken: vi.fn(),
81
+ captureLogin: (...args: unknown[]) => mockGetAccessToken(...args),
82
+ prepareWebviewAuth: vi.fn().mockResolvedValue(undefined),
83
+ beginFreshLogin: vi.fn().mockResolvedValue(undefined),
84
+ }));
85
+
86
+ vi.mock("../api", () => ({
87
+ fetchAllArticlesSince: vi.fn().mockResolvedValue({ articles: [], userName: "testuser" }),
88
+ fetchAllDraftsSince: vi.fn().mockResolvedValue([]),
89
+ fetchAllCollections: vi.fn().mockResolvedValue([]),
90
+ fetchUserProfile: (...args: unknown[]) => mockFetchUserProfile(...args),
91
+ fetchArticleComments: vi.fn().mockResolvedValue({ comments: [], donations: [], appreciations: [] }),
92
+ fetchAllArticleCommentCounts: vi.fn().mockResolvedValue(new Map()),
93
+ apiConfig: mockApiConfig,
94
+ MattersAuthError: class MattersAuthError extends Error {
95
+ readonly code: string;
96
+ constructor(code: string, message: string) {
97
+ super(message);
98
+ this.name = "MattersAuthError";
99
+ this.code = code;
100
+ }
101
+ },
102
+ }));
103
+
104
+ vi.mock("../domain", () => ({
105
+ initializeDomain: vi.fn().mockResolvedValue("matters.town"),
106
+ getDomain: vi.fn().mockReturnValue("matters.town"),
107
+ loginUrl: vi.fn().mockReturnValue("https://matters.town/login"),
108
+ articleUrl: vi.fn(),
109
+ isMattersUrl: vi.fn(),
110
+ }));
111
+
112
+ vi.mock("../utils", () => ({
113
+ reportError: vi.fn().mockResolvedValue(undefined),
114
+ setCurrentHookName: vi.fn(),
115
+ sleep: vi.fn().mockResolvedValue(undefined),
116
+ }));
117
+
118
+ vi.mock("../progress", () => ({
119
+ overallProgress: vi.fn().mockReturnValue(0),
120
+ }));
121
+
122
+ vi.mock("../converter", () => ({
123
+ parseFrontmatter: vi.fn(),
124
+ regenerateFrontmatter: vi.fn(),
125
+ }));
126
+
127
+ vi.mock("../downloader", () => ({
128
+ downloadMediaAndUpdate: vi.fn().mockResolvedValue(undefined),
129
+ rewriteAllInternalLinks: vi.fn().mockResolvedValue(undefined),
130
+ }));
131
+
132
+ vi.mock("../social", () => ({
133
+ loadSocialData: vi.fn().mockResolvedValue({}),
134
+ saveSocialData: vi.fn().mockResolvedValue(undefined),
135
+ mergeSocialData: vi.fn().mockReturnValue({}),
136
+ }));
137
+
138
+ import { process } from "../main";
139
+
140
+ // ============================================================================
141
+ // Tests
142
+ // ============================================================================
143
+
144
+ describe("process hook binding guard", () => {
145
+ // sync_on_build: false so process() returns early after auth, letting us test
146
+ // only the binding guard logic without needing full sync mocking
147
+ const baseContext = {
148
+ project_path: "/test-project",
149
+ moss_dir: "/test-project/.moss",
150
+ config: { sync_on_build: false },
151
+ project_info: { homepage_file: null, lang: "en" },
152
+ };
153
+
154
+ beforeEach(() => {
155
+ vi.clearAllMocks();
156
+ mockApiConfig.queryMode = "viewer";
157
+ mockApiConfig.testUserName = "Matty";
158
+ });
159
+
160
+ it("proceeds normally when boundUserName is already set", async () => {
161
+ mockGetConfig.mockResolvedValue({ boundUserName: "alice", userName: "alice" });
162
+ mockGetAccessToken.mockResolvedValue("test-token");
163
+ mockFetchUserProfile.mockResolvedValue({
164
+ userName: "alice",
165
+ displayName: "Alice",
166
+ language: "en",
167
+ });
168
+
169
+ const result = await process(baseContext);
170
+
171
+ expect(result.success).toBe(true);
172
+ // detectBoundUser should NOT be called — boundUserName was already in config
173
+ expect(mockDetectBoundUser).not.toHaveBeenCalled();
174
+ });
175
+
176
+ it("auto-binds when existing Matters articles are found", async () => {
177
+ // First call: no boundUserName. Second call (after save): with boundUserName
178
+ mockGetConfig
179
+ .mockResolvedValueOnce({}) // binding guard check
180
+ .mockResolvedValue({ boundUserName: "bob", userName: "bob" }); // subsequent calls
181
+ mockDetectBoundUser.mockResolvedValue("bob");
182
+ mockGetAccessToken.mockResolvedValue("test-token");
183
+ mockFetchUserProfile.mockResolvedValue({
184
+ userName: "bob",
185
+ displayName: "Bob",
186
+ language: "en",
187
+ });
188
+
189
+ const result = await process(baseContext);
190
+
191
+ expect(result.success).toBe(true);
192
+ // Should have saved both boundUserName and userName
193
+ expect(mockSaveConfig).toHaveBeenCalledWith(
194
+ expect.objectContaining({ boundUserName: "bob", userName: "bob" })
195
+ );
196
+ // Should NOT have opened the login browser
197
+ expect(mockOpenBrowser).not.toHaveBeenCalled();
198
+ });
199
+
200
+ it("skips sync gracefully when user closes login window on unbound project", async () => {
201
+ mockGetConfig.mockResolvedValue({}); // no boundUserName
202
+ mockDetectBoundUser.mockResolvedValue(null); // no existing articles
203
+ // openBrowser returns a handle whose .closed resolves immediately (user closed window)
204
+ mockOpenBrowser.mockResolvedValue({
205
+ closed: Promise.resolve(),
206
+ });
207
+ mockGetAccessToken.mockResolvedValue(null); // no token
208
+
209
+ // A present-user trigger is required to reach promptLogin at all — the
210
+ // background gate exits before it (spec §3.3). Without this trigger the
211
+ // window-close mocks above are dead setup and the path is uncovered.
212
+ const result = await process({ ...baseContext, trigger: "onboarding_flow" });
213
+
214
+ expect(result.success).toBe(true);
215
+ expect(result.message).toContain("No Matters account bound");
216
+ // The login window must actually have been opened (guards against the
217
+ // trigger gate silently short-circuiting this test again).
218
+ expect(mockOpenBrowser).toHaveBeenCalled();
219
+ // Should NOT have saved any config
220
+ expect(mockSaveConfig).not.toHaveBeenCalled();
221
+ });
222
+
223
+ it("binds after successful login on fresh project", async () => {
224
+ mockGetConfig
225
+ .mockResolvedValueOnce({}) // binding guard check
226
+ .mockResolvedValueOnce({}) // affirmBindingFromProfile inside promptLogin
227
+ .mockResolvedValue({ boundUserName: "carol", userName: "carol" }); // after binding
228
+ mockDetectBoundUser.mockResolvedValue(null); // no existing articles
229
+ // openBrowser returns handle; token appears after login
230
+ mockOpenBrowser.mockResolvedValue({
231
+ closed: new Promise(() => {}), // never closes
232
+ });
233
+ // First getAccessToken call returns null (pre-login), then returns token
234
+ mockGetAccessToken
235
+ .mockResolvedValueOnce(null) // initial check in waitForToken
236
+ .mockResolvedValueOnce("new-token") // found after login
237
+ .mockResolvedValue("new-token"); // subsequent checks
238
+ mockFetchUserProfile.mockResolvedValue({
239
+ userName: "carol",
240
+ displayName: "Carol",
241
+ language: "en",
242
+ });
243
+
244
+ // Login-to-bind requires a present user since the trigger gate (spec
245
+ // §3.3): background/absent triggers exit quietly instead of prompting.
246
+ const result = await process({ ...baseContext, trigger: "onboarding_flow" });
247
+
248
+ expect(result.success).toBe(true);
249
+ // affirmBindingFromProfile (called from promptLogin) saves the binding.
250
+ // The condition fires because getConfig returns {} (no boundUserName yet)
251
+ // while fetchUserProfile returns "carol".
252
+ expect(mockSaveConfig).toHaveBeenCalledWith(
253
+ expect.objectContaining({ boundUserName: "carol" })
254
+ );
255
+ });
256
+ });