@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,125 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
addCanonicalLinkToContent,
|
|
4
|
+
getArticleContent,
|
|
5
|
+
isArticleLive,
|
|
6
|
+
} from "../main";
|
|
7
|
+
import type { ArticleInfo } from "../types";
|
|
8
|
+
|
|
9
|
+
describe("addCanonicalLinkToContent", () => {
|
|
10
|
+
const canonicalUrl = "https://example.com/posts/hello/";
|
|
11
|
+
|
|
12
|
+
it("appends markdown canonical link for markdown content", () => {
|
|
13
|
+
const result = addCanonicalLinkToContent("# Hello\n\nContent.", canonicalUrl, false);
|
|
14
|
+
expect(result).toContain("---");
|
|
15
|
+
expect(result).toContain(`[Original link](${canonicalUrl})`);
|
|
16
|
+
expect(result).not.toContain("<hr>");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("appends HTML canonical link for HTML content", () => {
|
|
20
|
+
const result = addCanonicalLinkToContent("<h1>Hello</h1><p>Content.</p>", canonicalUrl, true);
|
|
21
|
+
expect(result).toContain("<hr>");
|
|
22
|
+
expect(result).toContain(`<a href="${canonicalUrl}">Original link</a>`);
|
|
23
|
+
expect(result).not.toContain("---\n");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("defaults to markdown mode when isHtml is omitted", () => {
|
|
27
|
+
const result = addCanonicalLinkToContent("# Hello", canonicalUrl);
|
|
28
|
+
expect(result).toContain("---");
|
|
29
|
+
expect(result).not.toContain("<hr>");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("getArticleContent", () => {
|
|
34
|
+
const baseArticle: ArticleInfo = {
|
|
35
|
+
source_path: "posts/test.md",
|
|
36
|
+
title: "Test",
|
|
37
|
+
content: "# Test\n\nMarkdown content.",
|
|
38
|
+
frontmatter: {},
|
|
39
|
+
url_path: "posts/test/",
|
|
40
|
+
tags: [],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
it("returns html_content when available", () => {
|
|
44
|
+
const article: ArticleInfo = {
|
|
45
|
+
...baseArticle,
|
|
46
|
+
html_content: "<h1>Test</h1>\n<p>Markdown content.</p>",
|
|
47
|
+
};
|
|
48
|
+
const result = getArticleContent(article);
|
|
49
|
+
expect(result.content).toBe("<h1>Test</h1>\n<p>Markdown content.</p>");
|
|
50
|
+
expect(result.isHtml).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("falls back to markdown content when html_content is undefined", () => {
|
|
54
|
+
const result = getArticleContent(baseArticle);
|
|
55
|
+
expect(result.content).toBe("# Test\n\nMarkdown content.");
|
|
56
|
+
expect(result.isHtml).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("falls back to markdown content when html_content is empty string", () => {
|
|
60
|
+
const article: ArticleInfo = {
|
|
61
|
+
...baseArticle,
|
|
62
|
+
html_content: "",
|
|
63
|
+
};
|
|
64
|
+
const result = getArticleContent(article);
|
|
65
|
+
expect(result.content).toBe("# Test\n\nMarkdown content.");
|
|
66
|
+
expect(result.isHtml).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("isArticleLive", () => {
|
|
71
|
+
const originalFetch = globalThis.fetch;
|
|
72
|
+
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
globalThis.fetch = vi.fn();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
globalThis.fetch = originalFetch;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("returns true when article URL responds with 200", async () => {
|
|
82
|
+
vi.mocked(globalThis.fetch).mockResolvedValueOnce({ ok: true } as Response);
|
|
83
|
+
|
|
84
|
+
const result = await isArticleLive("https://guoliu.github.io", "writings/reviews/tools-for-thought/");
|
|
85
|
+
expect(result).toBe(true);
|
|
86
|
+
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
87
|
+
"https://guoliu.github.io/writings/reviews/tools-for-thought/",
|
|
88
|
+
{ method: "HEAD" }
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("returns false when article URL responds with 404", async () => {
|
|
93
|
+
vi.mocked(globalThis.fetch).mockResolvedValueOnce({ ok: false, status: 404 } as Response);
|
|
94
|
+
|
|
95
|
+
const result = await isArticleLive("https://guoliu.github.io", "writings/reviews/new-article/");
|
|
96
|
+
expect(result).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("returns false when fetch throws (network error)", async () => {
|
|
100
|
+
vi.mocked(globalThis.fetch).mockRejectedValueOnce(new Error("Network error"));
|
|
101
|
+
|
|
102
|
+
const result = await isArticleLive("https://guoliu.github.io", "writings/reviews/new-article/");
|
|
103
|
+
expect(result).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("handles trailing slash on siteUrl and leading slash on articlePath", async () => {
|
|
107
|
+
vi.mocked(globalThis.fetch).mockResolvedValueOnce({ ok: true } as Response);
|
|
108
|
+
|
|
109
|
+
await isArticleLive("https://guoliu.github.io/", "/writings/reviews/test/");
|
|
110
|
+
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
111
|
+
"https://guoliu.github.io/writings/reviews/test/",
|
|
112
|
+
{ method: "HEAD" }
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("handles siteUrl without trailing slash and articlePath without leading slash", async () => {
|
|
117
|
+
vi.mocked(globalThis.fetch).mockResolvedValueOnce({ ok: true } as Response);
|
|
118
|
+
|
|
119
|
+
await isArticleLive("https://guoliu.github.io", "writings/reviews/test/");
|
|
120
|
+
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
121
|
+
"https://guoliu.github.io/writings/reviews/test/",
|
|
122
|
+
{ method: "HEAD" }
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the MOSS_MATTERS_TEST_PROFILE escape hatch (T8a, 2026-05-28).
|
|
3
|
+
*
|
|
4
|
+
* Verifies both layers of the escape hatch:
|
|
5
|
+
* 1. API layer: apiConfig.queryMode flips to "user" and apiConfig.testUserName
|
|
6
|
+
* is set to the profile (strips leading @).
|
|
7
|
+
* 2. UI layer: when the env var is set, the process hook auto-binds to the
|
|
8
|
+
* test profile without prompting login and without calling openBrowser.
|
|
9
|
+
*
|
|
10
|
+
* Both layers must work together for the e2e harness — an API-only flip
|
|
11
|
+
* leaves the auth webview visible (per dispatch plan).
|
|
12
|
+
*/
|
|
13
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
14
|
+
|
|
15
|
+
const mockGetPluginEnvVar = vi.fn();
|
|
16
|
+
const mockOpenBrowser = vi.fn();
|
|
17
|
+
const mockGetConfig = vi.fn();
|
|
18
|
+
const mockSaveConfig = vi.fn().mockResolvedValue(undefined);
|
|
19
|
+
const mockDetectBoundUser = vi.fn();
|
|
20
|
+
const mockFetchUserProfile = vi.fn();
|
|
21
|
+
const mockSyncToLocalFiles = vi.fn();
|
|
22
|
+
|
|
23
|
+
vi.mock("@symbiosis-lab/moss-api", () => ({
|
|
24
|
+
getPluginCookie: vi.fn(),
|
|
25
|
+
setPluginCookie: vi.fn(),
|
|
26
|
+
httpPost: vi.fn(),
|
|
27
|
+
httpGet: vi.fn(),
|
|
28
|
+
fetchUrl: vi.fn(),
|
|
29
|
+
downloadAsset: vi.fn(),
|
|
30
|
+
readFile: vi.fn(),
|
|
31
|
+
writeFile: vi.fn(),
|
|
32
|
+
listFiles: vi.fn().mockResolvedValue([]),
|
|
33
|
+
showToast: vi.fn().mockResolvedValue(undefined),
|
|
34
|
+
openBrowser: (...args: unknown[]) => mockOpenBrowser(...args),
|
|
35
|
+
closeBrowser: vi.fn().mockResolvedValue(undefined),
|
|
36
|
+
readPluginFile: vi.fn(),
|
|
37
|
+
writePluginFile: vi.fn().mockResolvedValue(undefined),
|
|
38
|
+
pluginFileExists: vi.fn().mockResolvedValue(false),
|
|
39
|
+
getPluginEnvVar: (...args: unknown[]) => mockGetPluginEnvVar(...args),
|
|
40
|
+
// clearPluginCookies — called by promptLogin() before opening the browser.
|
|
41
|
+
clearPluginCookies: vi.fn().mockResolvedValue(undefined),
|
|
42
|
+
// setMessageContext is called at module load by utils.ts — stub it.
|
|
43
|
+
setMessageContext: vi.fn(),
|
|
44
|
+
sendMessage: vi.fn().mockResolvedValue(undefined),
|
|
45
|
+
reportError: vi.fn().mockResolvedValue(undefined),
|
|
46
|
+
reportComplete: vi.fn().mockResolvedValue(undefined),
|
|
47
|
+
startTask: vi.fn().mockResolvedValue({
|
|
48
|
+
id: "0",
|
|
49
|
+
progress: vi.fn().mockResolvedValue(undefined),
|
|
50
|
+
awaiting: vi.fn().mockResolvedValue(undefined),
|
|
51
|
+
succeeded: vi.fn().mockResolvedValue(undefined),
|
|
52
|
+
failed: vi.fn().mockResolvedValue(undefined),
|
|
53
|
+
cancelled: vi.fn().mockResolvedValue(undefined),
|
|
54
|
+
}),
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
vi.mock("../config", () => ({
|
|
58
|
+
getConfig: (...args: unknown[]) => mockGetConfig(...args),
|
|
59
|
+
saveConfig: (...args: unknown[]) => mockSaveConfig(...args),
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
vi.mock("../sync", () => ({
|
|
63
|
+
detectBoundUser: (...args: unknown[]) => mockDetectBoundUser(...args),
|
|
64
|
+
syncToLocalFiles: (...args: unknown[]) => mockSyncToLocalFiles(...args),
|
|
65
|
+
scanLocalArticles: vi.fn().mockResolvedValue([]),
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
vi.mock("../credential", () => ({
|
|
69
|
+
clearTokenCache: vi.fn(),
|
|
70
|
+
loadStoredToken: vi.fn().mockResolvedValue(null),
|
|
71
|
+
saveStoredToken: vi.fn().mockResolvedValue(undefined),
|
|
72
|
+
clearStoredToken: vi.fn().mockResolvedValue(undefined),
|
|
73
|
+
getSessionState: vi.fn().mockResolvedValue("none"),
|
|
74
|
+
shouldNudgeSessionExpired: vi.fn().mockResolvedValue(false),
|
|
75
|
+
markSessionInvalidated: vi.fn().mockResolvedValue(undefined),
|
|
76
|
+
authHeaderToken: vi.fn().mockResolvedValue(""),
|
|
77
|
+
captureLogin: vi.fn().mockResolvedValue(""),
|
|
78
|
+
prepareWebviewAuth: vi.fn().mockResolvedValue(undefined),
|
|
79
|
+
beginFreshLogin: vi.fn().mockResolvedValue(undefined),
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
vi.mock("../api", async () => {
|
|
83
|
+
const actual = await vi.importActual<typeof import("../api")>("../api");
|
|
84
|
+
return {
|
|
85
|
+
...actual,
|
|
86
|
+
fetchAllArticlesSince: vi.fn().mockResolvedValue({ articles: [], userName: "guo" }),
|
|
87
|
+
fetchAllDraftsSince: vi.fn().mockResolvedValue([]),
|
|
88
|
+
fetchAllCollections: vi.fn().mockResolvedValue([]),
|
|
89
|
+
fetchUserProfile: (...args: unknown[]) => mockFetchUserProfile(...args),
|
|
90
|
+
fetchArticleComments: vi.fn().mockResolvedValue([]),
|
|
91
|
+
fetchAllArticleCommentCounts: vi.fn().mockResolvedValue(new Map()),
|
|
92
|
+
createDraft: vi.fn(),
|
|
93
|
+
fetchDraft: vi.fn(),
|
|
94
|
+
uploadCoverByUrl: vi.fn(),
|
|
95
|
+
uploadEmbedByUrl: vi.fn(),
|
|
96
|
+
};
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
vi.mock("../downloader", () => ({
|
|
100
|
+
downloadMediaAndUpdate: vi.fn().mockResolvedValue({ downloads: 0, updates: 0 }),
|
|
101
|
+
rewriteAllInternalLinks: vi.fn().mockResolvedValue(0),
|
|
102
|
+
}));
|
|
103
|
+
|
|
104
|
+
vi.mock("../social", () => ({
|
|
105
|
+
loadSocialData: vi.fn().mockResolvedValue({ articles: [] }),
|
|
106
|
+
saveSocialData: vi.fn().mockResolvedValue(undefined),
|
|
107
|
+
mergeSocialData: vi.fn().mockReturnValue({ articles: [] }),
|
|
108
|
+
}));
|
|
109
|
+
|
|
110
|
+
vi.mock("../domain", async () => {
|
|
111
|
+
const actual = await vi.importActual<typeof import("../domain")>("../domain");
|
|
112
|
+
return {
|
|
113
|
+
...actual,
|
|
114
|
+
initializeDomain: vi.fn().mockResolvedValue(undefined),
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("MOSS_MATTERS_TEST_PROFILE escape hatch", () => {
|
|
119
|
+
beforeEach(async () => {
|
|
120
|
+
vi.clearAllMocks();
|
|
121
|
+
vi.resetModules();
|
|
122
|
+
mockGetConfig.mockResolvedValue({});
|
|
123
|
+
// Ensure auth-check route reads as "unauth + saved userName" so the
|
|
124
|
+
// process hook progresses past Phase 1 without prompting login again.
|
|
125
|
+
mockDetectBoundUser.mockResolvedValue(null);
|
|
126
|
+
// apiConfig is a module singleton — reset its escape-hatch fields so
|
|
127
|
+
// a prior test that flipped queryMode doesn't leak into the next.
|
|
128
|
+
const { apiConfig } = await import("../api");
|
|
129
|
+
apiConfig.queryMode = "viewer";
|
|
130
|
+
apiConfig.testUserName = "Matty";
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("API layer: flips apiConfig.queryMode to 'user' when env var is set", async () => {
|
|
134
|
+
mockGetPluginEnvVar.mockResolvedValue("@guo");
|
|
135
|
+
|
|
136
|
+
// Fresh import so module-level cache + apiConfig are reset.
|
|
137
|
+
const main = await import("../main");
|
|
138
|
+
const { apiConfig } = await import("../api");
|
|
139
|
+
|
|
140
|
+
// Pre-state: viewer mode (production default)
|
|
141
|
+
expect(apiConfig.queryMode).toBe("viewer");
|
|
142
|
+
|
|
143
|
+
// Trigger the process hook
|
|
144
|
+
const ctx = { config: { sync_on_build: false } } as Parameters<typeof main.process>[0];
|
|
145
|
+
await main.process(ctx);
|
|
146
|
+
|
|
147
|
+
// Post-state: user mode, profile bound
|
|
148
|
+
expect(apiConfig.queryMode).toBe("user");
|
|
149
|
+
expect(apiConfig.testUserName).toBe("guo");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("API layer: strips leading @ from profile name", async () => {
|
|
153
|
+
mockGetPluginEnvVar.mockResolvedValue("@guo");
|
|
154
|
+
const main = await import("../main");
|
|
155
|
+
const { apiConfig } = await import("../api");
|
|
156
|
+
await main.process({ config: { sync_on_build: false } } as Parameters<typeof main.process>[0]);
|
|
157
|
+
expect(apiConfig.testUserName).toBe("guo");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("API layer: accepts profile without leading @", async () => {
|
|
161
|
+
mockGetPluginEnvVar.mockResolvedValue("matty");
|
|
162
|
+
const main = await import("../main");
|
|
163
|
+
const { apiConfig } = await import("../api");
|
|
164
|
+
await main.process({ config: { sync_on_build: false } } as Parameters<typeof main.process>[0]);
|
|
165
|
+
expect(apiConfig.testUserName).toBe("matty");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("UI layer: skips openBrowser when env var is set", async () => {
|
|
169
|
+
mockGetPluginEnvVar.mockResolvedValue("@guo");
|
|
170
|
+
const main = await import("../main");
|
|
171
|
+
await main.process({ config: { sync_on_build: false } } as Parameters<typeof main.process>[0]);
|
|
172
|
+
expect(mockOpenBrowser).not.toHaveBeenCalled();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("UI layer: auto-binds project to test profile", async () => {
|
|
176
|
+
mockGetPluginEnvVar.mockResolvedValue("@guo");
|
|
177
|
+
const main = await import("../main");
|
|
178
|
+
await main.process({ config: { sync_on_build: false } } as Parameters<typeof main.process>[0]);
|
|
179
|
+
|
|
180
|
+
// saveConfig is called with boundUserName + userName = "guo"
|
|
181
|
+
const lastCall = mockSaveConfig.mock.calls[mockSaveConfig.mock.calls.length - 1];
|
|
182
|
+
expect(lastCall?.[0]).toMatchObject({
|
|
183
|
+
boundUserName: "guo",
|
|
184
|
+
userName: "guo",
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("production path: env var unset → apiConfig stays in 'viewer' mode", async () => {
|
|
189
|
+
mockGetPluginEnvVar.mockResolvedValue(undefined);
|
|
190
|
+
// Saved username so auth Phase 1 doesn't prompt login
|
|
191
|
+
mockGetConfig.mockResolvedValue({ boundUserName: "real-user", userName: "real-user" });
|
|
192
|
+
|
|
193
|
+
const main = await import("../main");
|
|
194
|
+
const { apiConfig } = await import("../api");
|
|
195
|
+
await main.process({ config: { sync_on_build: false } } as Parameters<typeof main.process>[0]);
|
|
196
|
+
|
|
197
|
+
// queryMode might be flipped by the saved-username unauthenticated
|
|
198
|
+
// fallback (matters' legacy code path) — what we care about is that
|
|
199
|
+
// the test-profile branch did NOT fire (no openBrowser call, the
|
|
200
|
+
// bound user matches the saved one not "guo").
|
|
201
|
+
expect(mockOpenBrowser).not.toHaveBeenCalled();
|
|
202
|
+
// The escape hatch did not auto-rebind to a test profile.
|
|
203
|
+
const lastCall = mockSaveConfig.mock.calls[mockSaveConfig.mock.calls.length - 1];
|
|
204
|
+
if (lastCall) {
|
|
205
|
+
expect(lastCall[0].boundUserName).not.toBe("guo");
|
|
206
|
+
}
|
|
207
|
+
expect(apiConfig.testUserName).not.toBe("guo");
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { isDraftUrl, looksLikePublishedArticleUrl } from "../url-detect";
|
|
3
|
+
|
|
4
|
+
describe("matters url-detect", () => {
|
|
5
|
+
describe("isDraftUrl", () => {
|
|
6
|
+
it("recognizes the draft editor URL", () => {
|
|
7
|
+
expect(isDraftUrl("https://matters.town/me/drafts/abc123")).toBe(true);
|
|
8
|
+
expect(isDraftUrl("https://matters.town/me/drafts/xzy-987-xyz")).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("rejects non-draft URLs", () => {
|
|
12
|
+
expect(isDraftUrl("https://matters.town/@guo/some-post-a1b2c3")).toBe(false);
|
|
13
|
+
expect(isDraftUrl("https://matters.town/")).toBe(false);
|
|
14
|
+
expect(isDraftUrl("https://matters.town/login")).toBe(false);
|
|
15
|
+
expect(isDraftUrl("https://matters.town/@guo")).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("looksLikePublishedArticleUrl", () => {
|
|
20
|
+
it("recognizes a published article URL with hash suffix", () => {
|
|
21
|
+
// Positive: /@user/slug-hash (6+ alphanumeric chars at end)
|
|
22
|
+
expect(looksLikePublishedArticleUrl("https://matters.town/@guo/some-post-a1b2c3")).toBe(true);
|
|
23
|
+
expect(looksLikePublishedArticleUrl("https://matters.town/@alice/my-article-xyz789")).toBe(true);
|
|
24
|
+
expect(looksLikePublishedArticleUrl("https://matters.town/@bob/hello-world-abcdef")).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("rejects a draft URL", () => {
|
|
28
|
+
expect(looksLikePublishedArticleUrl("https://matters.town/me/drafts/abc123")).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("rejects home URL", () => {
|
|
32
|
+
expect(looksLikePublishedArticleUrl("https://matters.town/")).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("rejects login URL", () => {
|
|
36
|
+
expect(looksLikePublishedArticleUrl("https://matters.town/login")).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("rejects bare profile root (no article path)", () => {
|
|
40
|
+
expect(looksLikePublishedArticleUrl("https://matters.town/@guo")).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("rejects /@user/followers (profile sub-page, no hash)", () => {
|
|
44
|
+
// "followers" has no numeric suffix — fails the hash requirement
|
|
45
|
+
expect(looksLikePublishedArticleUrl("https://matters.town/@guo/followers")).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("rejects /@user/settings (profile sub-page, no hash)", () => {
|
|
49
|
+
expect(looksLikePublishedArticleUrl("https://matters.town/@guo/settings")).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("rejects /@user/bookmarks (profile sub-page, no hash)", () => {
|
|
53
|
+
expect(looksLikePublishedArticleUrl("https://matters.town/@guo/bookmarks")).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("rejects a slug without the hash suffix (fewer than 6 alphanumeric chars)", () => {
|
|
57
|
+
// "abc" is only 3 chars — below the 6-char minimum
|
|
58
|
+
expect(looksLikePublishedArticleUrl("https://matters.town/@guo/no-hash-abc")).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("requires the hash to be at the end after a dash", () => {
|
|
62
|
+
// A slug like "pure-text-only" has no trailing hash segment
|
|
63
|
+
expect(looksLikePublishedArticleUrl("https://matters.town/@guo/pure-text-only")).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("known false-trigger: /@user/tags-abcdef returns true (accepted, caught by API verify)", () => {
|
|
67
|
+
// /@guo/tags-abcdef passes the regex because its final path segment ends
|
|
68
|
+
// in "-<6+ alnum>". This is an intentionally accepted false-positive: the
|
|
69
|
+
// regex cannot cheaply distinguish article slugs from profile sub-pages
|
|
70
|
+
// whose names happen to end in a hash-like suffix. The caller (the matters
|
|
71
|
+
// plugin sync check) always verifies the candidate URL against the Matters
|
|
72
|
+
// API (draft.article); the API rejects non-article paths, so this false-
|
|
73
|
+
// trigger is harmless in practice. Do NOT change the regex to reject this
|
|
74
|
+
// case — the cure would be worse than the disease (over-fitting to a URL
|
|
75
|
+
// shape that Matters can change at any time).
|
|
76
|
+
expect(looksLikePublishedArticleUrl("https://matters.town/@guo/tags-abcdef")).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
slugify,
|
|
4
|
+
simpleHash,
|
|
5
|
+
generateLocalFilename,
|
|
6
|
+
getExtensionFromContentType,
|
|
7
|
+
uint8ArrayToBase64,
|
|
8
|
+
formatArticleSyncSummary,
|
|
9
|
+
} from "../utils";
|
|
10
|
+
|
|
11
|
+
describe("formatArticleSyncSummary", () => {
|
|
12
|
+
it("leads with the noun for the all-unchanged run (the opaque '5 unchanged' case)", () => {
|
|
13
|
+
expect(formatArticleSyncSummary({ created: 0, updated: 0, skipped: 5, failed: 0 })).toBe(
|
|
14
|
+
"5 articles already up to date",
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("uses the singular noun for one article", () => {
|
|
19
|
+
expect(formatArticleSyncSummary({ created: 0, updated: 0, skipped: 1, failed: 0 })).toBe(
|
|
20
|
+
"1 article already up to date",
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("shows the total then the changed breakdown for a mixed run", () => {
|
|
25
|
+
expect(formatArticleSyncSummary({ created: 3, updated: 2, skipped: 5, failed: 0 })).toBe(
|
|
26
|
+
"10 articles: 3 new, 2 updated, 5 unchanged",
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("omits zero-count segments", () => {
|
|
31
|
+
expect(formatArticleSyncSummary({ created: 4, updated: 0, skipped: 0, failed: 0 })).toBe(
|
|
32
|
+
"4 articles: 4 new",
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("reports nothing-to-do plainly", () => {
|
|
37
|
+
expect(formatArticleSyncSummary({ created: 0, updated: 0, skipped: 0, failed: 0 })).toBe(
|
|
38
|
+
"no articles to sync",
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("appends a failure count as a separate cohort when some synced and some failed", () => {
|
|
43
|
+
expect(formatArticleSyncSummary({ created: 5, updated: 0, skipped: 0, failed: 2 })).toBe(
|
|
44
|
+
"5 articles: 5 new, 2 failed to sync",
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("keeps the failure cohort distinct from an all-unchanged set (no 'up to date, 2 failed' ambiguity)", () => {
|
|
49
|
+
expect(formatArticleSyncSummary({ created: 0, updated: 0, skipped: 5, failed: 2 })).toBe(
|
|
50
|
+
"5 articles already up to date, 2 failed to sync",
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("reports a fully-failed run without a misleading 'synced' noun", () => {
|
|
55
|
+
expect(formatArticleSyncSummary({ created: 0, updated: 0, skipped: 0, failed: 3 })).toBe(
|
|
56
|
+
"3 articles failed to sync",
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("slugify", () => {
|
|
62
|
+
it("converts simple text to lowercase slug", () => {
|
|
63
|
+
expect(slugify("Hello World")).toBe("hello-world");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("removes special characters", () => {
|
|
67
|
+
expect(slugify("Hello! World?")).toBe("hello-world");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("handles multiple spaces", () => {
|
|
71
|
+
expect(slugify("Hello World")).toBe("hello-world");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("trims leading and trailing hyphens", () => {
|
|
75
|
+
expect(slugify(" Hello World ")).toBe("hello-world");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("preserves CJK characters", () => {
|
|
79
|
+
expect(slugify("你好世界")).toBe("你好世界");
|
|
80
|
+
expect(slugify("Hello 世界")).toBe("hello-世界");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("preserves Cyrillic characters", () => {
|
|
84
|
+
expect(slugify("Привет мир")).toBe("привет-мир");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("preserves Arabic characters", () => {
|
|
88
|
+
expect(slugify("مرحبا بالعالم")).toBe("مرحبا-بالعالم");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("handles empty string", () => {
|
|
92
|
+
expect(slugify("")).toBe("");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("handles numbers", () => {
|
|
96
|
+
expect(slugify("Test 123")).toBe("test-123");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("collapses multiple hyphens", () => {
|
|
100
|
+
expect(slugify("Hello---World")).toBe("hello-world");
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("simpleHash", () => {
|
|
105
|
+
it("returns consistent hash for same input", () => {
|
|
106
|
+
const hash1 = simpleHash("test");
|
|
107
|
+
const hash2 = simpleHash("test");
|
|
108
|
+
expect(hash1).toBe(hash2);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("returns different hash for different inputs", () => {
|
|
112
|
+
const hash1 = simpleHash("test1");
|
|
113
|
+
const hash2 = simpleHash("test2");
|
|
114
|
+
expect(hash1).not.toBe(hash2);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("returns hexadecimal string", () => {
|
|
118
|
+
const hash = simpleHash("test");
|
|
119
|
+
expect(hash).toMatch(/^[0-9a-f]+$/);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("handles empty string", () => {
|
|
123
|
+
const hash = simpleHash("");
|
|
124
|
+
expect(hash).toBe("0");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("generateLocalFilename", () => {
|
|
129
|
+
it("extracts filename with extension from URL", () => {
|
|
130
|
+
expect(
|
|
131
|
+
generateLocalFilename("https://example.com/images/photo.jpg")
|
|
132
|
+
).toBe("photo.jpg");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("extracts UUID-based filename", () => {
|
|
136
|
+
expect(
|
|
137
|
+
generateLocalFilename(
|
|
138
|
+
"https://cdn.example.com/550e8400-e29b-41d4-a716-446655440000/image.png"
|
|
139
|
+
)
|
|
140
|
+
).toBe("550e8400-e29b-41d4-a716-446655440000.png");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("handles UUID without extension", () => {
|
|
144
|
+
expect(
|
|
145
|
+
generateLocalFilename(
|
|
146
|
+
"https://cdn.example.com/550e8400-e29b-41d4-a716-446655440000"
|
|
147
|
+
)
|
|
148
|
+
).toBe("550e8400-e29b-41d4-a716-446655440000");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("removes /public suffix", () => {
|
|
152
|
+
expect(
|
|
153
|
+
generateLocalFilename("https://cdn.example.com/image.jpg/public")
|
|
154
|
+
).toBe("image.jpg");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("falls back to hash for URLs without filename", () => {
|
|
158
|
+
const result = generateLocalFilename("https://example.com/");
|
|
159
|
+
expect(result).toMatch(/^[0-9a-f]+$/);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("returns null for invalid URLs", () => {
|
|
163
|
+
expect(generateLocalFilename("not-a-url")).toBeNull();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("getExtensionFromContentType", () => {
|
|
168
|
+
it("returns jpg for image/jpeg", () => {
|
|
169
|
+
expect(getExtensionFromContentType("image/jpeg")).toBe("jpg");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("returns jpg for image/jpg", () => {
|
|
173
|
+
expect(getExtensionFromContentType("image/jpg")).toBe("jpg");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("returns png for image/png", () => {
|
|
177
|
+
expect(getExtensionFromContentType("image/png")).toBe("png");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("returns gif for image/gif", () => {
|
|
181
|
+
expect(getExtensionFromContentType("image/gif")).toBe("gif");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("returns webp for image/webp", () => {
|
|
185
|
+
expect(getExtensionFromContentType("image/webp")).toBe("webp");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("returns svg for image/svg+xml", () => {
|
|
189
|
+
expect(getExtensionFromContentType("image/svg+xml")).toBe("svg");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("handles content-type with charset", () => {
|
|
193
|
+
expect(getExtensionFromContentType("image/png; charset=utf-8")).toBe("png");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("returns null for unknown content types", () => {
|
|
197
|
+
expect(getExtensionFromContentType("application/json")).toBeNull();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("returns null for empty string", () => {
|
|
201
|
+
expect(getExtensionFromContentType("")).toBeNull();
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe("uint8ArrayToBase64", () => {
|
|
206
|
+
it("converts small arrays correctly", () => {
|
|
207
|
+
const bytes = new Uint8Array([72, 101, 108, 108, 111]); // "Hello"
|
|
208
|
+
expect(uint8ArrayToBase64(bytes)).toBe("SGVsbG8=");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("handles empty array", () => {
|
|
212
|
+
const bytes = new Uint8Array([]);
|
|
213
|
+
expect(uint8ArrayToBase64(bytes)).toBe("");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("handles large arrays without stack overflow", () => {
|
|
217
|
+
// Create a 100KB array
|
|
218
|
+
const bytes = new Uint8Array(100000);
|
|
219
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
220
|
+
bytes[i] = i % 256;
|
|
221
|
+
}
|
|
222
|
+
// Should not throw
|
|
223
|
+
const result = uint8ArrayToBase64(bytes);
|
|
224
|
+
expect(result.length).toBeGreaterThan(0);
|
|
225
|
+
});
|
|
226
|
+
});
|