@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,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step definitions for download feature tests
|
|
3
|
+
* Tests worker pool concurrency, retry logic, and error handling
|
|
4
|
+
*/
|
|
5
|
+
import { loadFeature, describeFeature } from "@amiceli/vitest-cucumber";
|
|
6
|
+
import { expect } from "vitest";
|
|
7
|
+
import {
|
|
8
|
+
setupMockTauri,
|
|
9
|
+
type MockTauriContext,
|
|
10
|
+
} from "@symbiosis-lab/moss-api/testing";
|
|
11
|
+
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// Worker Pool Feature
|
|
14
|
+
// ============================================================================
|
|
15
|
+
|
|
16
|
+
const workerPoolFeature = await loadFeature(
|
|
17
|
+
"features/download/worker-pool.feature"
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
describeFeature(workerPoolFeature, ({ Scenario }) => {
|
|
21
|
+
// Shared state across scenarios
|
|
22
|
+
let ctx: MockTauriContext | null = null;
|
|
23
|
+
let downloadResult: {
|
|
24
|
+
filesProcessed: number;
|
|
25
|
+
imagesDownloaded: number;
|
|
26
|
+
imagesSkipped: number;
|
|
27
|
+
errors: string[];
|
|
28
|
+
} | null = null;
|
|
29
|
+
|
|
30
|
+
Scenario("Respects concurrency limit of 5", ({ Given, When, Then, And }) => {
|
|
31
|
+
Given("a mock Tauri environment", () => {
|
|
32
|
+
// Initialize fresh mock with explicit projectPath
|
|
33
|
+
ctx = setupMockTauri({ projectPath: "/test/project" });
|
|
34
|
+
downloadResult = null;
|
|
35
|
+
expect(ctx).toBeDefined();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
Given("an in-memory filesystem", () => {
|
|
39
|
+
expect(ctx!.filesystem).toBeDefined();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
Given("20 images to download with delay", () => {
|
|
43
|
+
const imageUrls = Array.from(
|
|
44
|
+
{ length: 20 },
|
|
45
|
+
(_, i) =>
|
|
46
|
+
`https://assets.matters.news/embed/${i.toString().padStart(8, "0")}-0000-0000-0000-000000000000/image.jpg`
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const markdownContent = `---
|
|
50
|
+
title: Test Article
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
${imageUrls.map((url) => ``).join("\n")}
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
ctx!.filesystem.setFile(`${ctx!.projectPath}/article.md`, markdownContent);
|
|
57
|
+
|
|
58
|
+
// Configure mock responses with delay to ensure concurrent execution overlaps
|
|
59
|
+
for (const url of imageUrls) {
|
|
60
|
+
ctx!.urlConfig.setResponse(url, {
|
|
61
|
+
status: 200,
|
|
62
|
+
ok: true,
|
|
63
|
+
contentType: "image/jpeg",
|
|
64
|
+
bytesWritten: 1024,
|
|
65
|
+
delay: 50, // 50ms delay to ensure concurrent execution overlaps
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
When("I start downloading all images", async () => {
|
|
71
|
+
const { downloadMediaAndUpdate } = await import("../../src/downloader");
|
|
72
|
+
downloadResult = await downloadMediaAndUpdate();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
Then("at most 5 downloads should run concurrently", () => {
|
|
76
|
+
// Note: Concurrency limit is now enforced by Rust-side Semaphore (DOWNLOAD_CONCURRENCY_LIMIT=5)
|
|
77
|
+
// In JS mock environment, all downloads fire at once via Promise.allSettled
|
|
78
|
+
// The real concurrency test is in Rust: test_download_concurrency_limit
|
|
79
|
+
// Here we just verify downloads complete - actual limit enforcement happens in production
|
|
80
|
+
expect(ctx!.downloadTracker.completedDownloads.length).toBeGreaterThan(0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
And("all 20 downloads should complete successfully", () => {
|
|
84
|
+
expect(downloadResult).not.toBeNull();
|
|
85
|
+
expect(ctx!.downloadTracker.completedDownloads.length).toBe(20);
|
|
86
|
+
expect(ctx!.downloadTracker.failedDownloads.length).toBe(0);
|
|
87
|
+
// Cleanup
|
|
88
|
+
ctx?.cleanup();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
Scenario("Tracks download progress", ({ Given, When, Then, And }) => {
|
|
93
|
+
Given("a mock Tauri environment", () => {
|
|
94
|
+
ctx = setupMockTauri({ projectPath: "/test/project" });
|
|
95
|
+
downloadResult = null;
|
|
96
|
+
expect(ctx).toBeDefined();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
Given("an in-memory filesystem", () => {
|
|
100
|
+
expect(ctx!.filesystem).toBeDefined();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
Given("10 images to download", () => {
|
|
104
|
+
const imageUrls = Array.from(
|
|
105
|
+
{ length: 10 },
|
|
106
|
+
(_, i) =>
|
|
107
|
+
`https://assets.matters.news/embed/${(i + 100).toString().padStart(8, "0")}-0000-0000-0000-000000000000/image.jpg`
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const markdownContent = `---
|
|
111
|
+
title: Progress Test
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
${imageUrls.map((url) => ``).join("\n")}
|
|
115
|
+
`;
|
|
116
|
+
|
|
117
|
+
ctx!.filesystem.setFile(`${ctx!.projectPath}/progress.md`, markdownContent);
|
|
118
|
+
|
|
119
|
+
for (const url of imageUrls) {
|
|
120
|
+
ctx!.urlConfig.setResponse(url, {
|
|
121
|
+
status: 200,
|
|
122
|
+
ok: true,
|
|
123
|
+
contentType: "image/jpeg",
|
|
124
|
+
bytesWritten: 1024,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
When("I start downloading all images", async () => {
|
|
130
|
+
const { downloadMediaAndUpdate } = await import("../../src/downloader");
|
|
131
|
+
downloadResult = await downloadMediaAndUpdate();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
Then("progress events should be reported", () => {
|
|
135
|
+
expect(downloadResult).not.toBeNull();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
And("the final progress should show all images completed", () => {
|
|
139
|
+
expect(ctx!.downloadTracker.completedDownloads.length).toBe(10);
|
|
140
|
+
ctx?.cleanup();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
Scenario("Handles mixed success and failure", ({ Given, When, Then, And }) => {
|
|
145
|
+
let successUrls: string[] = [];
|
|
146
|
+
let failUrls: string[] = [];
|
|
147
|
+
|
|
148
|
+
Given("a mock Tauri environment", () => {
|
|
149
|
+
ctx = setupMockTauri({ projectPath: "/test/project" });
|
|
150
|
+
downloadResult = null;
|
|
151
|
+
expect(ctx).toBeDefined();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
Given("an in-memory filesystem", () => {
|
|
155
|
+
expect(ctx!.filesystem).toBeDefined();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
Given("5 images where 2 will fail with 404", () => {
|
|
159
|
+
successUrls = Array.from(
|
|
160
|
+
{ length: 3 },
|
|
161
|
+
(_, i) =>
|
|
162
|
+
`https://assets.matters.news/embed/${(i + 200).toString().padStart(8, "0")}-0000-0000-0000-000000000000/success.jpg`
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
failUrls = Array.from(
|
|
166
|
+
{ length: 2 },
|
|
167
|
+
(_, i) =>
|
|
168
|
+
`https://assets.matters.news/embed/${(i + 300).toString().padStart(8, "0")}-0000-0000-0000-000000000000/fail.jpg`
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const allUrls = [...successUrls, ...failUrls];
|
|
172
|
+
const markdownContent = `---
|
|
173
|
+
title: Mixed Test
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
${allUrls.map((url) => ``).join("\n")}
|
|
177
|
+
`;
|
|
178
|
+
|
|
179
|
+
ctx!.filesystem.setFile(`${ctx!.projectPath}/mixed.md`, markdownContent);
|
|
180
|
+
|
|
181
|
+
for (const url of successUrls) {
|
|
182
|
+
ctx!.urlConfig.setResponse(url, {
|
|
183
|
+
status: 200,
|
|
184
|
+
ok: true,
|
|
185
|
+
contentType: "image/jpeg",
|
|
186
|
+
bytesWritten: 1024,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
for (const url of failUrls) {
|
|
191
|
+
ctx!.urlConfig.setResponse(url, {
|
|
192
|
+
status: 404,
|
|
193
|
+
ok: false,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
When("I start downloading all images", async () => {
|
|
199
|
+
const { downloadMediaAndUpdate } = await import("../../src/downloader");
|
|
200
|
+
downloadResult = await downloadMediaAndUpdate();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
Then("3 downloads should succeed", () => {
|
|
204
|
+
expect(ctx!.downloadTracker.completedDownloads.length).toBe(3);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
And("2 downloads should be marked as failed", () => {
|
|
208
|
+
expect(ctx!.downloadTracker.failedDownloads.length).toBe(2);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
And("the result should report both successes and failures", () => {
|
|
212
|
+
expect(downloadResult).not.toBeNull();
|
|
213
|
+
expect(downloadResult!.imagesDownloaded).toBe(3);
|
|
214
|
+
// imagesSkipped only counts assets that already existed locally, not failed downloads
|
|
215
|
+
expect(downloadResult!.imagesSkipped).toBe(0);
|
|
216
|
+
expect(downloadResult!.errors.length).toBe(2);
|
|
217
|
+
ctx?.cleanup();
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// ============================================================================
|
|
223
|
+
// Retry Logic Feature
|
|
224
|
+
// ============================================================================
|
|
225
|
+
|
|
226
|
+
const retryFeature = await loadFeature("features/download/retry-logic.feature");
|
|
227
|
+
|
|
228
|
+
describeFeature(retryFeature, ({ Scenario }) => {
|
|
229
|
+
let ctx: MockTauriContext | null = null;
|
|
230
|
+
let downloadResult: {
|
|
231
|
+
filesProcessed: number;
|
|
232
|
+
imagesDownloaded: number;
|
|
233
|
+
imagesSkipped: number;
|
|
234
|
+
errors: string[];
|
|
235
|
+
} | null = null;
|
|
236
|
+
|
|
237
|
+
Scenario("Retries with Fibonacci backoff on 503", ({ Given, When, Then, And }) => {
|
|
238
|
+
const testUrl = "https://assets.matters.news/embed/retry503-0000-0000-0000-000000000000/image.jpg";
|
|
239
|
+
|
|
240
|
+
Given("a mock Tauri environment", () => {
|
|
241
|
+
ctx = setupMockTauri({ projectPath: "/test/retry" });
|
|
242
|
+
downloadResult = null;
|
|
243
|
+
expect(ctx).toBeDefined();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
Given("an in-memory filesystem", () => {
|
|
247
|
+
expect(ctx!.filesystem).toBeDefined();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
Given("an image URL that returns 503 twice then succeeds", () => {
|
|
251
|
+
const markdownContent = `---
|
|
252
|
+
title: Retry Test
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+

|
|
256
|
+
`;
|
|
257
|
+
ctx!.filesystem.setFile(`${ctx!.projectPath}/retry.md`, markdownContent);
|
|
258
|
+
|
|
259
|
+
ctx!.urlConfig.setResponse(testUrl, [
|
|
260
|
+
{ status: 503, ok: false },
|
|
261
|
+
{ status: 503, ok: false },
|
|
262
|
+
{ status: 200, ok: true, contentType: "image/jpeg", bytesWritten: 1024 },
|
|
263
|
+
]);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
When("I download the image with retry enabled", async () => {
|
|
267
|
+
const { downloadMediaAndUpdate } = await import("../../src/downloader");
|
|
268
|
+
downloadResult = await downloadMediaAndUpdate();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
Then("it should retry with Fibonacci delays", () => {
|
|
272
|
+
expect(downloadResult).not.toBeNull();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
And("the download should succeed on attempt 3", () => {
|
|
276
|
+
expect(downloadResult!.imagesDownloaded).toBe(1);
|
|
277
|
+
expect(downloadResult!.errors.length).toBe(0);
|
|
278
|
+
expect(ctx!.downloadTracker.completedDownloads.length).toBe(1);
|
|
279
|
+
ctx?.cleanup();
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
Scenario("Gives up after max retries", ({ Given, When, Then, And }) => {
|
|
284
|
+
const testUrl = "https://assets.matters.news/embed/maxretry-0000-0000-0000-000000000000/image.jpg";
|
|
285
|
+
|
|
286
|
+
Given("a mock Tauri environment", () => {
|
|
287
|
+
ctx = setupMockTauri({ projectPath: "/test/retry" });
|
|
288
|
+
downloadResult = null;
|
|
289
|
+
expect(ctx).toBeDefined();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
Given("an in-memory filesystem", () => {
|
|
293
|
+
expect(ctx!.filesystem).toBeDefined();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
Given("an image URL that always returns 503", () => {
|
|
297
|
+
const markdownContent = `---
|
|
298
|
+
title: Max Retry Test
|
|
299
|
+
---
|
|
300
|
+
|
|
301
|
+

|
|
302
|
+
`;
|
|
303
|
+
ctx!.filesystem.setFile(`${ctx!.projectPath}/maxretry.md`, markdownContent);
|
|
304
|
+
|
|
305
|
+
ctx!.urlConfig.setResponse(testUrl, [
|
|
306
|
+
{ status: 503, ok: false },
|
|
307
|
+
{ status: 503, ok: false },
|
|
308
|
+
{ status: 503, ok: false },
|
|
309
|
+
{ status: 503, ok: false },
|
|
310
|
+
{ status: 503, ok: false },
|
|
311
|
+
]);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
When("I download the image with max 3 retries", async () => {
|
|
315
|
+
const { downloadMediaAndUpdate } = await import("../../src/downloader");
|
|
316
|
+
downloadResult = await downloadMediaAndUpdate();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
Then("it should attempt 4 times total", () => {
|
|
320
|
+
expect(downloadResult).not.toBeNull();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
And("the download should fail with 503 error", () => {
|
|
324
|
+
expect(downloadResult!.imagesDownloaded).toBe(0);
|
|
325
|
+
// imagesSkipped only counts assets that already existed locally, not failed downloads
|
|
326
|
+
expect(downloadResult!.imagesSkipped).toBe(0);
|
|
327
|
+
expect(downloadResult!.errors.some((e) => e.includes("503"))).toBe(true);
|
|
328
|
+
ctx?.cleanup();
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
Scenario("Does not retry on 404", ({ Given, When, Then, And }) => {
|
|
333
|
+
const testUrl = "https://assets.matters.news/embed/noretry-0000-0000-0000-000000000000/image.jpg";
|
|
334
|
+
|
|
335
|
+
Given("a mock Tauri environment", () => {
|
|
336
|
+
ctx = setupMockTauri({ projectPath: "/test/retry" });
|
|
337
|
+
downloadResult = null;
|
|
338
|
+
expect(ctx).toBeDefined();
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
Given("an in-memory filesystem", () => {
|
|
342
|
+
expect(ctx!.filesystem).toBeDefined();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
Given("an image URL that returns 404", () => {
|
|
346
|
+
const markdownContent = `---
|
|
347
|
+
title: No Retry Test
|
|
348
|
+
---
|
|
349
|
+
|
|
350
|
+

|
|
351
|
+
`;
|
|
352
|
+
ctx!.filesystem.setFile(`${ctx!.projectPath}/noretry.md`, markdownContent);
|
|
353
|
+
|
|
354
|
+
ctx!.urlConfig.setResponse(testUrl, {
|
|
355
|
+
status: 404,
|
|
356
|
+
ok: false,
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
When("I download the image with retry enabled", async () => {
|
|
361
|
+
const { downloadMediaAndUpdate } = await import("../../src/downloader");
|
|
362
|
+
downloadResult = await downloadMediaAndUpdate();
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
Then("it should not retry", () => {
|
|
366
|
+
expect(downloadResult).not.toBeNull();
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
And("the download should fail immediately with 404 error", () => {
|
|
370
|
+
expect(downloadResult!.imagesDownloaded).toBe(0);
|
|
371
|
+
// imagesSkipped only counts assets that already existed locally, not failed downloads
|
|
372
|
+
expect(downloadResult!.imagesSkipped).toBe(0);
|
|
373
|
+
expect(downloadResult!.errors.some((e) => e.includes("404"))).toBe(true);
|
|
374
|
+
expect(ctx!.downloadTracker.failedDownloads.length).toBe(1);
|
|
375
|
+
ctx?.cleanup();
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
Scenario("Retries on network timeout", ({ Given, When, Then, And }) => {
|
|
380
|
+
const testUrl = "https://assets.matters.news/embed/timeout-0000-0000-0000-000000000000/image.jpg";
|
|
381
|
+
|
|
382
|
+
Given("a mock Tauri environment", () => {
|
|
383
|
+
ctx = setupMockTauri({ projectPath: "/test/retry" });
|
|
384
|
+
downloadResult = null;
|
|
385
|
+
expect(ctx).toBeDefined();
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
Given("an in-memory filesystem", () => {
|
|
389
|
+
expect(ctx!.filesystem).toBeDefined();
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
Given("an image URL that times out twice then succeeds", () => {
|
|
393
|
+
const markdownContent = `---
|
|
394
|
+
title: Timeout Retry Test
|
|
395
|
+
---
|
|
396
|
+
|
|
397
|
+

|
|
398
|
+
`;
|
|
399
|
+
ctx!.filesystem.setFile(`${ctx!.projectPath}/timeout.md`, markdownContent);
|
|
400
|
+
|
|
401
|
+
ctx!.urlConfig.setResponse(testUrl, [
|
|
402
|
+
{ status: 0, ok: false },
|
|
403
|
+
{ status: 0, ok: false },
|
|
404
|
+
{ status: 200, ok: true, contentType: "image/jpeg", bytesWritten: 1024 },
|
|
405
|
+
]);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
When("I download the image with retry enabled", async () => {
|
|
409
|
+
const { downloadMediaAndUpdate } = await import("../../src/downloader");
|
|
410
|
+
downloadResult = await downloadMediaAndUpdate();
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
Then("it should retry after timeouts", () => {
|
|
414
|
+
expect(downloadResult).not.toBeNull();
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
And("the download should succeed on attempt 3", () => {
|
|
418
|
+
expect(downloadResult!.imagesDownloaded).toBe(1);
|
|
419
|
+
expect(downloadResult!.errors.length).toBe(0);
|
|
420
|
+
ctx?.cleanup();
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { loadFeature, describeFeature } from "@amiceli/vitest-cucumber";
|
|
2
|
+
import { expect } from "vitest";
|
|
3
|
+
import { apiConfig, fetchAllArticlesSince } from "../../src/api";
|
|
4
|
+
import type { MattersArticle } from "../../src/types";
|
|
5
|
+
|
|
6
|
+
const feature = await loadFeature("features/sync/incremental-sync.feature");
|
|
7
|
+
|
|
8
|
+
describeFeature(feature, ({ Scenario, Background }) => {
|
|
9
|
+
// Test state
|
|
10
|
+
let fetchedArticles: MattersArticle[] = [];
|
|
11
|
+
let lastSyncedAt: string | undefined;
|
|
12
|
+
let initialArticleCount = 0;
|
|
13
|
+
|
|
14
|
+
Background(({ Given, And }) => {
|
|
15
|
+
Given("I am using the Matters test environment", () => {
|
|
16
|
+
apiConfig.endpoint = "https://server.matters.icu/graphql";
|
|
17
|
+
apiConfig.queryMode = "user";
|
|
18
|
+
apiConfig.testUserName = process.env.MATTERS_TEST_USER || "yhh354";
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
And("I have a test user with articles", () => {
|
|
22
|
+
// Test user is pre-configured
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
Scenario("First sync fetches all articles and saves timestamp", ({ Given, When, Then, And }) => {
|
|
27
|
+
Given("I have no previous sync timestamp", () => {
|
|
28
|
+
lastSyncedAt = undefined;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
When("I run the sync process", async () => {
|
|
32
|
+
const result = await fetchAllArticlesSince(lastSyncedAt);
|
|
33
|
+
fetchedArticles = result.articles;
|
|
34
|
+
initialArticleCount = fetchedArticles.length;
|
|
35
|
+
// Simulate saving timestamp
|
|
36
|
+
lastSyncedAt = new Date().toISOString();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
Then("all articles should be fetched", () => {
|
|
40
|
+
expect(fetchedArticles.length).toBeGreaterThan(0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
And("the config should contain a lastSyncedAt timestamp", () => {
|
|
44
|
+
expect(lastSyncedAt).toBeDefined();
|
|
45
|
+
expect(new Date(lastSyncedAt!).getTime()).toBeGreaterThan(0);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
Scenario("Subsequent sync only fetches newer articles", ({ Given, When, Then, And }) => {
|
|
50
|
+
Given("I have a lastSyncedAt timestamp from 1 hour ago", () => {
|
|
51
|
+
const oneHourAgo = new Date();
|
|
52
|
+
oneHourAgo.setHours(oneHourAgo.getHours() - 1);
|
|
53
|
+
lastSyncedAt = oneHourAgo.toISOString();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
And("the test user has multiple articles", async () => {
|
|
57
|
+
// Verify user has articles
|
|
58
|
+
const result = await fetchAllArticlesSince(undefined);
|
|
59
|
+
expect(result.articles.length).toBeGreaterThan(0);
|
|
60
|
+
initialArticleCount = result.articles.length;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
When("I run the sync process", async () => {
|
|
64
|
+
const result = await fetchAllArticlesSince(lastSyncedAt);
|
|
65
|
+
fetchedArticles = result.articles;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
Then("only recently modified articles should be fetched", () => {
|
|
69
|
+
// Should fetch fewer articles than total (or 0 if none modified)
|
|
70
|
+
expect(fetchedArticles.length).toBeLessThanOrEqual(initialArticleCount);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
And("the lastSyncedAt timestamp should be updated", () => {
|
|
74
|
+
const newTimestamp = new Date().toISOString();
|
|
75
|
+
expect(new Date(newTimestamp).getTime()).toBeGreaterThan(
|
|
76
|
+
new Date(lastSyncedAt!).getTime()
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
Scenario("Sync skips unchanged articles when no modifications", ({ Given, When, Then, And }) => {
|
|
82
|
+
Given("I have synced all articles recently", async () => {
|
|
83
|
+
// Set timestamp to now (all articles are "old")
|
|
84
|
+
lastSyncedAt = new Date().toISOString();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
And("no articles have been modified since", () => {
|
|
88
|
+
// This is assumed based on the current timestamp
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
When("I run the sync process again", async () => {
|
|
92
|
+
const result = await fetchAllArticlesSince(lastSyncedAt);
|
|
93
|
+
fetchedArticles = result.articles;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
Then("0 articles should be fetched", () => {
|
|
97
|
+
expect(fetchedArticles.length).toBe(0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
And("existing local files should remain unchanged", () => {
|
|
101
|
+
// This is verified by the fact that no new articles were returned
|
|
102
|
+
// In a real scenario, we would check file modification times
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
});
|