@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,649 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Law invariants for the Matters syndication toast migration.
|
|
3
|
+
*
|
|
4
|
+
* These tests assert that the syndication path respects the feedback
|
|
5
|
+
* design system: chatty per-step toasts → L1 PanelTask signals;
|
|
6
|
+
* only one terminal L3 ack per run; errors/waits never auto-fade.
|
|
7
|
+
*/
|
|
8
|
+
// @vitest-environment node
|
|
9
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
10
|
+
import type { ArticleInfo, SyndicateContext } from "../types";
|
|
11
|
+
|
|
12
|
+
// ── Mocks ────────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
const mockShowToast = vi.fn().mockResolvedValue(undefined);
|
|
15
|
+
const mockDismissToast = vi.fn().mockResolvedValue(undefined);
|
|
16
|
+
const mockTaskProgress = vi.fn().mockResolvedValue(undefined);
|
|
17
|
+
const mockTaskAwaiting = vi.fn().mockResolvedValue(undefined);
|
|
18
|
+
const mockTaskAdvise = vi.fn().mockResolvedValue(undefined);
|
|
19
|
+
const mockTaskSucceeded = vi.fn().mockResolvedValue(undefined);
|
|
20
|
+
const mockTaskFailed = vi.fn().mockResolvedValue(undefined);
|
|
21
|
+
const mockTaskCancelled = vi.fn().mockResolvedValue(undefined);
|
|
22
|
+
const mockStartTask = vi.fn().mockResolvedValue({
|
|
23
|
+
id: "42",
|
|
24
|
+
progress: mockTaskProgress,
|
|
25
|
+
awaiting: mockTaskAwaiting,
|
|
26
|
+
advise: mockTaskAdvise,
|
|
27
|
+
succeeded: mockTaskSucceeded,
|
|
28
|
+
failed: mockTaskFailed,
|
|
29
|
+
cancelled: mockTaskCancelled,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// mockOpenBrowser is reset per test to control whether the "browser" ever
|
|
33
|
+
// closes. For tests where fetchDraft returns published, we use a never-
|
|
34
|
+
// resolving handle (the publish poll exits via draft.article). For tests
|
|
35
|
+
// where fetchDraft stays unpublished (timeout path), we use an immediately-
|
|
36
|
+
// resolving handle so the browser-close branch exits the poll loop before it
|
|
37
|
+
// runs for the full 600s of Date.now()-based wall-clock time (sleep is mocked
|
|
38
|
+
// to no-op, making a tight busy loop that OOMs otherwise).
|
|
39
|
+
const mockOpenBrowser = vi.fn().mockResolvedValue({ closed: new Promise<void>(() => {}) });
|
|
40
|
+
|
|
41
|
+
vi.mock("@symbiosis-lab/moss-api", () => ({
|
|
42
|
+
getPluginCookie: vi.fn(),
|
|
43
|
+
httpPost: vi.fn(),
|
|
44
|
+
httpGet: vi.fn(),
|
|
45
|
+
fetchUrl: vi.fn(),
|
|
46
|
+
downloadAsset: vi.fn(),
|
|
47
|
+
readFile: vi.fn(),
|
|
48
|
+
writeFile: vi.fn(),
|
|
49
|
+
listFiles: vi.fn().mockResolvedValue([]),
|
|
50
|
+
showToast: (...args: unknown[]) => mockShowToast(...args),
|
|
51
|
+
dismissToast: (...args: unknown[]) => mockDismissToast(...args),
|
|
52
|
+
openBrowser: (...args: unknown[]) => mockOpenBrowser(...args),
|
|
53
|
+
closeBrowser: vi.fn().mockResolvedValue(undefined),
|
|
54
|
+
readPluginFile: vi.fn().mockResolvedValue("{}"),
|
|
55
|
+
writePluginFile: vi.fn().mockResolvedValue(undefined),
|
|
56
|
+
pluginFileExists: vi.fn().mockResolvedValue(false),
|
|
57
|
+
// REQUIRED: syndicate() calls applyTestProfileEscapeHatch() which calls
|
|
58
|
+
// getPluginEnvVar('MOSS_MATTERS_TEST_PROFILE'). Without this mock the
|
|
59
|
+
// test throws 'getPluginEnvVar is not a function' before reaching any
|
|
60
|
+
// toast assertion.
|
|
61
|
+
getPluginEnvVar: vi.fn().mockResolvedValue(undefined),
|
|
62
|
+
startTask: (...args: unknown[]) => mockStartTask(...args),
|
|
63
|
+
// REQUIRED: utils.ts calls setMessageContext at module load time.
|
|
64
|
+
// Without this mock the worker crashes on import before any test runs.
|
|
65
|
+
setMessageContext: vi.fn(),
|
|
66
|
+
sendMessage: vi.fn().mockResolvedValue(undefined),
|
|
67
|
+
reportError: vi.fn().mockResolvedValue(undefined),
|
|
68
|
+
reportComplete: vi.fn().mockResolvedValue(undefined),
|
|
69
|
+
emitEvent: vi.fn().mockResolvedValue(undefined),
|
|
70
|
+
// R7: waitForPublishOrClose calls onEvent('browser-url-changed').
|
|
71
|
+
// Without this mock the rewritten function throws on import.
|
|
72
|
+
// Returns a no-op unlisten function; the URL path is never exercised in
|
|
73
|
+
// these law tests (browser-close / poll are the drivers here).
|
|
74
|
+
onEvent: vi.fn().mockResolvedValue(vi.fn()),
|
|
75
|
+
// clearPluginCookies — called by promptLogin() before opening the browser.
|
|
76
|
+
clearPluginCookies: vi.fn().mockResolvedValue(undefined),
|
|
77
|
+
}));
|
|
78
|
+
|
|
79
|
+
vi.mock("../config", () => ({
|
|
80
|
+
getConfig: vi.fn().mockResolvedValue({ userName: "guo" }),
|
|
81
|
+
saveConfig: vi.fn().mockResolvedValue(undefined),
|
|
82
|
+
}));
|
|
83
|
+
|
|
84
|
+
vi.mock("../credential", () => ({
|
|
85
|
+
clearTokenCache: vi.fn(),
|
|
86
|
+
loadStoredToken: vi.fn().mockResolvedValue(null),
|
|
87
|
+
saveStoredToken: vi.fn().mockResolvedValue(undefined),
|
|
88
|
+
clearStoredToken: vi.fn().mockResolvedValue(undefined),
|
|
89
|
+
getSessionState: vi.fn().mockResolvedValue("valid"),
|
|
90
|
+
shouldNudgeSessionExpired: vi.fn().mockResolvedValue(false),
|
|
91
|
+
markSessionInvalidated: vi.fn().mockResolvedValue(undefined),
|
|
92
|
+
authHeaderToken: vi.fn().mockResolvedValue("tok"),
|
|
93
|
+
captureLogin: vi.fn().mockResolvedValue("tok"),
|
|
94
|
+
prepareWebviewAuth: vi.fn().mockResolvedValue(undefined),
|
|
95
|
+
beginFreshLogin: vi.fn().mockResolvedValue(undefined),
|
|
96
|
+
}));
|
|
97
|
+
|
|
98
|
+
vi.mock("../api", () => ({
|
|
99
|
+
createDraft: vi.fn().mockResolvedValue({
|
|
100
|
+
id: "d1",
|
|
101
|
+
title: "Post",
|
|
102
|
+
content: "",
|
|
103
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
104
|
+
publishState: "unpublished",
|
|
105
|
+
article: { id: "a1", shortHash: "abc", slug: "post" }, // immediately published
|
|
106
|
+
}),
|
|
107
|
+
fetchDraft: vi.fn().mockResolvedValue({
|
|
108
|
+
id: "d1",
|
|
109
|
+
title: "Post",
|
|
110
|
+
content: "",
|
|
111
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
112
|
+
publishState: "published",
|
|
113
|
+
article: { id: "a1", shortHash: "abc", slug: "post" },
|
|
114
|
+
}),
|
|
115
|
+
uploadCoverByUrl: vi.fn().mockResolvedValue("asset-1"),
|
|
116
|
+
uploadEmbedByUrl: vi.fn().mockResolvedValue("https://cdn/img.jpg"),
|
|
117
|
+
fetchUserProfile: vi.fn().mockResolvedValue({ userName: "guo", displayName: "Guo" }),
|
|
118
|
+
fetchAllArticlesSince: vi.fn().mockResolvedValue({ articles: [], userName: "guo" }),
|
|
119
|
+
fetchAllDraftsSince: vi.fn().mockResolvedValue([]),
|
|
120
|
+
fetchAllCollections: vi.fn().mockResolvedValue([]),
|
|
121
|
+
fetchArticleComments: vi.fn().mockResolvedValue({ comments: [], donations: [], appreciations: [] }),
|
|
122
|
+
fetchAllArticleCommentCounts: vi.fn().mockResolvedValue([]),
|
|
123
|
+
MattersAuthError: class MattersAuthError extends Error {},
|
|
124
|
+
apiConfig: { queryMode: "viewer", testUserName: "Matty", endpoint: "https://server.matters.town/graphql" },
|
|
125
|
+
}));
|
|
126
|
+
|
|
127
|
+
vi.mock("../domain", () => ({
|
|
128
|
+
initializeDomain: vi.fn().mockResolvedValue(undefined),
|
|
129
|
+
getDomain: vi.fn().mockReturnValue("matters.town"),
|
|
130
|
+
loginUrl: vi.fn().mockReturnValue("https://matters.town/login"),
|
|
131
|
+
draftUrl: vi.fn().mockImplementation((id: string) => `https://matters.town/drafts/${id}`),
|
|
132
|
+
articleUrl: vi.fn().mockImplementation((_u: string, slug: string, hash: string) => `https://matters.town/@guo/${slug}-${hash}`),
|
|
133
|
+
isMattersUrl: vi.fn().mockImplementation((url: string) => url.includes("matters.town")),
|
|
134
|
+
}));
|
|
135
|
+
|
|
136
|
+
vi.mock("../sync", () => ({
|
|
137
|
+
detectBoundUser: vi.fn().mockResolvedValue({ userName: "guo" }),
|
|
138
|
+
syncToLocalFiles: vi.fn().mockResolvedValue({ result: { created: 0, updated: 0, skipped: 0, errors: [] }, articlePathMap: new Map() }),
|
|
139
|
+
scanLocalArticles: vi.fn().mockResolvedValue([]),
|
|
140
|
+
}));
|
|
141
|
+
|
|
142
|
+
vi.mock("../utils", () => ({
|
|
143
|
+
reportError: vi.fn().mockResolvedValue(undefined),
|
|
144
|
+
setCurrentHookName: vi.fn(),
|
|
145
|
+
sleep: vi.fn().mockResolvedValue(undefined),
|
|
146
|
+
}));
|
|
147
|
+
|
|
148
|
+
vi.mock("../converter", () => ({
|
|
149
|
+
parseFrontmatter: vi.fn().mockReturnValue(null),
|
|
150
|
+
regenerateFrontmatter: vi.fn().mockReturnValue(""),
|
|
151
|
+
}));
|
|
152
|
+
|
|
153
|
+
vi.mock("../progress", () => ({
|
|
154
|
+
overallProgress: vi.fn().mockReturnValue(50),
|
|
155
|
+
}));
|
|
156
|
+
|
|
157
|
+
vi.mock("../social", () => ({
|
|
158
|
+
loadSocialData: vi.fn().mockResolvedValue({ articles: {} }),
|
|
159
|
+
saveSocialData: vi.fn().mockResolvedValue(undefined),
|
|
160
|
+
mergeSocialData: vi.fn().mockReturnValue({ articles: {} }),
|
|
161
|
+
reconcileLegacySocialData: vi.fn().mockResolvedValue(undefined),
|
|
162
|
+
}));
|
|
163
|
+
|
|
164
|
+
vi.mock("../downloader", () => ({
|
|
165
|
+
downloadMediaAndUpdate: vi.fn().mockResolvedValue(undefined),
|
|
166
|
+
rewriteAllInternalLinks: vi.fn().mockResolvedValue({ linksRewritten: 0 }),
|
|
167
|
+
}));
|
|
168
|
+
|
|
169
|
+
vi.mock("../auth-route", () => ({
|
|
170
|
+
resolveAuthRoute: vi.fn().mockResolvedValue({ kind: "skip" }),
|
|
171
|
+
isUserPresent: vi.fn().mockResolvedValue(true),
|
|
172
|
+
}));
|
|
173
|
+
|
|
174
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
function makeUnsyncedArticle(overrides: Partial<ArticleInfo> = {}): ArticleInfo {
|
|
177
|
+
return {
|
|
178
|
+
source_path: "posts/test.md",
|
|
179
|
+
title: "Test Article",
|
|
180
|
+
content: "# Test\n\nBody.",
|
|
181
|
+
frontmatter: {}, // no `syndicated:` key → will be queued
|
|
182
|
+
url_path: "posts/test/",
|
|
183
|
+
tags: [],
|
|
184
|
+
...overrides,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function makeSyndicateContext(articles: ArticleInfo[]): SyndicateContext {
|
|
189
|
+
return {
|
|
190
|
+
deployment: { url: "https://example.com", deployed_at: "2026-06-14T00:00:00Z" },
|
|
191
|
+
articles,
|
|
192
|
+
config: {},
|
|
193
|
+
project_info: { folder_name: "test", homepage_file: null, lang: "en" },
|
|
194
|
+
} as unknown as SyndicateContext;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function restartMockTask() {
|
|
198
|
+
mockStartTask.mockResolvedValue({
|
|
199
|
+
id: "42",
|
|
200
|
+
progress: mockTaskProgress,
|
|
201
|
+
awaiting: mockTaskAwaiting,
|
|
202
|
+
advise: mockTaskAdvise,
|
|
203
|
+
succeeded: mockTaskSucceeded,
|
|
204
|
+
failed: mockTaskFailed,
|
|
205
|
+
cancelled: mockTaskCancelled,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
import { syndicate } from "../main";
|
|
212
|
+
import { fetchDraft } from "../api";
|
|
213
|
+
import { getSessionState } from "../credential";
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Shared "happy path" setup: fetchDraft returns published draft.
|
|
217
|
+
* Used by every describe block that tests the published/success path.
|
|
218
|
+
*
|
|
219
|
+
* CRITICAL: vi.clearAllMocks() in beforeEach does NOT reset mock implementations
|
|
220
|
+
* set by mockResolvedValue() — only mock call history. Any describe block that
|
|
221
|
+
* changes fetchDraft to "unpublished" (Law 2 timeout tests) must explicitly
|
|
222
|
+
* restore it here to avoid leaking the unpublished mock into later tests.
|
|
223
|
+
* Without this reset, "Law 3" tests would get an unpublished fetchDraft mock
|
|
224
|
+
* combined with a never-resolving openBrowser.closed promise, causing a
|
|
225
|
+
* tight waitForPublishOrClose busy-loop that runs for 600 real seconds and OOMs.
|
|
226
|
+
*/
|
|
227
|
+
function resetFetchDraftToPublished() {
|
|
228
|
+
vi.mocked(fetchDraft).mockResolvedValue({
|
|
229
|
+
id: "d1",
|
|
230
|
+
title: "Post",
|
|
231
|
+
content: "",
|
|
232
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
233
|
+
publishState: "published",
|
|
234
|
+
article: { id: "a1", shortHash: "abc", slug: "post" },
|
|
235
|
+
} as never);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* vi.clearAllMocks() clears call history but NOT mockResolvedValue
|
|
240
|
+
* implementations, so a "Law 2 login-required" test that sets
|
|
241
|
+
* getSessionState → 'expired' leaks that into any later describe block that
|
|
242
|
+
* assumes the default 'valid' session. The "Law 3 — exactly ONE showToast"
|
|
243
|
+
* assertion is the canary: a leaked 'expired' adds a login-required toast and
|
|
244
|
+
* the count becomes 2. Reset defensively in every block that relies on the
|
|
245
|
+
* default valid session, mirroring resetFetchDraftToPublished().
|
|
246
|
+
*/
|
|
247
|
+
function resetGetSessionStateToValid() {
|
|
248
|
+
vi.mocked(getSessionState).mockResolvedValue("valid" as never);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
describe("Law 1 — no chatty per-step toasts during syndication", () => {
|
|
252
|
+
beforeEach(() => {
|
|
253
|
+
vi.clearAllMocks();
|
|
254
|
+
restartMockTask();
|
|
255
|
+
resetFetchDraftToPublished();
|
|
256
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, status: 200 } as unknown as Response));
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
afterEach(() => {
|
|
260
|
+
vi.unstubAllGlobals();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("does NOT emit 'Starting Matters syndication...' toast (line 894 deleted)", async () => {
|
|
264
|
+
const ctx = makeSyndicateContext([makeUnsyncedArticle()]);
|
|
265
|
+
await syndicate(ctx);
|
|
266
|
+
const startingMsg = mockShowToast.mock.calls.find(
|
|
267
|
+
([opts]: [{ message: string }]) => opts.message?.includes("Starting Matters syndication"),
|
|
268
|
+
);
|
|
269
|
+
expect(startingMsg).toBeUndefined();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("does NOT emit 'Creating draft:' toast per article (line 1080 deleted)", async () => {
|
|
273
|
+
const ctx = makeSyndicateContext([
|
|
274
|
+
makeUnsyncedArticle({ title: "Alpha" }),
|
|
275
|
+
makeUnsyncedArticle({ title: "Beta", source_path: "posts/beta.md", url_path: "posts/beta/" }),
|
|
276
|
+
]);
|
|
277
|
+
await syndicate(ctx);
|
|
278
|
+
const creatingCalls = mockShowToast.mock.calls.filter(
|
|
279
|
+
([opts]: [{ message: string }]) => opts.message?.startsWith("Creating draft:"),
|
|
280
|
+
);
|
|
281
|
+
expect(creatingCalls).toHaveLength(0);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("does NOT emit 'Draft created!' toast per article (line 1186 replaced by task.awaiting)", async () => {
|
|
285
|
+
const ctx = makeSyndicateContext([makeUnsyncedArticle()]);
|
|
286
|
+
await syndicate(ctx);
|
|
287
|
+
const draftCreatedCalls = mockShowToast.mock.calls.filter(
|
|
288
|
+
([opts]: [{ message: string }]) => opts.message?.includes("Draft created"),
|
|
289
|
+
);
|
|
290
|
+
expect(draftCreatedCalls).toHaveLength(0);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("does NOT emit 'Published to Matters!' per article (N → 1 terminal ack)", async () => {
|
|
294
|
+
const ctx = makeSyndicateContext([
|
|
295
|
+
makeUnsyncedArticle({ title: "Alpha" }),
|
|
296
|
+
makeUnsyncedArticle({ title: "Beta", source_path: "posts/beta.md", url_path: "posts/beta/" }),
|
|
297
|
+
]);
|
|
298
|
+
await syndicate(ctx);
|
|
299
|
+
const publishedCalls = mockShowToast.mock.calls.filter(
|
|
300
|
+
([opts]: [{ message: string }]) => opts.message === "Published to Matters!",
|
|
301
|
+
);
|
|
302
|
+
expect(publishedCalls).toHaveLength(0);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
describe("Law 1 — task.awaiting() replaces 'Draft created!' toast, fires AFTER browser opens", () => {
|
|
307
|
+
beforeEach(() => {
|
|
308
|
+
vi.clearAllMocks();
|
|
309
|
+
restartMockTask();
|
|
310
|
+
resetFetchDraftToPublished();
|
|
311
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, status: 200 } as unknown as Response));
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
afterEach(() => {
|
|
315
|
+
vi.unstubAllGlobals();
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("calls task.awaiting with 'publish the draft' directive and 'cancel' escape", async () => {
|
|
319
|
+
const ctx = makeSyndicateContext([makeUnsyncedArticle()]);
|
|
320
|
+
await syndicate(ctx);
|
|
321
|
+
expect(mockTaskAwaiting).toHaveBeenCalledWith(
|
|
322
|
+
"publish the draft",
|
|
323
|
+
"Matters editor",
|
|
324
|
+
"cancel",
|
|
325
|
+
);
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
describe("Law 2 — draft-timeout becomes a NeedsAction advisory with a link (not a toast)", () => {
|
|
330
|
+
beforeEach(() => {
|
|
331
|
+
vi.clearAllMocks();
|
|
332
|
+
restartMockTask();
|
|
333
|
+
// For the "timeout" path: fetchDraft returns NOT published.
|
|
334
|
+
// The poll loop in waitForPublishOrClose would run for 600 real seconds
|
|
335
|
+
// (sleep is no-op so Date.now() advances in real time) — that OOMs.
|
|
336
|
+
// Fix: make the browser "close" immediately so the browserClosed branch
|
|
337
|
+
// exits the loop after the first sleep. The article then returns null
|
|
338
|
+
// (browser-close path) which lands us at the same draft-saved code path
|
|
339
|
+
// as a wall-clock timeout.
|
|
340
|
+
mockOpenBrowser.mockResolvedValue({ closed: Promise.resolve() });
|
|
341
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, status: 200 } as unknown as Response));
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
afterEach(() => {
|
|
345
|
+
vi.unstubAllGlobals();
|
|
346
|
+
// Restore default (never-resolving) for other test groups.
|
|
347
|
+
mockOpenBrowser.mockResolvedValue({ closed: new Promise<void>(() => {}) });
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("does NOT emit 'Draft saved - publish when ready' toast on timeout", async () => {
|
|
351
|
+
const { fetchDraft } = await import("../api");
|
|
352
|
+
vi.mocked(fetchDraft).mockResolvedValue({
|
|
353
|
+
id: "d1",
|
|
354
|
+
title: "Post",
|
|
355
|
+
content: "",
|
|
356
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
357
|
+
publishState: "unpublished",
|
|
358
|
+
article: null,
|
|
359
|
+
} as never);
|
|
360
|
+
|
|
361
|
+
const ctx = makeSyndicateContext([makeUnsyncedArticle({ source_path: "posts/alpha.md" })]);
|
|
362
|
+
await syndicate(ctx);
|
|
363
|
+
|
|
364
|
+
const timeoutToast = mockShowToast.mock.calls.find(
|
|
365
|
+
([opts]: [{ message: string }]) => opts.message?.includes("Draft saved"),
|
|
366
|
+
);
|
|
367
|
+
expect(timeoutToast).toBeUndefined();
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("files a NeedsAction task advisory with Link action on timeout", async () => {
|
|
371
|
+
const { fetchDraft } = await import("../api");
|
|
372
|
+
vi.mocked(fetchDraft).mockResolvedValue({
|
|
373
|
+
id: "d1",
|
|
374
|
+
title: "Post",
|
|
375
|
+
content: "",
|
|
376
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
377
|
+
publishState: "unpublished",
|
|
378
|
+
article: null,
|
|
379
|
+
} as never);
|
|
380
|
+
|
|
381
|
+
const ctx = makeSyndicateContext([makeUnsyncedArticle({ source_path: "posts/alpha.md" })]);
|
|
382
|
+
await syndicate(ctx);
|
|
383
|
+
|
|
384
|
+
// advise() accumulates on the handle; it flushes at task.succeeded() after
|
|
385
|
+
// the loop, not at the per-article return. The SDK buffers advisories.
|
|
386
|
+
const adviseCalls = mockTaskAdvise.mock.calls;
|
|
387
|
+
const timeoutAdvisory = adviseCalls.find(
|
|
388
|
+
([adv]: [{ severity: string; action: unknown }]) =>
|
|
389
|
+
adv.severity === "NeedsAction" &&
|
|
390
|
+
typeof (adv.action as { Link?: unknown })?.Link === "object",
|
|
391
|
+
);
|
|
392
|
+
expect(timeoutAdvisory).toBeDefined();
|
|
393
|
+
const adv = timeoutAdvisory![0] as {
|
|
394
|
+
scope: string;
|
|
395
|
+
severity: string;
|
|
396
|
+
what: string;
|
|
397
|
+
action: { Link: { href: string; label: string } };
|
|
398
|
+
};
|
|
399
|
+
expect(adv.scope).toBe("Remote");
|
|
400
|
+
expect(adv.what).toContain("Draft saved");
|
|
401
|
+
expect(adv.action.Link.href).toContain("matters.town/drafts");
|
|
402
|
+
expect(adv.action.Link.label).toBe("Open draft");
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("Law 2b — #808: timed-out/closed draft (published=0, draftsCreated=1) fires NO success toast and task.succeeded receives 0", async () => {
|
|
406
|
+
// Regression guard for #808: a draft that was created but never published
|
|
407
|
+
// (user closed the browser / timed out) must NOT count as a syndication
|
|
408
|
+
// success. syndicatedCount MUST be 0, not 1.
|
|
409
|
+
const { fetchDraft } = await import("../api");
|
|
410
|
+
vi.mocked(fetchDraft).mockResolvedValue({
|
|
411
|
+
id: "d1",
|
|
412
|
+
title: "Post",
|
|
413
|
+
content: "",
|
|
414
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
415
|
+
publishState: "unpublished",
|
|
416
|
+
article: null,
|
|
417
|
+
} as never);
|
|
418
|
+
|
|
419
|
+
const ctx = makeSyndicateContext([makeUnsyncedArticle({ source_path: "posts/alpha.md" })]);
|
|
420
|
+
await syndicate(ctx);
|
|
421
|
+
|
|
422
|
+
// No success toast should fire when nothing was actually published.
|
|
423
|
+
expect(mockShowToast).not.toHaveBeenCalledWith(
|
|
424
|
+
expect.objectContaining({ message: expect.stringMatching(/Syndicated \d+ article/) }),
|
|
425
|
+
);
|
|
426
|
+
// task.succeeded is still called (the run itself succeeded), but MUST
|
|
427
|
+
// receive 0 — not the draftsCreated count. Calling with (undefined, 0)
|
|
428
|
+
// means "completed with zero syndicated", which is correct.
|
|
429
|
+
expect(mockTaskSucceeded).toHaveBeenCalledWith(undefined, 0);
|
|
430
|
+
// The NeedsAction advisory IS still filed (existing Law 2 assertions above cover this).
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("Law 2c — #808 mixed-batch: 1 published + 1 timed-out → toast 'Syndicated 1 article', task.succeeded receives 1", async () => {
|
|
434
|
+
// Two articles: article-1 is published (fetchDraft poll returns article non-null),
|
|
435
|
+
// article-2 times out (browser closes before the poll can confirm, article null).
|
|
436
|
+
// Expected: syndicatedCount = 1, toast matches /Syndicated 1 article/ (NOT "Syndicated 2").
|
|
437
|
+
//
|
|
438
|
+
// NOTE on waitForPublishOrClose mechanics: when `closed` is already resolved,
|
|
439
|
+
// the browser-close branch fires AFTER the first `sleep()` (via microtask), so
|
|
440
|
+
// `fetchDraft` is never called inside the poll loop — the function returns null.
|
|
441
|
+
// For article-1 to register as published, `closed` must NOT resolve before
|
|
442
|
+
// `fetchDraft` is polled → use a never-resolving promise so the poll runs once.
|
|
443
|
+
// For article-2 (timeout path) `closed` resolves immediately → loop exits null.
|
|
444
|
+
const { fetchDraft } = await import("../api");
|
|
445
|
+
vi.mocked(fetchDraft)
|
|
446
|
+
.mockResolvedValueOnce({
|
|
447
|
+
id: "d1",
|
|
448
|
+
title: "Post One",
|
|
449
|
+
content: "",
|
|
450
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
451
|
+
publishState: "published",
|
|
452
|
+
article: { id: "a1", shortHash: "abc", slug: "post-one" },
|
|
453
|
+
} as never)
|
|
454
|
+
.mockResolvedValueOnce({
|
|
455
|
+
id: "d2",
|
|
456
|
+
title: "Post Two",
|
|
457
|
+
content: "",
|
|
458
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
459
|
+
publishState: "unpublished",
|
|
460
|
+
article: null,
|
|
461
|
+
} as never);
|
|
462
|
+
|
|
463
|
+
// Article-1: never-resolving closed → poll fires → fetchDraft[0] returns published.
|
|
464
|
+
// Article-2: immediately-resolving closed → browser-close branch exits → null.
|
|
465
|
+
mockOpenBrowser
|
|
466
|
+
.mockResolvedValueOnce({ closed: new Promise<void>(() => {}) })
|
|
467
|
+
.mockResolvedValueOnce({ closed: Promise.resolve() });
|
|
468
|
+
|
|
469
|
+
const ctx = makeSyndicateContext([
|
|
470
|
+
makeUnsyncedArticle({ title: "Post One", source_path: "posts/post-one.md", url_path: "posts/post-one/" }),
|
|
471
|
+
makeUnsyncedArticle({ title: "Post Two", source_path: "posts/post-two.md", url_path: "posts/post-two/" }),
|
|
472
|
+
]);
|
|
473
|
+
await syndicate(ctx);
|
|
474
|
+
|
|
475
|
+
// Must fire exactly one success toast matching "Syndicated 1 article" (not "2 articles").
|
|
476
|
+
const successToastCalls = mockShowToast.mock.calls.filter(
|
|
477
|
+
([opts]: [{ variant?: string }]) => opts.variant === "success",
|
|
478
|
+
);
|
|
479
|
+
expect(successToastCalls).toHaveLength(1);
|
|
480
|
+
const [[opts]] = successToastCalls as [[{ message: string; variant: string }]];
|
|
481
|
+
expect(opts.message).toMatch(/Syndicated 1 article/);
|
|
482
|
+
expect(opts.message).not.toMatch(/Syndicated 2/);
|
|
483
|
+
|
|
484
|
+
// task.succeeded must receive exactly 1.
|
|
485
|
+
expect(mockTaskSucceeded).toHaveBeenCalledWith(undefined, 1);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("prepareWebviewAuth is called BEFORE the draft-room openBrowser on the unpublished-draft path", async () => {
|
|
489
|
+
// Drive the unpublished-draft → browser-close path so openBrowser(draftPageUrl) is reached.
|
|
490
|
+
// createDraft returns unpublished (no article); browser closes immediately.
|
|
491
|
+
const { fetchDraft: fd, createDraft } = await import("../api");
|
|
492
|
+
vi.mocked(createDraft).mockResolvedValue({
|
|
493
|
+
id: "d1",
|
|
494
|
+
title: "Post",
|
|
495
|
+
content: "",
|
|
496
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
497
|
+
publishState: "unpublished",
|
|
498
|
+
article: null,
|
|
499
|
+
} as never);
|
|
500
|
+
vi.mocked(fd).mockResolvedValue({
|
|
501
|
+
id: "d1",
|
|
502
|
+
title: "Post",
|
|
503
|
+
content: "",
|
|
504
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
505
|
+
publishState: "unpublished",
|
|
506
|
+
article: null,
|
|
507
|
+
} as never);
|
|
508
|
+
// Browser closes immediately → waitForPublishOrClose exits null.
|
|
509
|
+
mockOpenBrowser.mockResolvedValue({ closed: Promise.resolve() });
|
|
510
|
+
|
|
511
|
+
const { prepareWebviewAuth } = await import("../credential");
|
|
512
|
+
|
|
513
|
+
const ctx = makeSyndicateContext([makeUnsyncedArticle({ source_path: "posts/alpha.md" })]);
|
|
514
|
+
await syndicate(ctx);
|
|
515
|
+
|
|
516
|
+
// prepareWebviewAuth must have been called and must precede openBrowser(draftPageUrl).
|
|
517
|
+
expect(vi.mocked(prepareWebviewAuth)).toHaveBeenCalled();
|
|
518
|
+
expect(mockOpenBrowser).toHaveBeenCalled();
|
|
519
|
+
|
|
520
|
+
const prepareOrder = vi.mocked(prepareWebviewAuth).mock.invocationCallOrder[0];
|
|
521
|
+
// The last openBrowser call is the draft-room one (first invocation index).
|
|
522
|
+
const openBrowserOrder = mockOpenBrowser.mock.invocationCallOrder.at(-1)!;
|
|
523
|
+
expect(prepareOrder).toBeLessThan(openBrowserOrder);
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
describe("Law 3 — one terminal L3 ack after the loop (not per article)", () => {
|
|
528
|
+
beforeEach(() => {
|
|
529
|
+
vi.clearAllMocks();
|
|
530
|
+
restartMockTask();
|
|
531
|
+
resetFetchDraftToPublished(); // MUST reset: Law 2 timeout tests set fetchDraft to unpublished
|
|
532
|
+
resetGetSessionStateToValid(); // MUST reset: Law 2 login tests set getSessionState to 'expired' (would add a 2nd toast)
|
|
533
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, status: 200 } as unknown as Response));
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
afterEach(() => {
|
|
537
|
+
vi.unstubAllGlobals();
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it("emits exactly ONE showToast call per syndication run (the terminal ack)", async () => {
|
|
541
|
+
const ctx = makeSyndicateContext([
|
|
542
|
+
makeUnsyncedArticle({ title: "Alpha" }),
|
|
543
|
+
makeUnsyncedArticle({ title: "Beta", source_path: "posts/beta.md", url_path: "posts/beta/" }),
|
|
544
|
+
]);
|
|
545
|
+
await syndicate(ctx);
|
|
546
|
+
expect(mockShowToast).toHaveBeenCalledTimes(1);
|
|
547
|
+
const [[opts]] = mockShowToast.mock.calls as [[{ message: string; variant: string; persistent?: boolean; actions?: Array<{url: string}> }]];
|
|
548
|
+
expect(opts.message).toMatch(/Syndicated.*Matters/);
|
|
549
|
+
expect(opts.variant).toBe("success");
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it("terminal ack is persistent (explicit persistent:true, never a finite duration)", async () => {
|
|
553
|
+
const ctx = makeSyndicateContext([makeUnsyncedArticle()]);
|
|
554
|
+
await syndicate(ctx);
|
|
555
|
+
const [[opts]] = mockShowToast.mock.calls as [[{ persistent?: boolean }]];
|
|
556
|
+
// Must explicitly set persistent:true — do not rely on 'no duration' as a proxy
|
|
557
|
+
expect(opts.persistent).toBe(true);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it("terminal ack includes a 'View profile' action link", async () => {
|
|
561
|
+
const ctx = makeSyndicateContext([makeUnsyncedArticle()]);
|
|
562
|
+
await syndicate(ctx);
|
|
563
|
+
const [[opts]] = mockShowToast.mock.calls as [[{ actions?: Array<{url: string; label: string}> }]];
|
|
564
|
+
expect(opts.actions).toBeDefined();
|
|
565
|
+
expect(opts.actions![0].url).toContain("matters.town");
|
|
566
|
+
expect(opts.actions![0].label).toMatch(/[Pp]rofile|[Vv]iew/);
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
describe("Law 2 — login-required toast is persistent and dismissed after successful login", () => {
|
|
571
|
+
beforeEach(() => {
|
|
572
|
+
vi.clearAllMocks();
|
|
573
|
+
restartMockTask();
|
|
574
|
+
resetFetchDraftToPublished(); // MUST reset: Law 2 timeout tests set fetchDraft to unpublished
|
|
575
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, status: 200 } as unknown as Response));
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
afterEach(() => {
|
|
579
|
+
vi.unstubAllGlobals();
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it("login-required showToast uses persistent:true (not duration:5000)", async () => {
|
|
583
|
+
// Trigger the login-required path: session is not valid
|
|
584
|
+
vi.mocked(getSessionState).mockResolvedValue("expired" as never);
|
|
585
|
+
// promptLogin succeeds (login success path)
|
|
586
|
+
// We'll let the mock login succeed by stubbing promptLogin's browser path:
|
|
587
|
+
// openBrowser mock already returns a resolved handle; getAccessToken mock
|
|
588
|
+
// returns a token, which is the promptLogin success signal.
|
|
589
|
+
|
|
590
|
+
const ctx = makeSyndicateContext([makeUnsyncedArticle()]);
|
|
591
|
+
await syndicate(ctx);
|
|
592
|
+
|
|
593
|
+
const loginRequiredCall = mockShowToast.mock.calls.find(
|
|
594
|
+
([opts]: [{ message: string }]) => opts.message?.includes("login required"),
|
|
595
|
+
);
|
|
596
|
+
expect(loginRequiredCall).toBeDefined();
|
|
597
|
+
const [opts] = loginRequiredCall! as [{ duration?: number; persistent?: boolean; id?: string }];
|
|
598
|
+
expect(opts.duration).toBeUndefined();
|
|
599
|
+
expect(opts.persistent).toBe(true);
|
|
600
|
+
expect(opts.id).toBe("matters-login-required");
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it("dismisses login-required toast after successful login", async () => {
|
|
604
|
+
vi.mocked(getSessionState).mockResolvedValue("expired" as never);
|
|
605
|
+
|
|
606
|
+
const ctx = makeSyndicateContext([makeUnsyncedArticle()]);
|
|
607
|
+
await syndicate(ctx);
|
|
608
|
+
|
|
609
|
+
// After successful login, dismissToast must be called with the toast id
|
|
610
|
+
expect(mockDismissToast).toHaveBeenCalledWith("matters-login-required");
|
|
611
|
+
});
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
describe("Law 2 — session-expired toast is persistent (not duration: 8000)", () => {
|
|
615
|
+
beforeEach(() => {
|
|
616
|
+
vi.clearAllMocks();
|
|
617
|
+
restartMockTask();
|
|
618
|
+
resetFetchDraftToPublished();
|
|
619
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, status: 200 } as unknown as Response));
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
afterEach(() => {
|
|
623
|
+
vi.unstubAllGlobals();
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it("session-expired showToast has persistent:true, no duration field", async () => {
|
|
627
|
+
// Drive the expired path via a MattersAuthError in the per-article loop.
|
|
628
|
+
// notifySessionExpired() is called from the outer catch when shouldNudge=true.
|
|
629
|
+
const { MattersAuthError, createDraft } = await import("../api");
|
|
630
|
+
const { shouldNudgeSessionExpired } = await import("../credential");
|
|
631
|
+
vi.mocked(shouldNudgeSessionExpired).mockResolvedValue(true);
|
|
632
|
+
vi.mocked(createDraft).mockRejectedValue(
|
|
633
|
+
new (MattersAuthError as new (code: string, msg: string) => Error)("TOKEN_INVALID", "rejected"),
|
|
634
|
+
);
|
|
635
|
+
|
|
636
|
+
const ctx = makeSyndicateContext([makeUnsyncedArticle()]);
|
|
637
|
+
await syndicate(ctx);
|
|
638
|
+
|
|
639
|
+
const expiredCall = mockShowToast.mock.calls.find(
|
|
640
|
+
([opts]: [{ message: string }]) => opts.message?.includes("session expired"),
|
|
641
|
+
);
|
|
642
|
+
expect(expiredCall).toBeDefined();
|
|
643
|
+
const [opts] = expiredCall! as [{ duration?: number; persistent?: boolean }];
|
|
644
|
+
// Law 2: must NOT have a finite duration
|
|
645
|
+
expect(opts.duration).toBeUndefined();
|
|
646
|
+
// Law 2: MUST be persistent
|
|
647
|
+
expect(opts.persistent).toBe(true);
|
|
648
|
+
});
|
|
649
|
+
});
|