@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,38 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { resolveAuthRoute, isUserPresent } from "../auth-route";
3
+
4
+ describe("isUserPresent", () => {
5
+ it.each([
6
+ ["onboarding_flow", true],
7
+ ["settings_manual", true],
8
+ ["manual_one", true],
9
+ ["background", false],
10
+ [undefined, false], // older moss: absent ⇒ background, the quiet default
11
+ ["future_unknown_trigger", false], // unknown ⇒ quiet default, never popups
12
+ ] as const)("trigger %s → %s", (trigger, expected) => {
13
+ expect(isUserPresent(trigger)).toBe(expected);
14
+ });
15
+ });
16
+
17
+ describe("resolveAuthRoute", () => {
18
+ // Full table from the design spec §3.3.
19
+ it.each([
20
+ // state trigger hasUserName expected
21
+ ["valid", "background", true, "proceed"],
22
+ ["valid", "background", false, "proceed"],
23
+ ["valid", "settings_manual", true, "proceed"],
24
+ ["valid", "onboarding_flow", false, "proceed"],
25
+ ["expired", "onboarding_flow", true, "prompt_login"],
26
+ ["expired", "settings_manual", true, "prompt_login"],
27
+ ["expired", "manual_one", false, "prompt_login"],
28
+ ["expired", "background", true, "public_fallback"],
29
+ ["expired", "background", false, "soft_fail"],
30
+ ["none", "settings_manual", true, "public_fallback"], // existing behavior preserved
31
+ ["none", "onboarding_flow", false, "prompt_login"], // existing behavior preserved
32
+ ["none", "background", true, "public_fallback"],
33
+ ["none", "background", false, "soft_fail"], // was promptLogin: background never popups
34
+ ["expired", undefined, true, "public_fallback"], // absent trigger = background
35
+ ] as const)("(%s, %s, userName=%s) → %s", (state, trigger, hasUserName, expected) => {
36
+ expect(resolveAuthRoute(state, trigger, hasUserName)).toBe(expected);
37
+ });
38
+ });
@@ -0,0 +1,462 @@
1
+ /**
2
+ * Tests for trigger-aware auth routing in the process hook (and, from T6,
3
+ * the syndicate session gate + mid-sync auth failure handling).
4
+ *
5
+ * Mock prelude copied from binding-guard.test.ts with the deltas the full
6
+ * pipeline needs (binding-guard's tests use sync_on_build: false and return
7
+ * early; these run the whole import path).
8
+ */
9
+
10
+ import { describe, it, expect, vi, beforeEach } from "vitest";
11
+
12
+ // ============================================================================
13
+ // Mocks
14
+ // ============================================================================
15
+
16
+ const mockGetConfig = vi.fn();
17
+ const mockSaveConfig = vi.fn().mockResolvedValue(undefined);
18
+ const mockDetectBoundUser = vi.fn();
19
+ const mockGetAccessToken = vi.fn();
20
+ const mockFetchUserProfile = vi.fn();
21
+ const mockOpenBrowser = vi.fn();
22
+ const mockCloseBrowser = vi.fn().mockResolvedValue(undefined);
23
+ const mockGetSessionState = vi.fn();
24
+ const mockShouldNudge = vi.fn();
25
+ const mockFetchAllArticlesSince = vi.fn().mockResolvedValue({ articles: [], userName: "testuser" });
26
+ const mockShowToast = vi.fn().mockResolvedValue(undefined);
27
+ const mockDismissToast = vi.fn().mockResolvedValue(undefined);
28
+ const mockTaskFailed = vi.fn().mockResolvedValue(undefined);
29
+ const mockTaskSucceeded = vi.fn().mockResolvedValue(undefined);
30
+ // vi.hoisted: the ../api factory passes this object through by VALUE
31
+ // (`apiConfig: mockApiConfig`), which evaluates at factory time — a plain
32
+ // top-level const would hit the vi.mock hoisting TDZ.
33
+ const mockApiConfig = vi.hoisted(() => ({
34
+ queryMode: "viewer",
35
+ testUserName: "Matty",
36
+ endpoint: "https://server.matters.town/graphql",
37
+ }));
38
+ // Hoisted so it's accessible inside vi.mock AND in test assertions.
39
+ // Task 2 watchdog fix: verify task.awaiting() is called BEFORE promptLogin().
40
+ const mockTaskAwaiting = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
41
+
42
+ vi.mock("@symbiosis-lab/moss-api", () => ({
43
+ getPluginCookie: vi.fn(),
44
+ httpPost: vi.fn(),
45
+ readFile: vi.fn(),
46
+ writeFile: vi.fn(),
47
+ listFiles: vi.fn().mockResolvedValue([]),
48
+ showToast: (...args: unknown[]) => mockShowToast(...args),
49
+ dismissToast: (...args: unknown[]) => mockDismissToast(...args),
50
+ openBrowser: (...args: unknown[]) => mockOpenBrowser(...args),
51
+ closeBrowser: (...args: unknown[]) => mockCloseBrowser(...args),
52
+ returnToEditor: vi.fn().mockResolvedValue(undefined),
53
+ readPluginFile: vi.fn(),
54
+ writePluginFile: vi.fn().mockResolvedValue(undefined),
55
+ pluginFileExists: vi.fn(),
56
+ // T8a escape hatch — undefined return = no test profile = production
57
+ // path (which is what these routing tests exercise).
58
+ getPluginEnvVar: vi.fn().mockResolvedValue(undefined),
59
+ // clearPluginCookies — called by promptLogin() before opening the browser.
60
+ clearPluginCookies: vi.fn().mockResolvedValue(undefined),
61
+ // startTask mock — returns a TaskHandle whose terminal transitions are
62
+ // captured so tests can assert on the receipt copy.
63
+ // mockTaskAwaiting is hoisted so tests can assert call-order vs openBrowser.
64
+ startTask: vi.fn().mockResolvedValue({
65
+ id: "0",
66
+ progress: vi.fn().mockResolvedValue(undefined),
67
+ awaiting: (...args: unknown[]) => mockTaskAwaiting(...args),
68
+ advise: vi.fn().mockResolvedValue(undefined),
69
+ succeeded: (...args: unknown[]) => mockTaskSucceeded(...args),
70
+ failed: (...args: unknown[]) => mockTaskFailed(...args),
71
+ cancelled: vi.fn().mockResolvedValue(undefined),
72
+ }),
73
+ }));
74
+
75
+ vi.mock("../config", () => ({
76
+ getConfig: (...args: unknown[]) => mockGetConfig(...args),
77
+ saveConfig: (...args: unknown[]) => mockSaveConfig(...args),
78
+ }));
79
+
80
+ vi.mock("../sync", () => ({
81
+ detectBoundUser: (...args: unknown[]) => mockDetectBoundUser(...args),
82
+ syncToLocalFiles: vi.fn().mockResolvedValue({
83
+ result: { created: 0, updated: 0, skipped: 0, errors: [] },
84
+ articlePathMap: new Map(),
85
+ }),
86
+ scanLocalArticles: vi.fn().mockResolvedValue([]),
87
+ }));
88
+
89
+ vi.mock("../credential", () => ({
90
+ clearTokenCache: vi.fn(),
91
+ loadStoredToken: vi.fn().mockResolvedValue(null),
92
+ saveStoredToken: vi.fn().mockResolvedValue(undefined),
93
+ clearStoredToken: vi.fn().mockResolvedValue(undefined),
94
+ getSessionState: (...args: unknown[]) => mockGetSessionState(...args),
95
+ shouldNudgeSessionExpired: (...args: unknown[]) => mockShouldNudge(...args),
96
+ markSessionInvalidated: vi.fn().mockResolvedValue(undefined),
97
+ authHeaderToken: vi.fn(),
98
+ captureLogin: (...args: unknown[]) => mockGetAccessToken(...args),
99
+ prepareWebviewAuth: vi.fn().mockResolvedValue(undefined),
100
+ beginFreshLogin: vi.fn().mockResolvedValue(undefined),
101
+ }));
102
+
103
+ vi.mock("../api", () => ({
104
+ fetchAllArticlesSince: (...args: unknown[]) => mockFetchAllArticlesSince(...args),
105
+ fetchAllDraftsSince: vi.fn().mockResolvedValue([]),
106
+ fetchAllCollections: vi.fn().mockResolvedValue([]),
107
+ fetchUserProfile: (...args: unknown[]) => mockFetchUserProfile(...args),
108
+ fetchArticleComments: vi.fn().mockResolvedValue({ comments: [], donations: [], appreciations: [] }),
109
+ fetchAllArticleCommentCounts: vi.fn().mockResolvedValue(new Map()),
110
+ apiConfig: mockApiConfig,
111
+ MattersAuthError: class MattersAuthError extends Error {
112
+ readonly code: string;
113
+ constructor(code: string, message: string) {
114
+ super(message);
115
+ this.name = "MattersAuthError";
116
+ this.code = code;
117
+ }
118
+ },
119
+ }));
120
+
121
+ vi.mock("../domain", () => ({
122
+ initializeDomain: vi.fn().mockResolvedValue("matters.town"),
123
+ getDomain: vi.fn().mockReturnValue("matters.town"),
124
+ loginUrl: vi.fn().mockReturnValue("https://matters.town/login"),
125
+ articleUrl: vi.fn(),
126
+ isMattersUrl: vi.fn(),
127
+ }));
128
+
129
+ vi.mock("../utils", () => ({
130
+ reportError: vi.fn().mockResolvedValue(undefined),
131
+ setCurrentHookName: vi.fn(),
132
+ sleep: vi.fn().mockResolvedValue(undefined),
133
+ // Pure receipt formatter — stubbed (these tests assert routing + the unauth
134
+ // note, not the summary text; the real impl is covered by utils.test.ts).
135
+ // A fixed factory is used here on purpose: importing the real ../utils runs
136
+ // its top-level setMessageContext() side effect, which has no host in tests.
137
+ formatArticleSyncSummary: vi.fn(() => "articles synced"),
138
+ }));
139
+
140
+ vi.mock("../progress", () => ({
141
+ overallProgress: vi.fn().mockReturnValue(0),
142
+ }));
143
+
144
+ vi.mock("../converter", () => ({
145
+ parseFrontmatter: vi.fn(),
146
+ regenerateFrontmatter: vi.fn(),
147
+ }));
148
+
149
+ vi.mock("../downloader", () => ({
150
+ downloadMediaAndUpdate: vi.fn().mockResolvedValue({ imagesDownloaded: 0, imagesSkipped: 0, errors: [] }),
151
+ rewriteAllInternalLinks: vi.fn().mockResolvedValue({ linksRewritten: 0 }),
152
+ }));
153
+
154
+ vi.mock("../social", () => ({
155
+ loadSocialData: vi.fn().mockResolvedValue({}),
156
+ saveSocialData: vi.fn().mockResolvedValue(undefined),
157
+ mergeSocialData: vi.fn().mockReturnValue({}),
158
+ reconcileLegacySocialData: vi.fn().mockResolvedValue(false),
159
+ }));
160
+
161
+ import { process as processHook, syndicate } from "../main";
162
+ // Resolves to the class in our ../api mock, so instanceof matches what
163
+ // main.ts (which imports from the same mocked module) catches.
164
+ import { MattersAuthError } from "../api";
165
+
166
+ // ============================================================================
167
+ // Fixtures
168
+ // ============================================================================
169
+
170
+ /** Passing-guard config fixture: boundUserName set so the guard is satisfied. */
171
+ const BOUND_CONFIG = { boundUserName: "guo", userName: "guo" };
172
+
173
+ function makeContext(trigger: string | undefined) {
174
+ // Mirror binding-guard.test.ts's context fixture; only trigger varies.
175
+ return {
176
+ trigger,
177
+ config: { sync_on_build: true },
178
+ project_info: { folder_name: "test", homepage_file: null, lang: "en" },
179
+ } as never;
180
+ }
181
+
182
+ beforeEach(() => {
183
+ vi.clearAllMocks();
184
+ mockApiConfig.queryMode = "viewer";
185
+ mockApiConfig.testUserName = "Matty";
186
+ mockGetConfig.mockResolvedValue({ ...BOUND_CONFIG, userName: "guo" });
187
+ mockShouldNudge.mockResolvedValue(true);
188
+ mockFetchUserProfile.mockResolvedValue({ userName: "guo", displayName: "Guo", language: "en" });
189
+ // Restore awaiting behavior after clearAllMocks (Task 2 watchdog fix).
190
+ mockTaskAwaiting.mockResolvedValue(undefined);
191
+ });
192
+
193
+ // ============================================================================
194
+ // Tests
195
+ // ============================================================================
196
+
197
+ describe("process hook auth routing", () => {
198
+ it("expired + background + userName → public fallback, no login window, nudge toast", async () => {
199
+ mockGetSessionState.mockResolvedValue("expired");
200
+ await processHook(makeContext("background"));
201
+ expect(mockOpenBrowser).not.toHaveBeenCalled();
202
+ expect(mockApiConfig.queryMode).toBe("user");
203
+ expect(mockApiConfig.testUserName).toBe("guo");
204
+ expect(mockShowToast).toHaveBeenCalledTimes(1);
205
+ expect(mockShowToast.mock.calls[0][0].message).toContain("session expired");
206
+ expect(mockShowToast.mock.calls[0][0].message).not.toContain("—");
207
+ // Law 2: the session-expired toast must persist — it is a blocking auth
208
+ // state and must not auto-dismiss (commit a084a436e made it persistent).
209
+ expect(mockShowToast.mock.calls[0][0].persistent).toBe(true);
210
+ expect(String(mockTaskSucceeded.mock.calls[0][0])).toContain(". Matters session expired");
211
+ });
212
+
213
+ it("nudge toast suppressed when the persisted throttle says no (logs only)", async () => {
214
+ mockGetSessionState.mockResolvedValue("expired");
215
+ mockShouldNudge.mockResolvedValue(false);
216
+ await processHook(makeContext("background"));
217
+ expect(mockShowToast).not.toHaveBeenCalled();
218
+ expect(String(mockTaskSucceeded.mock.calls[0][0])).toContain("log in to resume"); // receipt still honest
219
+ });
220
+
221
+ it("expired + background + NO userName → soft fail with session-expired copy", async () => {
222
+ mockGetConfig.mockResolvedValue({ ...BOUND_CONFIG, userName: undefined });
223
+ mockGetSessionState.mockResolvedValue("expired");
224
+ const result = await processHook(makeContext("background"));
225
+ expect(result.success).toBe(false);
226
+ expect(String(mockTaskFailed.mock.calls[0][0])).toContain("session expired");
227
+ expect(mockOpenBrowser).not.toHaveBeenCalled();
228
+ });
229
+
230
+ it("expired + settings_manual → opens the login window", async () => {
231
+ mockGetSessionState.mockResolvedValue("expired");
232
+ // Pre-closed handle: waitForToken's window-closed check exits the poll,
233
+ // promptLogin returns false; we only assert the login UI was reached.
234
+ mockOpenBrowser.mockResolvedValue({ closed: Promise.resolve() });
235
+ const result = await processHook(makeContext("settings_manual"));
236
+ expect(mockOpenBrowser).toHaveBeenCalled();
237
+ expect(result.success).toBe(false); // login did not complete in this stub
238
+ });
239
+
240
+ it("none + settings_manual + userName → public fallback with not-logged-in receipt (existing behavior, now honest)", async () => {
241
+ mockGetSessionState.mockResolvedValue("none");
242
+ const result = await processHook(makeContext("settings_manual"));
243
+ expect(mockOpenBrowser).not.toHaveBeenCalled();
244
+ expect(mockApiConfig.queryMode).toBe("user");
245
+ expect(result.success).toBe(true);
246
+ expect(String(mockTaskSucceeded.mock.calls[0][0])).toContain(". Not logged in");
247
+ expect(mockShowToast).not.toHaveBeenCalled(); // no session event, no toast
248
+ });
249
+
250
+ it("valid + background → proceeds in viewer mode, no toast, no login", async () => {
251
+ mockGetSessionState.mockResolvedValue("valid");
252
+ const result = await processHook(makeContext("background"));
253
+ expect(mockOpenBrowser).not.toHaveBeenCalled();
254
+ expect(mockShowToast).not.toHaveBeenCalled();
255
+ expect(mockApiConfig.queryMode).toBe("viewer");
256
+ expect(result.success).toBe(true);
257
+ });
258
+
259
+ it("queryMode reset: a fallback run does not leak public mode into the next run", async () => {
260
+ // Module state persists across hook invocations in the webview runtime;
261
+ // public_fallback flips queryMode to "user" and a later valid-session
262
+ // run must start back in "viewer".
263
+ mockGetSessionState.mockResolvedValue("expired");
264
+ await processHook(makeContext("background"));
265
+ expect(mockApiConfig.queryMode).toBe("user");
266
+ mockGetSessionState.mockResolvedValue("valid");
267
+ await processHook(makeContext("background"));
268
+ expect(mockApiConfig.queryMode).toBe("viewer");
269
+ });
270
+
271
+ it("sync_on_build:false on a fallback route does not claim 'Authenticated'", async () => {
272
+ mockGetSessionState.mockResolvedValue("expired");
273
+ const ctx = {
274
+ trigger: "background",
275
+ config: { sync_on_build: false },
276
+ project_info: { folder_name: "test", homepage_file: null, lang: "en" },
277
+ } as never;
278
+ const result = await processHook(ctx);
279
+ expect(result.success).toBe(true);
280
+ expect(result.message).not.toContain("Authenticated");
281
+ });
282
+ });
283
+
284
+ describe("binding guard trigger gating", () => {
285
+ it("unbound + background → quiet clean success, NO login window", async () => {
286
+ mockGetConfig.mockResolvedValue({}); // no boundUserName
287
+ mockDetectBoundUser.mockResolvedValue(null);
288
+ const result = await processHook(makeContext("background"));
289
+ expect(mockOpenBrowser).not.toHaveBeenCalled();
290
+ expect(result.success).toBe(true);
291
+ expect(result.message).toContain("No Matters account bound");
292
+ });
293
+
294
+ it("unbound + onboarding_flow → still prompts login (user is present)", async () => {
295
+ mockGetConfig.mockResolvedValue({});
296
+ mockDetectBoundUser.mockResolvedValue(null);
297
+ mockOpenBrowser.mockResolvedValue({ closed: Promise.resolve() });
298
+ await processHook(makeContext("onboarding_flow"));
299
+ expect(mockOpenBrowser).toHaveBeenCalled();
300
+ });
301
+ });
302
+
303
+ describe("mid-sync auth failure (process)", () => {
304
+ beforeEach(() => {
305
+ mockGetSessionState.mockResolvedValue("valid"); // passes pre-flight...
306
+ });
307
+
308
+ it("MattersAuthError during fetch → clean session-expired failure, no Error: nesting", async () => {
309
+ // ...then the server revokes mid-run:
310
+ mockFetchAllArticlesSince.mockRejectedValueOnce(
311
+ new MattersAuthError("TOKEN_INVALID", "Matters rejected the session (TOKEN_INVALID)")
312
+ );
313
+ const result = await processHook(makeContext("background"));
314
+ expect(result.success).toBe(false);
315
+ const failedMsg = String(mockTaskFailed.mock.calls[0][0]);
316
+ expect(failedMsg).toContain("session expired");
317
+ expect(failedMsg).not.toContain("Error:");
318
+ expect(failedMsg).not.toContain("500");
319
+ expect(mockShowToast).toHaveBeenCalledTimes(1); // nudge
320
+ });
321
+
322
+ it("non-auth error during fetch keeps the cause, de-nested (no 'Error:' prefix)", async () => {
323
+ mockFetchAllArticlesSince.mockRejectedValueOnce(
324
+ new Error("GraphQL request failed (502): upstream connect error")
325
+ );
326
+ const result = await processHook(makeContext("background"));
327
+ expect(result.success).toBe(false);
328
+ const failedMsg = String(mockTaskFailed.mock.calls[0][0]);
329
+ expect(failedMsg).toContain("502");
330
+ expect(failedMsg).not.toContain("Error:"); // the de-nesting is the change under test
331
+ });
332
+ });
333
+
334
+ describe("syndicate session gate", () => {
335
+ // One unsyndicated article so execution reaches the session gate: an
336
+ // empty list early-returns "No new articles to syndicate" BEFORE the
337
+ // gate. Both tests still exit before the per-article loop (login fails /
338
+ // fetchUserProfile rejects), so isArticleLive is never reached.
339
+ const SYNDICATE_CONTEXT = {
340
+ deployment: { url: "https://example.com", deployed_at: "2026-06-10T00:00:00Z" },
341
+ articles: [
342
+ {
343
+ title: "A post",
344
+ content: "body",
345
+ url_path: "posts/a-post.html",
346
+ tags: [],
347
+ frontmatter: {},
348
+ },
349
+ ],
350
+ config: {},
351
+ project_info: { folder_name: "test", homepage_file: null, lang: "en" },
352
+ } as never;
353
+
354
+ it("expired session → prompts login before syndicating", async () => {
355
+ mockGetSessionState.mockResolvedValue("expired");
356
+ mockOpenBrowser.mockResolvedValue({ closed: Promise.resolve() });
357
+ const result = await syndicate(SYNDICATE_CONTEXT);
358
+ expect(mockOpenBrowser).toHaveBeenCalled();
359
+ expect(result.success).toBe(false);
360
+ expect(result.message).toContain("Login required");
361
+ });
362
+
363
+ it("login-cancelled path does NOT emit 'Login cancelled' toast (task.failed() is the terminal signal)", async () => {
364
+ // When promptLogin returns false (window closed immediately), the old code
365
+ // emitted a 'Login cancelled' toast — that was deleted. Only the
366
+ // 'Matters login required' persistent toast (Law 2) must fire; task.failed()
367
+ // is the terminal failure signal. No 'Login cancelled' chatty toast.
368
+ mockGetSessionState.mockResolvedValue("expired");
369
+ mockOpenBrowser.mockResolvedValue({ closed: Promise.resolve() }); // closes immediately → loginSuccess=false
370
+ await syndicate(SYNDICATE_CONTEXT);
371
+ const cancelledToast = mockShowToast.mock.calls.find(
372
+ ([opts]: [{ message: string }]) => opts.message?.includes("Login cancelled") || opts.message?.includes("cancelled"),
373
+ );
374
+ expect(cancelledToast).toBeUndefined();
375
+ // The login-required toast still fires (Law 2: blocking state must persist)
376
+ const loginRequiredToast = mockShowToast.mock.calls.find(
377
+ ([opts]: [{ message: string }]) => opts.message?.includes("login required"),
378
+ );
379
+ expect(loginRequiredToast).toBeDefined();
380
+ });
381
+
382
+ it("syndicate() does NOT emit 'Starting Matters syndication...' toast (startTask() is the per-run signal)", async () => {
383
+ // Law 1: one event one home. The startTask() call at the top of syndicate()
384
+ // already registers the task in L1 — a simultaneous 'Starting...' toast
385
+ // is a double-signal that was deleted. Verified absent in any syndicate path.
386
+ mockGetSessionState.mockResolvedValue("expired");
387
+ mockOpenBrowser.mockResolvedValue({ closed: Promise.resolve() }); // short-circuit to login path
388
+ await syndicate(SYNDICATE_CONTEXT);
389
+ const startingToast = mockShowToast.mock.calls.find(
390
+ ([opts]: [{ message: string }]) => opts.message?.includes("Starting Matters syndication"),
391
+ );
392
+ expect(startingToast).toBeUndefined();
393
+ });
394
+
395
+ it("MattersAuthError outside the loop → session-expired publish copy", async () => {
396
+ mockGetSessionState.mockResolvedValue("valid");
397
+ mockGetConfig.mockResolvedValue({ ...BOUND_CONFIG, userName: undefined }); // forces fetchUserProfile
398
+ mockFetchUserProfile.mockRejectedValueOnce(
399
+ new MattersAuthError("TOKEN_INVALID", "Matters rejected the session (TOKEN_INVALID)")
400
+ );
401
+ const result = await syndicate(SYNDICATE_CONTEXT);
402
+ expect(result.success).toBe(false);
403
+ expect(result.message).toContain("session expired, log in again to publish.");
404
+ });
405
+
406
+ // Task 2 watchdog fix: task.awaiting() must be called BEFORE promptLogin()
407
+ // (which calls openBrowser) so the Rust inactivity watchdog knows the hook
408
+ // is legitimately waiting for the user, not stalled.
409
+ it("login-awaiting: task.awaiting() is called BEFORE openBrowser (promptLogin) during syndicate login", async () => {
410
+ mockGetSessionState.mockResolvedValue("expired");
411
+ mockOpenBrowser.mockResolvedValue({ closed: Promise.resolve() }); // closes immediately
412
+ await syndicate(SYNDICATE_CONTEXT);
413
+
414
+ // Spec: quiet label, no Awaiting pulse — awaiting is called with an empty
415
+ // venue string so the UI shows the directive text as a quiet label rather
416
+ // than "Waiting for you to [x] in [venue]". The core invariant (called
417
+ // BEFORE openBrowser so the Rust watchdog marks the hook Awaiting) is
418
+ // unchanged.
419
+ expect(mockTaskAwaiting).toHaveBeenCalledWith("Connect to Matters", "", "cancel");
420
+ expect(mockOpenBrowser).toHaveBeenCalled();
421
+
422
+ // Order invariant: awaiting must precede openBrowser so the watchdog
423
+ // marks the hook as Awaiting before the browser window opens.
424
+ const awaitingOrder = mockTaskAwaiting.mock.invocationCallOrder[0];
425
+ const openBrowserOrder = mockOpenBrowser.mock.invocationCallOrder[0];
426
+ expect(awaitingOrder).toBeLessThan(openBrowserOrder);
427
+ });
428
+ });
429
+
430
+ describe("process hook login-awaiting (Task 2 watchdog fix)", () => {
431
+ const UNBOUND_CONTEXT = {
432
+ trigger: "onboarding_flow",
433
+ config: { sync_on_build: true },
434
+ project_info: { folder_name: "test", homepage_file: null, lang: "en" },
435
+ } as never;
436
+
437
+ beforeEach(() => {
438
+ // Simulate an unbound project requiring login (fresh project path).
439
+ mockGetConfig.mockResolvedValue({}); // no boundUserName
440
+ mockDetectBoundUser.mockResolvedValue(null); // no existing articles
441
+ // Browser closes immediately — login fails; tests only care about
442
+ // the call order, not the success outcome.
443
+ mockOpenBrowser.mockResolvedValue({ closed: Promise.resolve() });
444
+ mockGetAccessToken.mockResolvedValue(null);
445
+ mockTaskAwaiting.mockResolvedValue(undefined);
446
+ });
447
+
448
+ // task.awaiting() must precede openBrowser (promptLogin) in the process
449
+ // hook's fresh-bind path so the watchdog is aware the hook is Awaiting.
450
+ it("login-awaiting: task.awaiting() is called BEFORE openBrowser (promptLogin) in fresh-bind login", async () => {
451
+ await processHook(UNBOUND_CONTEXT);
452
+
453
+ // Spec: quiet label — same empty venue, same order invariant (awaiting
454
+ // before openBrowser so the Rust watchdog marks the hook Awaiting).
455
+ expect(mockTaskAwaiting).toHaveBeenCalledWith("Connect to Matters", "", "cancel");
456
+ expect(mockOpenBrowser).toHaveBeenCalled();
457
+
458
+ const awaitingOrder = mockTaskAwaiting.mock.invocationCallOrder[0];
459
+ const openBrowserOrder = mockOpenBrowser.mock.invocationCallOrder[0];
460
+ expect(awaitingOrder).toBeLessThan(openBrowserOrder);
461
+ });
462
+ });