@symbiosis-lab/moss-plugin-matters 1.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +88 -0
- package/README.md +18 -0
- package/assets/icon.svg +1 -0
- package/assets/manifest.json +36 -0
- package/codegen.ts +26 -0
- package/e2e/moss-cli.test.ts +338 -0
- package/features/api/fetch-articles.feature +39 -0
- package/features/auth/wallet-auth.feature +27 -0
- package/features/download/retry-logic.feature +36 -0
- package/features/download/self-correcting.feature +83 -0
- package/features/download/worker-pool.feature +29 -0
- package/features/social/fetch-social-data.feature +40 -0
- package/features/steps/api.steps.ts +180 -0
- package/features/steps/download.steps.ts +423 -0
- package/features/steps/incremental-sync.steps.ts +105 -0
- package/features/steps/self-correcting.steps.ts +575 -0
- package/features/steps/social.steps.ts +257 -0
- package/features/steps/syndication.steps.ts +264 -0
- package/features/steps/wallet-auth.steps.ts +185 -0
- package/features/sync/article-sync.feature +49 -0
- package/features/sync/homepage-grid.feature +43 -0
- package/features/sync/incremental-sync.feature +28 -0
- package/features/syndication/create-draft.feature +35 -0
- package/package.json +58 -0
- package/src/__generated__/schema.graphql +4289 -0
- package/src/__generated__/types.ts +5355 -0
- package/src/__tests__/api.test.ts +678 -0
- package/src/__tests__/auth-route.test.ts +38 -0
- package/src/__tests__/auth-routing.test.ts +462 -0
- package/src/__tests__/auto-detect.test.ts +412 -0
- package/src/__tests__/binding-guard.test.ts +256 -0
- package/src/__tests__/config.test.ts +212 -0
- package/src/__tests__/converter.test.ts +289 -0
- package/src/__tests__/credential.test.ts +332 -0
- package/src/__tests__/domain.test.ts +341 -0
- package/src/__tests__/downloader.test.ts +679 -0
- package/src/__tests__/folder-detection.test.ts +289 -0
- package/src/__tests__/force-fresh-login.test.ts +236 -0
- package/src/__tests__/main.test.ts +2437 -0
- package/src/__tests__/progress.test.ts +93 -0
- package/src/__tests__/session.test.ts +375 -0
- package/src/__tests__/social-integration.test.ts +386 -0
- package/src/__tests__/social-sync-logic.test.ts +107 -0
- package/src/__tests__/social.test.ts +788 -0
- package/src/__tests__/sync.test.ts +1273 -0
- package/src/__tests__/syndication-toast-law.test.ts +649 -0
- package/src/__tests__/syndication.test.ts +125 -0
- package/src/__tests__/test-profile-escape.test.ts +209 -0
- package/src/__tests__/url-detect.test.ts +79 -0
- package/src/__tests__/utils.test.ts +226 -0
- package/src/api.ts +1366 -0
- package/src/auth-route.ts +38 -0
- package/src/config.ts +80 -0
- package/src/converter.ts +305 -0
- package/src/credential.ts +329 -0
- package/src/domain.ts +183 -0
- package/src/downloader.ts +761 -0
- package/src/main.ts +2092 -0
- package/src/progress.ts +89 -0
- package/src/queries/user.graphql +85 -0
- package/src/queries/viewer.graphql +104 -0
- package/src/social.ts +413 -0
- package/src/sync.ts +818 -0
- package/src/types.ts +477 -0
- package/src/url-detect.ts +49 -0
- package/src/utils.ts +305 -0
- package/test-fixtures/syndication-test-site/input/index.md +8 -0
- package/test-fixtures/syndication-test-site/input/posts/rich-test-article.md +90 -0
- package/test-helpers/TEST_ACCOUNT.md +151 -0
- package/test-helpers/api-client.ts +252 -0
- package/test-helpers/fixtures/articles.ts +147 -0
- package/test-helpers/wallet-auth.ts +305 -0
- package/test-setup/e2e.ts +93 -0
- package/tsconfig.json +23 -0
- package/vitest.config.ts +39 -0
|
@@ -0,0 +1,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
|
+
});
|