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