@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,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for credential.ts — the single matters-credential owner.
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - authHeaderToken: stored-token-only path (old getAccessToken(false))
|
|
6
|
+
* - captureLogin: cookie-capture login path (old getAccessToken(true))
|
|
7
|
+
* - prepareWebviewAuth: projects auth.json token into the __access_token cookie
|
|
8
|
+
* - beginFreshLogin: clears stored token AND plugin cookies before fresh login
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
11
|
+
|
|
12
|
+
// ── Mock @symbiosis-lab/moss-api ─────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
const mockSetPluginCookie = vi.fn().mockResolvedValue(undefined);
|
|
15
|
+
const mockClearPluginCookies = vi.fn().mockResolvedValue(undefined);
|
|
16
|
+
const mockReadPluginFile = vi.fn();
|
|
17
|
+
const mockWritePluginFile = vi.fn().mockResolvedValue(undefined);
|
|
18
|
+
const mockPluginFileExists = vi.fn().mockResolvedValue(true);
|
|
19
|
+
const mockGetPluginCookie = vi.fn();
|
|
20
|
+
|
|
21
|
+
vi.mock("@symbiosis-lab/moss-api", () => ({
|
|
22
|
+
readPluginFile: (...a: unknown[]) => mockReadPluginFile(...a),
|
|
23
|
+
writePluginFile: (...a: unknown[]) => mockWritePluginFile(...a),
|
|
24
|
+
pluginFileExists: (...a: unknown[]) => mockPluginFileExists(...a),
|
|
25
|
+
getPluginCookie: (...a: unknown[]) => mockGetPluginCookie(...a),
|
|
26
|
+
setPluginCookie: (...a: unknown[]) => mockSetPluginCookie(...a),
|
|
27
|
+
clearPluginCookies: (...a: unknown[]) => mockClearPluginCookies(...a),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
authHeaderToken,
|
|
32
|
+
captureLogin,
|
|
33
|
+
prepareWebviewAuth,
|
|
34
|
+
beginFreshLogin,
|
|
35
|
+
clearTokenCache,
|
|
36
|
+
} from "../credential";
|
|
37
|
+
|
|
38
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
const FUTURE = Math.floor(Date.now() / 1000) + 90 * 24 * 3600;
|
|
41
|
+
const PAST = Math.floor(Date.now() / 1000) - 24 * 3600;
|
|
42
|
+
|
|
43
|
+
function fakeJwt(payload: Record<string, unknown>): string {
|
|
44
|
+
const b64url = (obj: Record<string, unknown>) =>
|
|
45
|
+
Buffer.from(JSON.stringify(obj)).toString("base64url");
|
|
46
|
+
return `${b64url({ alg: "HS256", typ: "JWT" })}.${b64url(payload)}.fakesig`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function mockAuthFile(record: Record<string, unknown> | null) {
|
|
50
|
+
mockPluginFileExists.mockResolvedValue(record !== null);
|
|
51
|
+
mockReadPluginFile.mockResolvedValue(JSON.stringify(record ?? {}));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── authHeaderToken (old getAccessToken(false)) ───────────────────────────────
|
|
55
|
+
|
|
56
|
+
describe("authHeaderToken", () => {
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
vi.clearAllMocks();
|
|
59
|
+
clearTokenCache();
|
|
60
|
+
// Default: no stored token
|
|
61
|
+
mockPluginFileExists.mockResolvedValue(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns token from project storage when auth.json exists", async () => {
|
|
65
|
+
mockPluginFileExists.mockResolvedValue(true);
|
|
66
|
+
mockReadPluginFile.mockResolvedValue(JSON.stringify({
|
|
67
|
+
accessToken: "stored-project-token",
|
|
68
|
+
savedAt: "2026-01-01T00:00:00Z",
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
const result = await authHeaderToken();
|
|
72
|
+
|
|
73
|
+
expect(result).toBe("stored-project-token");
|
|
74
|
+
// Should NOT check cookies
|
|
75
|
+
expect(mockGetPluginCookie).not.toHaveBeenCalled();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("returns null when no stored token", async () => {
|
|
79
|
+
mockPluginFileExists.mockResolvedValue(false);
|
|
80
|
+
|
|
81
|
+
const result = await authHeaderToken();
|
|
82
|
+
|
|
83
|
+
expect(result).toBeNull();
|
|
84
|
+
// Should NOT check cookies
|
|
85
|
+
expect(mockGetPluginCookie).not.toHaveBeenCalled();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("returns null when auth.json exists but has no accessToken field", async () => {
|
|
89
|
+
mockPluginFileExists.mockResolvedValue(true);
|
|
90
|
+
mockReadPluginFile.mockResolvedValue("{}");
|
|
91
|
+
|
|
92
|
+
const result = await authHeaderToken();
|
|
93
|
+
|
|
94
|
+
expect(result).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("returns null when auth.json contains invalid JSON", async () => {
|
|
98
|
+
mockPluginFileExists.mockResolvedValue(true);
|
|
99
|
+
mockReadPluginFile.mockResolvedValue("not-json");
|
|
100
|
+
|
|
101
|
+
const result = await authHeaderToken();
|
|
102
|
+
|
|
103
|
+
expect(result).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("returns null when storage read throws an error", async () => {
|
|
107
|
+
mockPluginFileExists.mockRejectedValue(new Error("storage read failed"));
|
|
108
|
+
|
|
109
|
+
const result = await authHeaderToken();
|
|
110
|
+
|
|
111
|
+
expect(result).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("caches the stored token after first retrieval", async () => {
|
|
115
|
+
mockPluginFileExists.mockResolvedValue(true);
|
|
116
|
+
mockReadPluginFile.mockResolvedValue(JSON.stringify({
|
|
117
|
+
accessToken: "cached-stored-token",
|
|
118
|
+
}));
|
|
119
|
+
|
|
120
|
+
const result1 = await authHeaderToken();
|
|
121
|
+
expect(result1).toBe("cached-stored-token");
|
|
122
|
+
|
|
123
|
+
// Second call should use cache
|
|
124
|
+
mockPluginFileExists.mockResolvedValue(false); // would return null if not cached
|
|
125
|
+
const result2 = await authHeaderToken();
|
|
126
|
+
|
|
127
|
+
expect(result2).toBe("cached-stored-token");
|
|
128
|
+
expect(mockReadPluginFile).toHaveBeenCalledTimes(1);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("clearTokenCache allows fresh retrieval from storage", async () => {
|
|
132
|
+
mockPluginFileExists.mockResolvedValue(true);
|
|
133
|
+
mockReadPluginFile.mockResolvedValue(JSON.stringify({
|
|
134
|
+
accessToken: "first-token",
|
|
135
|
+
}));
|
|
136
|
+
|
|
137
|
+
await authHeaderToken();
|
|
138
|
+
clearTokenCache();
|
|
139
|
+
|
|
140
|
+
mockReadPluginFile.mockResolvedValue(JSON.stringify({
|
|
141
|
+
accessToken: "second-token",
|
|
142
|
+
}));
|
|
143
|
+
|
|
144
|
+
const result = await authHeaderToken();
|
|
145
|
+
|
|
146
|
+
expect(result).toBe("second-token");
|
|
147
|
+
expect(mockReadPluginFile).toHaveBeenCalledTimes(2);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("does NOT check the global cookie (prevents cross-project leak)", async () => {
|
|
151
|
+
// Simulate: no stored token for this project, but global cookie exists
|
|
152
|
+
mockPluginFileExists.mockResolvedValue(false);
|
|
153
|
+
mockGetPluginCookie.mockResolvedValue([
|
|
154
|
+
{ name: "__access_token", value: "leaked-global-token" },
|
|
155
|
+
]);
|
|
156
|
+
|
|
157
|
+
// authHeaderToken must NOT check cookies
|
|
158
|
+
const result = await authHeaderToken();
|
|
159
|
+
|
|
160
|
+
expect(result).toBeNull();
|
|
161
|
+
expect(mockGetPluginCookie).not.toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ── captureLogin (old getAccessToken(true)) ───────────────────────────────────
|
|
166
|
+
|
|
167
|
+
describe("captureLogin", () => {
|
|
168
|
+
beforeEach(() => {
|
|
169
|
+
vi.clearAllMocks();
|
|
170
|
+
clearTokenCache();
|
|
171
|
+
mockWritePluginFile.mockResolvedValue(undefined);
|
|
172
|
+
// Default: no stored token
|
|
173
|
+
mockPluginFileExists.mockResolvedValue(false);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("checks cookies when no stored token", async () => {
|
|
177
|
+
mockPluginFileExists.mockResolvedValue(false);
|
|
178
|
+
mockGetPluginCookie.mockResolvedValue([
|
|
179
|
+
{ name: "__access_token", value: "cookie-token" },
|
|
180
|
+
]);
|
|
181
|
+
|
|
182
|
+
const result = await captureLogin();
|
|
183
|
+
|
|
184
|
+
expect(result).toBe("cookie-token");
|
|
185
|
+
expect(mockGetPluginCookie).toHaveBeenCalled();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("persists cookie token to project storage", async () => {
|
|
189
|
+
mockPluginFileExists.mockResolvedValue(false);
|
|
190
|
+
mockGetPluginCookie.mockResolvedValue([
|
|
191
|
+
{ name: "__access_token", value: "cookie-to-store" },
|
|
192
|
+
]);
|
|
193
|
+
|
|
194
|
+
await captureLogin();
|
|
195
|
+
|
|
196
|
+
expect(mockWritePluginFile).toHaveBeenCalledWith(
|
|
197
|
+
"auth.json",
|
|
198
|
+
expect.stringContaining("cookie-to-store")
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("returns undefined when getPluginCookie returns null (no context)", async () => {
|
|
203
|
+
mockPluginFileExists.mockResolvedValue(false);
|
|
204
|
+
mockGetPluginCookie.mockResolvedValue(null);
|
|
205
|
+
|
|
206
|
+
const result = await captureLogin();
|
|
207
|
+
|
|
208
|
+
expect(result).toBeUndefined();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("returns null when cookie exists but __access_token is not present", async () => {
|
|
212
|
+
mockPluginFileExists.mockResolvedValue(false);
|
|
213
|
+
mockGetPluginCookie.mockResolvedValue([
|
|
214
|
+
{ name: "other_cookie", value: "some_value" },
|
|
215
|
+
]);
|
|
216
|
+
|
|
217
|
+
const result = await captureLogin();
|
|
218
|
+
|
|
219
|
+
expect(result).toBeNull();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("still returns cookie token when auto-persist to storage fails", async () => {
|
|
223
|
+
mockPluginFileExists.mockResolvedValue(false);
|
|
224
|
+
mockGetPluginCookie.mockResolvedValue([
|
|
225
|
+
{ name: "__access_token", value: "cookie-despite-storage-fail" },
|
|
226
|
+
]);
|
|
227
|
+
mockWritePluginFile.mockRejectedValue(new Error("storage write failed"));
|
|
228
|
+
|
|
229
|
+
const result = await captureLogin();
|
|
230
|
+
|
|
231
|
+
expect(result).toBe("cookie-despite-storage-fail");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("prefers stored token over cookie", async () => {
|
|
235
|
+
mockPluginFileExists.mockResolvedValue(true);
|
|
236
|
+
mockReadPluginFile.mockResolvedValue(JSON.stringify({
|
|
237
|
+
accessToken: "stored-token",
|
|
238
|
+
}));
|
|
239
|
+
|
|
240
|
+
const result = await captureLogin();
|
|
241
|
+
|
|
242
|
+
expect(result).toBe("stored-token");
|
|
243
|
+
expect(mockGetPluginCookie).not.toHaveBeenCalled();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// Dead-token filter tests (matching session.test.ts coverage)
|
|
247
|
+
it("rejects an expired-exp cookie: resolves null and does NOT write auth.json", async () => {
|
|
248
|
+
mockAuthFile(null); // no stored record
|
|
249
|
+
mockGetPluginCookie.mockResolvedValue([
|
|
250
|
+
{ name: "__access_token", value: fakeJwt({ exp: PAST }) },
|
|
251
|
+
]);
|
|
252
|
+
expect(await captureLogin()).toBeNull();
|
|
253
|
+
expect(mockWritePluginFile).not.toHaveBeenCalled();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("rejects a cookie identical to the invalidatedAt-stamped record's token", async () => {
|
|
257
|
+
const revoked = fakeJwt({ exp: FUTURE });
|
|
258
|
+
mockAuthFile({ accessToken: revoked, invalidatedAt: "2026-06-10T03:00:00.000Z" });
|
|
259
|
+
mockGetPluginCookie.mockResolvedValue([
|
|
260
|
+
{ name: "__access_token", value: revoked },
|
|
261
|
+
]);
|
|
262
|
+
expect(await captureLogin()).toBeNull();
|
|
263
|
+
expect(mockWritePluginFile).not.toHaveBeenCalled();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("accepts a fresh future-exp cookie different from the stamped token and persists it", async () => {
|
|
267
|
+
const revoked = fakeJwt({ exp: FUTURE, id: "old" });
|
|
268
|
+
const fresh = fakeJwt({ exp: FUTURE, id: "new" });
|
|
269
|
+
mockAuthFile({ accessToken: revoked, invalidatedAt: "2026-06-10T03:00:00.000Z" });
|
|
270
|
+
mockGetPluginCookie.mockResolvedValue([
|
|
271
|
+
{ name: "__access_token", value: fresh },
|
|
272
|
+
]);
|
|
273
|
+
expect(await captureLogin()).toBe(fresh);
|
|
274
|
+
expect(mockWritePluginFile).toHaveBeenCalledWith(
|
|
275
|
+
"auth.json",
|
|
276
|
+
expect.stringContaining(fresh)
|
|
277
|
+
);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// ── prepareWebviewAuth ────────────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
describe("prepareWebviewAuth", () => {
|
|
284
|
+
beforeEach(() => {
|
|
285
|
+
vi.clearAllMocks();
|
|
286
|
+
clearTokenCache();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("projects the auth.json token into the __access_token cookie", async () => {
|
|
290
|
+
mockPluginFileExists.mockResolvedValue(true);
|
|
291
|
+
mockReadPluginFile.mockResolvedValue(JSON.stringify({ accessToken: "tok-α" }));
|
|
292
|
+
|
|
293
|
+
await prepareWebviewAuth();
|
|
294
|
+
|
|
295
|
+
expect(mockSetPluginCookie).toHaveBeenCalledWith([{ name: "__access_token", value: "tok-α" }]);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("is a no-op when there is no usable token", async () => {
|
|
299
|
+
mockPluginFileExists.mockResolvedValue(true);
|
|
300
|
+
mockReadPluginFile.mockResolvedValue("{}");
|
|
301
|
+
|
|
302
|
+
await prepareWebviewAuth();
|
|
303
|
+
|
|
304
|
+
expect(mockSetPluginCookie).not.toHaveBeenCalled();
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("is a no-op when auth file does not exist", async () => {
|
|
308
|
+
mockPluginFileExists.mockResolvedValue(false);
|
|
309
|
+
|
|
310
|
+
await prepareWebviewAuth();
|
|
311
|
+
|
|
312
|
+
expect(mockSetPluginCookie).not.toHaveBeenCalled();
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// ── beginFreshLogin ───────────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
describe("beginFreshLogin", () => {
|
|
319
|
+
beforeEach(() => {
|
|
320
|
+
vi.clearAllMocks();
|
|
321
|
+
clearTokenCache();
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("clears the stored token AND the cookies", async () => {
|
|
325
|
+
await beginFreshLogin();
|
|
326
|
+
|
|
327
|
+
// clearStoredToken writes "{}" to auth.json
|
|
328
|
+
expect(mockWritePluginFile).toHaveBeenCalledWith("auth.json", "{}");
|
|
329
|
+
// clearPluginCookies clears the matters-domain cookies
|
|
330
|
+
expect(mockClearPluginCookies).toHaveBeenCalled();
|
|
331
|
+
});
|
|
332
|
+
});
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { setupMockTauri, type MockTauriContext } from "@symbiosis-lab/moss-api/testing";
|
|
3
|
+
import { apiConfig } from "../api";
|
|
4
|
+
|
|
5
|
+
// We'll import these after creating domain.ts
|
|
6
|
+
import {
|
|
7
|
+
initializeDomain,
|
|
8
|
+
getDomain,
|
|
9
|
+
loginUrl,
|
|
10
|
+
draftUrl,
|
|
11
|
+
articleUrl,
|
|
12
|
+
isMattersUrl,
|
|
13
|
+
isInternalMattersLink,
|
|
14
|
+
extractShortHash,
|
|
15
|
+
resetDomain,
|
|
16
|
+
} from "../domain";
|
|
17
|
+
|
|
18
|
+
const PLUGIN_NAME = "matters-syndicator";
|
|
19
|
+
|
|
20
|
+
describe("Domain Module", () => {
|
|
21
|
+
let ctx: MockTauriContext;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
ctx = setupMockTauri({ pluginName: PLUGIN_NAME });
|
|
25
|
+
resetDomain(); // Reset to default before each test
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
ctx.cleanup();
|
|
30
|
+
resetDomain();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("initializeDomain", () => {
|
|
34
|
+
it("defaults to matters.town when no config", async () => {
|
|
35
|
+
// No config file → defaults
|
|
36
|
+
await initializeDomain();
|
|
37
|
+
|
|
38
|
+
expect(getDomain()).toBe("matters.town");
|
|
39
|
+
expect(apiConfig.endpoint).toBe("https://server.matters.town/graphql");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("uses configured domain from config.json", async () => {
|
|
43
|
+
ctx.filesystem.setFile(
|
|
44
|
+
`${ctx.projectPath}/.moss/plugins/${PLUGIN_NAME}/config.json`,
|
|
45
|
+
JSON.stringify({ domain: "matters.icu" })
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
await initializeDomain();
|
|
49
|
+
|
|
50
|
+
expect(getDomain()).toBe("matters.icu");
|
|
51
|
+
expect(apiConfig.endpoint).toBe("https://server.matters.icu/graphql");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("updates manifest.json domain when different from config", async () => {
|
|
55
|
+
// Set up existing manifest with matters.town
|
|
56
|
+
ctx.filesystem.setFile(
|
|
57
|
+
`${ctx.projectPath}/.moss/plugins/${PLUGIN_NAME}/manifest.json`,
|
|
58
|
+
JSON.stringify({
|
|
59
|
+
name: "matters",
|
|
60
|
+
version: "1.0.0",
|
|
61
|
+
domain: "matters.town",
|
|
62
|
+
entry: "main.bundle.js",
|
|
63
|
+
capabilities: ["process", "syndicate"],
|
|
64
|
+
})
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
// Set config to matters.icu
|
|
68
|
+
ctx.filesystem.setFile(
|
|
69
|
+
`${ctx.projectPath}/.moss/plugins/${PLUGIN_NAME}/config.json`,
|
|
70
|
+
JSON.stringify({ domain: "matters.icu" })
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
await initializeDomain();
|
|
74
|
+
|
|
75
|
+
// Manifest should be updated
|
|
76
|
+
const manifestContent = ctx.filesystem.getFile(
|
|
77
|
+
`${ctx.projectPath}/.moss/plugins/${PLUGIN_NAME}/manifest.json`
|
|
78
|
+
);
|
|
79
|
+
expect(manifestContent).toBeDefined();
|
|
80
|
+
const manifest = JSON.parse(manifestContent!.content);
|
|
81
|
+
expect(manifest.domain).toBe("matters.icu");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("does not update manifest when domain already matches", async () => {
|
|
85
|
+
ctx.filesystem.setFile(
|
|
86
|
+
`${ctx.projectPath}/.moss/plugins/${PLUGIN_NAME}/manifest.json`,
|
|
87
|
+
JSON.stringify({
|
|
88
|
+
name: "matters",
|
|
89
|
+
version: "1.0.0",
|
|
90
|
+
domain: "matters.icu",
|
|
91
|
+
entry: "main.bundle.js",
|
|
92
|
+
capabilities: ["process", "syndicate"],
|
|
93
|
+
})
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
ctx.filesystem.setFile(
|
|
97
|
+
`${ctx.projectPath}/.moss/plugins/${PLUGIN_NAME}/config.json`,
|
|
98
|
+
JSON.stringify({ domain: "matters.icu" })
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
await initializeDomain();
|
|
102
|
+
|
|
103
|
+
// Domain should be set correctly
|
|
104
|
+
expect(getDomain()).toBe("matters.icu");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("preserves other manifest fields when updating domain", async () => {
|
|
108
|
+
const originalManifest = {
|
|
109
|
+
name: "matters",
|
|
110
|
+
version: "1.0.0",
|
|
111
|
+
description: "Syndicate to Matters.town",
|
|
112
|
+
domain: "matters.town",
|
|
113
|
+
entry: "main.bundle.js",
|
|
114
|
+
capabilities: ["process", "syndicate"],
|
|
115
|
+
config: { auto_publish: false },
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
ctx.filesystem.setFile(
|
|
119
|
+
`${ctx.projectPath}/.moss/plugins/${PLUGIN_NAME}/manifest.json`,
|
|
120
|
+
JSON.stringify(originalManifest)
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
ctx.filesystem.setFile(
|
|
124
|
+
`${ctx.projectPath}/.moss/plugins/${PLUGIN_NAME}/config.json`,
|
|
125
|
+
JSON.stringify({ domain: "matters.icu" })
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
await initializeDomain();
|
|
129
|
+
|
|
130
|
+
const manifestContent = ctx.filesystem.getFile(
|
|
131
|
+
`${ctx.projectPath}/.moss/plugins/${PLUGIN_NAME}/manifest.json`
|
|
132
|
+
);
|
|
133
|
+
const manifest = JSON.parse(manifestContent!.content);
|
|
134
|
+
|
|
135
|
+
expect(manifest.name).toBe("matters");
|
|
136
|
+
expect(manifest.version).toBe("1.0.0");
|
|
137
|
+
expect(manifest.description).toBe("Syndicate to Matters.town");
|
|
138
|
+
expect(manifest.domain).toBe("matters.icu");
|
|
139
|
+
expect(manifest.entry).toBe("main.bundle.js");
|
|
140
|
+
expect(manifest.capabilities).toEqual(["process", "syndicate"]);
|
|
141
|
+
expect(manifest.config).toEqual({ auto_publish: false });
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("URL builders", () => {
|
|
146
|
+
it("loginUrl uses current domain", async () => {
|
|
147
|
+
await initializeDomain(); // defaults to matters.town
|
|
148
|
+
expect(loginUrl()).toBe("https://matters.town/login");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("loginUrl uses configured domain", async () => {
|
|
152
|
+
ctx.filesystem.setFile(
|
|
153
|
+
`${ctx.projectPath}/.moss/plugins/${PLUGIN_NAME}/config.json`,
|
|
154
|
+
JSON.stringify({ domain: "matters.icu" })
|
|
155
|
+
);
|
|
156
|
+
await initializeDomain();
|
|
157
|
+
expect(loginUrl()).toBe("https://matters.icu/login");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("draftUrl constructs correct URL", async () => {
|
|
161
|
+
await initializeDomain();
|
|
162
|
+
expect(draftUrl("draft-123")).toBe(
|
|
163
|
+
"https://matters.town/me/drafts/draft-123"
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("draftUrl uses configured domain", async () => {
|
|
168
|
+
ctx.filesystem.setFile(
|
|
169
|
+
`${ctx.projectPath}/.moss/plugins/${PLUGIN_NAME}/config.json`,
|
|
170
|
+
JSON.stringify({ domain: "matters.icu" })
|
|
171
|
+
);
|
|
172
|
+
await initializeDomain();
|
|
173
|
+
expect(draftUrl("draft-456")).toBe(
|
|
174
|
+
"https://matters.icu/me/drafts/draft-456"
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("articleUrl constructs correct URL", async () => {
|
|
179
|
+
await initializeDomain();
|
|
180
|
+
expect(articleUrl("alice", "my-article", "abc123")).toBe(
|
|
181
|
+
"https://matters.town/@alice/my-article-abc123"
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("articleUrl uses configured domain", async () => {
|
|
186
|
+
ctx.filesystem.setFile(
|
|
187
|
+
`${ctx.projectPath}/.moss/plugins/${PLUGIN_NAME}/config.json`,
|
|
188
|
+
JSON.stringify({ domain: "matters.icu" })
|
|
189
|
+
);
|
|
190
|
+
await initializeDomain();
|
|
191
|
+
expect(articleUrl("bob", "test-post", "xyz789")).toBe(
|
|
192
|
+
"https://matters.icu/@bob/test-post-xyz789"
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("isMattersUrl", () => {
|
|
198
|
+
it("matches default domain", async () => {
|
|
199
|
+
await initializeDomain();
|
|
200
|
+
expect(isMattersUrl("https://matters.town/@alice/post-abc123")).toBe(true);
|
|
201
|
+
expect(isMattersUrl("https://example.com/article")).toBe(false);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("matches configured domain", async () => {
|
|
205
|
+
ctx.filesystem.setFile(
|
|
206
|
+
`${ctx.projectPath}/.moss/plugins/${PLUGIN_NAME}/config.json`,
|
|
207
|
+
JSON.stringify({ domain: "matters.icu" })
|
|
208
|
+
);
|
|
209
|
+
await initializeDomain();
|
|
210
|
+
|
|
211
|
+
expect(isMattersUrl("https://matters.icu/@alice/post-abc123")).toBe(true);
|
|
212
|
+
// Should NOT match matters.town when configured to matters.icu
|
|
213
|
+
expect(isMattersUrl("https://matters.town/@alice/post-abc123")).toBe(
|
|
214
|
+
false
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("handles edge cases", async () => {
|
|
219
|
+
await initializeDomain();
|
|
220
|
+
expect(isMattersUrl("")).toBe(false);
|
|
221
|
+
expect(isMattersUrl("matters.town")).toBe(true); // contains domain
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe("isInternalMattersLink", () => {
|
|
226
|
+
it("matches user's own content on default domain", async () => {
|
|
227
|
+
await initializeDomain();
|
|
228
|
+
expect(
|
|
229
|
+
isInternalMattersLink(
|
|
230
|
+
"https://matters.town/@alice/my-post-abc",
|
|
231
|
+
"alice"
|
|
232
|
+
)
|
|
233
|
+
).toBe(true);
|
|
234
|
+
expect(
|
|
235
|
+
isInternalMattersLink(
|
|
236
|
+
"https://matters.town/@bob/other-post-abc",
|
|
237
|
+
"alice"
|
|
238
|
+
)
|
|
239
|
+
).toBe(false);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("matches user's own content on configured domain", async () => {
|
|
243
|
+
ctx.filesystem.setFile(
|
|
244
|
+
`${ctx.projectPath}/.moss/plugins/${PLUGIN_NAME}/config.json`,
|
|
245
|
+
JSON.stringify({ domain: "matters.icu" })
|
|
246
|
+
);
|
|
247
|
+
await initializeDomain();
|
|
248
|
+
|
|
249
|
+
expect(
|
|
250
|
+
isInternalMattersLink(
|
|
251
|
+
"https://matters.icu/@alice/my-post-abc",
|
|
252
|
+
"alice"
|
|
253
|
+
)
|
|
254
|
+
).toBe(true);
|
|
255
|
+
// Should NOT match matters.town when configured to matters.icu
|
|
256
|
+
expect(
|
|
257
|
+
isInternalMattersLink(
|
|
258
|
+
"https://matters.town/@alice/my-post-abc",
|
|
259
|
+
"alice"
|
|
260
|
+
)
|
|
261
|
+
).toBe(false);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe("getDomain", () => {
|
|
266
|
+
it("returns default before initialization", () => {
|
|
267
|
+
expect(getDomain()).toBe("matters.town");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("returns configured domain after initialization", async () => {
|
|
271
|
+
ctx.filesystem.setFile(
|
|
272
|
+
`${ctx.projectPath}/.moss/plugins/${PLUGIN_NAME}/config.json`,
|
|
273
|
+
JSON.stringify({ domain: "matters.icu" })
|
|
274
|
+
);
|
|
275
|
+
await initializeDomain();
|
|
276
|
+
expect(getDomain()).toBe("matters.icu");
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe("resetDomain", () => {
|
|
281
|
+
it("resets to default", async () => {
|
|
282
|
+
ctx.filesystem.setFile(
|
|
283
|
+
`${ctx.projectPath}/.moss/plugins/${PLUGIN_NAME}/config.json`,
|
|
284
|
+
JSON.stringify({ domain: "matters.icu" })
|
|
285
|
+
);
|
|
286
|
+
await initializeDomain();
|
|
287
|
+
expect(getDomain()).toBe("matters.icu");
|
|
288
|
+
|
|
289
|
+
resetDomain();
|
|
290
|
+
expect(getDomain()).toBe("matters.town");
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// ============================================================================
|
|
296
|
+
// extractShortHash — pure URL parsing (no Tauri context needed)
|
|
297
|
+
// ============================================================================
|
|
298
|
+
|
|
299
|
+
describe("extractShortHash", () => {
|
|
300
|
+
it("extracts shortHash from standard Matters URL", () => {
|
|
301
|
+
const url = "https://matters.town/@testuser/test-article-abc123def";
|
|
302
|
+
expect(extractShortHash(url)).toBe("abc123def");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("extracts shortHash from URL with multiple hyphens in slug", () => {
|
|
306
|
+
const url = "https://matters.town/@testuser/my-long-article-title-xyz789";
|
|
307
|
+
expect(extractShortHash(url)).toBe("xyz789");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("extracts shortHash from Chinese article URL", () => {
|
|
311
|
+
const url = "https://matters.town/@testuser/测试文章-shortHash123";
|
|
312
|
+
expect(extractShortHash(url)).toBe("shortHash123");
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("returns null for invalid URL", () => {
|
|
316
|
+
expect(extractShortHash("not a url")).toBe(null);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("returns null for URL without path segments", () => {
|
|
320
|
+
expect(extractShortHash("https://matters.town/")).toBe(null);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("returns null for URL with only slug (no hyphen)", () => {
|
|
324
|
+
expect(extractShortHash("https://matters.town/@testuser/article")).toBe(null);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("extracts shortHash from /a/ short-link URL", () => {
|
|
328
|
+
expect(extractShortHash("https://matters.town/a/aj5szksg7ppa")).toBe("aj5szksg7ppa");
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("extracts shortHash from /a/ short-link with query and fragment", () => {
|
|
332
|
+
expect(extractShortHash("https://matters.town/a/aj5szksg7ppa?utm=x#comment")).toBe(
|
|
333
|
+
"aj5szksg7ppa"
|
|
334
|
+
);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("returns null for bare /a path with no shortHash", () => {
|
|
338
|
+
expect(extractShortHash("https://matters.town/a")).toBe(null);
|
|
339
|
+
expect(extractShortHash("https://matters.town/a/")).toBe(null);
|
|
340
|
+
});
|
|
341
|
+
});
|