pushwork 1.1.8 → 1.2.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/ARCHITECTURE-ACCORDING-TO-CLAUDE.md +17 -11
- package/CLAUDE.md +46 -1
- package/README.md +18 -4
- package/dist/cli.js +45 -4
- package/dist/cli.js.map +1 -1
- package/dist/commands.d.ts +1 -0
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +151 -38
- package/dist/commands.js.map +1 -1
- package/dist/core/change-detection.js +2 -2
- package/dist/core/change-detection.js.map +1 -1
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +3 -0
- package/dist/core/config.js.map +1 -1
- package/dist/core/move-detection.d.ts.map +1 -1
- package/dist/core/move-detection.js +4 -1
- package/dist/core/move-detection.js.map +1 -1
- package/dist/core/sync-engine.d.ts +7 -3
- package/dist/core/sync-engine.d.ts.map +1 -1
- package/dist/core/sync-engine.js +40 -14
- package/dist/core/sync-engine.js.map +1 -1
- package/dist/types/config.d.ts +4 -0
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js +2 -1
- package/dist/types/config.js.map +1 -1
- package/dist/utils/content.js +1 -1
- package/dist/utils/content.js.map +1 -1
- package/dist/utils/network-sync.d.ts +1 -2
- package/dist/utils/network-sync.d.ts.map +1 -1
- package/dist/utils/network-sync.js +76 -7
- package/dist/utils/network-sync.js.map +1 -1
- package/dist/utils/output.js +7 -7
- package/dist/utils/output.js.map +1 -1
- package/dist/utils/repo-factory.d.ts +11 -3
- package/dist/utils/repo-factory.d.ts.map +1 -1
- package/dist/utils/repo-factory.js +112 -8
- package/dist/utils/repo-factory.js.map +1 -1
- package/flake.lock +128 -0
- package/flake.nix +66 -0
- package/package.json +98 -96
- package/scripts/roundtrip-test.sh +35 -0
- package/src/cli.ts +53 -6
- package/src/commands.ts +150 -26
- package/src/core/change-detection.ts +2 -2
- package/src/core/config.ts +4 -0
- package/src/core/move-detection.ts +3 -1
- package/src/core/sync-engine.ts +40 -15
- package/src/types/config.ts +4 -0
- package/src/utils/content.ts +1 -1
- package/src/utils/network-sync.ts +92 -8
- package/src/utils/output.ts +7 -7
- package/src/utils/repo-factory.ts +124 -10
- package/test/integration/clone-test.sh +0 -0
- package/test/integration/conflict-resolution-test.sh +0 -0
- package/test/integration/deletion-behavior-test.sh +0 -0
- package/test/integration/deletion-sync-test-simple.sh +0 -0
- package/test/integration/deletion-sync-test.sh +0 -0
- package/test/integration/full-integration-test.sh +0 -0
- package/test/integration/manual-sync-test.sh +0 -0
- package/test/integration/sub-flag.test.ts +187 -0
- package/test/run-tests.sh +0 -0
- package/test/unit/network-sync-sub.test.ts +144 -0
- package/test/unit/repo-factory.test.ts +111 -0
- package/test/unit/subduction-config.test.ts +69 -0
- package/dist/cli/commands.d.ts +0 -71
- package/dist/cli/commands.d.ts.map +0 -1
- package/dist/cli/commands.js +0 -794
- package/dist/cli/commands.js.map +0 -1
- package/dist/cli/index.d.ts +0 -2
- package/dist/cli/index.d.ts.map +0 -1
- package/dist/cli/index.js +0 -19
- package/dist/cli/index.js.map +0 -1
- package/dist/config/index.d.ts +0 -71
- package/dist/config/index.d.ts.map +0 -1
- package/dist/config/index.js +0 -314
- package/dist/config/index.js.map +0 -1
- package/dist/utils/content-similarity.d.ts +0 -53
- package/dist/utils/content-similarity.d.ts.map +0 -1
- package/dist/utils/content-similarity.js +0 -155
- package/dist/utils/content-similarity.js.map +0 -1
- package/dist/utils/node-polyfills.d.ts +0 -9
- package/dist/utils/node-polyfills.d.ts.map +0 -1
- package/dist/utils/node-polyfills.js +0 -9
- package/dist/utils/node-polyfills.js.map +0 -1
|
@@ -21,13 +21,11 @@ function debug(...args: any[]) {
|
|
|
21
21
|
*
|
|
22
22
|
* @param repo - The Automerge repository
|
|
23
23
|
* @param rootDirectoryUrl - The root directory URL to start traversal from
|
|
24
|
-
* @param syncServerStorageId - The sync server storage ID
|
|
25
24
|
* @param options - Configuration options
|
|
26
25
|
*/
|
|
27
26
|
export async function waitForBidirectionalSync(
|
|
28
27
|
repo: Repo,
|
|
29
28
|
rootDirectoryUrl: AutomergeUrl | undefined,
|
|
30
|
-
syncServerStorageId: StorageId | undefined,
|
|
31
29
|
options: {
|
|
32
30
|
timeoutMs?: number;
|
|
33
31
|
pollIntervalMs?: number;
|
|
@@ -42,7 +40,7 @@ export async function waitForBidirectionalSync(
|
|
|
42
40
|
handles,
|
|
43
41
|
} = options;
|
|
44
42
|
|
|
45
|
-
if (!
|
|
43
|
+
if (!rootDirectoryUrl) {
|
|
46
44
|
return;
|
|
47
45
|
}
|
|
48
46
|
|
|
@@ -295,16 +293,20 @@ export async function waitForSync(
|
|
|
295
293
|
): Promise<SyncWaitResult> {
|
|
296
294
|
const startTime = Date.now();
|
|
297
295
|
|
|
298
|
-
if (!syncServerStorageId) {
|
|
299
|
-
debug("waitForSync: no sync server storage ID, skipping");
|
|
300
|
-
return { failed: [] };
|
|
301
|
-
}
|
|
302
|
-
|
|
303
296
|
if (handlesToWaitOn.length === 0) {
|
|
304
297
|
debug("waitForSync: no documents to sync");
|
|
305
298
|
return { failed: [] };
|
|
306
299
|
}
|
|
307
300
|
|
|
301
|
+
// When no StorageId is available (Subduction mode), use head-stability
|
|
302
|
+
// polling. The SubductionSource handles sync internally — we just wait
|
|
303
|
+
// for each handle's heads to stop changing.
|
|
304
|
+
if (!syncServerStorageId) {
|
|
305
|
+
debug(`waitForSync: no storage ID, using head-stability polling for ${handlesToWaitOn.length} documents`);
|
|
306
|
+
out.taskLine(`Waiting for ${handlesToWaitOn.length} documents to sync`);
|
|
307
|
+
return waitForSyncViaHeadStability(handlesToWaitOn, timeoutMs, startTime);
|
|
308
|
+
}
|
|
309
|
+
|
|
308
310
|
debug(`waitForSync: waiting for ${handlesToWaitOn.length} documents (timeout=${timeoutMs}ms, batchSize=${SYNC_BATCH_SIZE})`);
|
|
309
311
|
|
|
310
312
|
// Separate already-synced from needs-sync
|
|
@@ -370,3 +372,85 @@ export async function waitForSync(
|
|
|
370
372
|
|
|
371
373
|
return { failed };
|
|
372
374
|
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Wait for sync by polling head stability (Subduction mode).
|
|
378
|
+
* Each handle's heads are polled until they remain unchanged for
|
|
379
|
+
* several consecutive checks, indicating the SubductionSource has
|
|
380
|
+
* finished syncing.
|
|
381
|
+
*/
|
|
382
|
+
async function waitForSyncViaHeadStability(
|
|
383
|
+
handles: DocHandle<unknown>[],
|
|
384
|
+
timeoutMs: number,
|
|
385
|
+
startTime: number,
|
|
386
|
+
): Promise<SyncWaitResult> {
|
|
387
|
+
const failed: DocHandle<unknown>[] = [];
|
|
388
|
+
let synced = 0;
|
|
389
|
+
|
|
390
|
+
// Process in batches
|
|
391
|
+
for (let i = 0; i < handles.length; i += SYNC_BATCH_SIZE) {
|
|
392
|
+
const batch = handles.slice(i, i + SYNC_BATCH_SIZE);
|
|
393
|
+
|
|
394
|
+
const results = await Promise.allSettled(
|
|
395
|
+
batch.map(handle => waitForHandleHeadStability(handle, timeoutMs, startTime))
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
for (const result of results) {
|
|
399
|
+
if (result.status === "rejected") {
|
|
400
|
+
failed.push(result.reason as DocHandle<unknown>);
|
|
401
|
+
} else {
|
|
402
|
+
synced++;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const elapsed = Date.now() - startTime;
|
|
408
|
+
if (failed.length > 0) {
|
|
409
|
+
debug(`waitForSync(heads): ${failed.length} documents failed after ${elapsed}ms`);
|
|
410
|
+
out.taskLine(`Sync: ${synced} synced, ${failed.length} timed out after ${(elapsed / 1000).toFixed(1)}s`, true);
|
|
411
|
+
} else {
|
|
412
|
+
debug(`waitForSync(heads): all ${handles.length} documents synced in ${elapsed}ms`);
|
|
413
|
+
out.taskLine(`All ${handles.length} documents synced (${(elapsed / 1000).toFixed(1)}s)`);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return { failed };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Wait for a single handle's heads to stabilize.
|
|
421
|
+
* Polls heads at 100ms intervals; resolves after 3 consecutive stable
|
|
422
|
+
* checks, rejects on timeout.
|
|
423
|
+
*/
|
|
424
|
+
function waitForHandleHeadStability(
|
|
425
|
+
handle: DocHandle<unknown>,
|
|
426
|
+
timeoutMs: number,
|
|
427
|
+
startTime: number,
|
|
428
|
+
): Promise<DocHandle<unknown>> {
|
|
429
|
+
return new Promise<DocHandle<unknown>>((resolve, reject) => {
|
|
430
|
+
let lastHeads = JSON.stringify(handle.heads());
|
|
431
|
+
let stableCount = 0;
|
|
432
|
+
const stableRequired = 3;
|
|
433
|
+
|
|
434
|
+
const pollInterval = setInterval(() => {
|
|
435
|
+
const currentHeads = JSON.stringify(handle.heads());
|
|
436
|
+
if (currentHeads === lastHeads) {
|
|
437
|
+
stableCount++;
|
|
438
|
+
if (stableCount >= stableRequired) {
|
|
439
|
+
clearInterval(pollInterval);
|
|
440
|
+
clearTimeout(timeout);
|
|
441
|
+
debug(`waitForSync(heads): ${handle.url}... converged in ${Date.now() - startTime}ms`);
|
|
442
|
+
resolve(handle);
|
|
443
|
+
}
|
|
444
|
+
} else {
|
|
445
|
+
stableCount = 0;
|
|
446
|
+
lastHeads = currentHeads;
|
|
447
|
+
}
|
|
448
|
+
}, 100);
|
|
449
|
+
|
|
450
|
+
const timeout = setTimeout(() => {
|
|
451
|
+
clearInterval(pollInterval);
|
|
452
|
+
debug(`waitForSync(heads): ${handle.url}... timed out after ${timeoutMs}ms`);
|
|
453
|
+
reject(handle);
|
|
454
|
+
}, timeoutMs);
|
|
455
|
+
});
|
|
456
|
+
}
|
package/src/utils/output.ts
CHANGED
|
@@ -345,7 +345,7 @@ export class Output {
|
|
|
345
345
|
this.taskOriginalMessage = null;
|
|
346
346
|
this.taskCurrentMessage = null;
|
|
347
347
|
}
|
|
348
|
-
console.
|
|
348
|
+
console.error(
|
|
349
349
|
chalk.red(
|
|
350
350
|
message instanceof Error
|
|
351
351
|
? message.message
|
|
@@ -367,7 +367,7 @@ export class Output {
|
|
|
367
367
|
this.taskOriginalMessage = null;
|
|
368
368
|
this.taskCurrentMessage = null;
|
|
369
369
|
}
|
|
370
|
-
console.
|
|
370
|
+
console.error(
|
|
371
371
|
`\n${chalk.bgRed.white(` ${label} `)}${message && ` ${message}`}`
|
|
372
372
|
);
|
|
373
373
|
}
|
|
@@ -400,19 +400,19 @@ export class Output {
|
|
|
400
400
|
|
|
401
401
|
if (error instanceof Error) {
|
|
402
402
|
// Error type and message
|
|
403
|
-
console.
|
|
403
|
+
console.error(chalk.red(`${error.name}: ${error.message}`));
|
|
404
404
|
|
|
405
405
|
// Stack trace
|
|
406
406
|
if (error.stack) {
|
|
407
|
-
console.
|
|
408
|
-
console.
|
|
407
|
+
console.error("");
|
|
408
|
+
console.error(chalk.dim("Stack trace:"));
|
|
409
409
|
const stackLines = error.stack.split("\n").slice(1); // Skip first line (error message)
|
|
410
410
|
stackLines.forEach((line) =>
|
|
411
|
-
console.
|
|
411
|
+
console.error(chalk.dim(` ${line.trim()}`))
|
|
412
412
|
);
|
|
413
413
|
}
|
|
414
414
|
} else {
|
|
415
|
-
console.
|
|
415
|
+
console.error(chalk.red(String(error)));
|
|
416
416
|
}
|
|
417
417
|
|
|
418
418
|
process.exit(exitCode);
|
|
@@ -1,28 +1,142 @@
|
|
|
1
|
-
import { Repo } from "@automerge/automerge-repo";
|
|
1
|
+
import { type Repo, type RepoConfig, type NetworkAdapterInterface } from "@automerge/automerge-repo";
|
|
2
2
|
import { NodeFSStorageAdapter } from "@automerge/automerge-repo-storage-nodefs";
|
|
3
|
-
import
|
|
3
|
+
import * as fs from "fs/promises";
|
|
4
4
|
import * as path from "path";
|
|
5
5
|
import { DirectoryConfig } from "../types";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
8
|
+
* Perform a real ESM dynamic import that tsc won't rewrite to require().
|
|
9
|
+
*
|
|
10
|
+
* TypeScript with `"module": "commonjs"` compiles `await import("x")` to
|
|
11
|
+
* `require("x")`, which resolves CJS entries instead of ESM entries. The
|
|
12
|
+
* Wasm module instance is different between the CJS and ESM module graphs,
|
|
13
|
+
* so initializing via CJS require() doesn't help the ESM /slim imports
|
|
14
|
+
* inside automerge-repo.
|
|
15
|
+
*
|
|
16
|
+
* This helper uses `new Function` to create a real `import()` expression
|
|
17
|
+
* that Node.js evaluates as ESM, sharing the same module graph as the
|
|
18
|
+
* Repo's internal imports.
|
|
19
|
+
*/
|
|
20
|
+
const dynamicImport = new Function("specifier", "return import(specifier)") as (
|
|
21
|
+
specifier: string,
|
|
22
|
+
) => Promise<any>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Initialize the Subduction Wasm module and return the Repo constructor.
|
|
26
|
+
*
|
|
27
|
+
* The Repo constructor calls set_subduction_logger() and new MemorySigner()
|
|
28
|
+
* from @automerge/automerge-subduction/slim, which require the Wasm module
|
|
29
|
+
* to be initialized first. automerge-repo exports initSubduction() to
|
|
30
|
+
* handle this — it dynamically imports the non-/slim entry (which
|
|
31
|
+
* auto-initializes the Wasm as a side effect).
|
|
32
|
+
*
|
|
33
|
+
* Both the Repo and initSubduction must be loaded via ESM dynamic import()
|
|
34
|
+
* so they share the same module graph as the Repo's internal /slim imports.
|
|
35
|
+
*/
|
|
36
|
+
let cachedRepoClass: typeof Repo | undefined;
|
|
37
|
+
|
|
38
|
+
async function getRepoClass(): Promise<typeof Repo> {
|
|
39
|
+
if (cachedRepoClass) return cachedRepoClass;
|
|
40
|
+
|
|
41
|
+
// Import Repo and initialize Subduction Wasm via automerge-repo's
|
|
42
|
+
// initSubduction() helper. This must happen before new Repo() because
|
|
43
|
+
// the constructor calls set_subduction_logger() and new MemorySigner()
|
|
44
|
+
// which require the Wasm module to be ready.
|
|
45
|
+
//
|
|
46
|
+
// Both imports use the ESM dynamic import wrapper so they share the
|
|
47
|
+
// same module graph as the Repo's internal /slim imports.
|
|
48
|
+
const repoMod = await dynamicImport("@automerge/automerge-repo");
|
|
49
|
+
await repoMod.initSubduction();
|
|
50
|
+
cachedRepoClass = repoMod.Repo as typeof Repo;
|
|
51
|
+
return cachedRepoClass;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Scan a directory tree for 0-byte files, which indicate incomplete writes
|
|
56
|
+
* from a previous run (process exited before storage flushed). Returns true
|
|
57
|
+
* if any are found.
|
|
58
|
+
*/
|
|
59
|
+
async function hasCorruptStorage(dir: string): Promise<boolean> {
|
|
60
|
+
try {
|
|
61
|
+
await fs.access(dir);
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
67
|
+
for (const entry of entries) {
|
|
68
|
+
const fullPath = path.join(dir, entry.name);
|
|
69
|
+
if (entry.isDirectory()) {
|
|
70
|
+
if (await hasCorruptStorage(fullPath)) return true;
|
|
71
|
+
} else if (entry.isFile()) {
|
|
72
|
+
const stat = await fs.stat(fullPath);
|
|
73
|
+
if (stat.size === 0) return true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Create an Automerge repository with configuration-based setup.
|
|
81
|
+
*
|
|
82
|
+
* When `sub` is true, uses the Subduction sync backend built into
|
|
83
|
+
* automerge-repo. The Repo manages its own SubductionSource internally —
|
|
84
|
+
* we just pass `subductionWebsocketEndpoints` and the Repo handles
|
|
85
|
+
* connection management, sync, and retries.
|
|
86
|
+
*
|
|
87
|
+
* When `sub` is false (default), uses the traditional WebSocket network
|
|
88
|
+
* adapter for sync via the automerge sync server.
|
|
9
89
|
*/
|
|
10
90
|
export async function createRepo(
|
|
11
91
|
workingDir: string,
|
|
12
|
-
config: DirectoryConfig
|
|
92
|
+
config: DirectoryConfig,
|
|
93
|
+
sub: boolean = false
|
|
13
94
|
): Promise<Repo> {
|
|
95
|
+
const RepoClass = await getRepoClass();
|
|
96
|
+
|
|
14
97
|
const syncToolDir = path.join(workingDir, ".pushwork");
|
|
15
|
-
const
|
|
98
|
+
const automergeDir = path.join(syncToolDir, "automerge");
|
|
99
|
+
|
|
100
|
+
// Detect and recover from corrupt local storage (0-byte files left by
|
|
101
|
+
// incomplete writes from a previous run). Wipe the cache so the Repo
|
|
102
|
+
// hydrates cleanly from the sync server.
|
|
103
|
+
if (await hasCorruptStorage(automergeDir)) {
|
|
104
|
+
console.warn("[pushwork] Corrupt local storage detected, clearing cache...");
|
|
105
|
+
await fs.rm(automergeDir, { recursive: true, force: true });
|
|
106
|
+
await fs.mkdir(automergeDir, { recursive: true });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const storage = new NodeFSStorageAdapter(automergeDir);
|
|
110
|
+
|
|
111
|
+
if (sub) {
|
|
112
|
+
const endpoints: string[] = [];
|
|
113
|
+
if (config.sync_enabled && config.sync_server) {
|
|
114
|
+
endpoints.push(config.sync_server);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return new RepoClass({
|
|
118
|
+
storage,
|
|
119
|
+
subductionWebsocketEndpoints: endpoints,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
16
122
|
|
|
17
|
-
|
|
123
|
+
// Default: WebSocket sync adapter
|
|
124
|
+
const repoConfig: RepoConfig = { storage };
|
|
18
125
|
|
|
19
|
-
// Add network adapter only if sync is enabled and server is configured
|
|
20
126
|
if (config.sync_enabled && config.sync_server) {
|
|
21
|
-
|
|
127
|
+
// Load the WebSocket adapter via ESM dynamic import to stay in the
|
|
128
|
+
// same module graph as the Repo.
|
|
129
|
+
const wsMod = await dynamicImport("@automerge/automerge-repo-network-websocket");
|
|
130
|
+
// The websocket adapter package (subduction.8) hasn't updated its
|
|
131
|
+
// NetworkAdapter base-class types to match the repo's new
|
|
132
|
+
// NetworkAdapterInterface (which added state() and stricter
|
|
133
|
+
// EventEmitter generics). At runtime the adapter has all required
|
|
134
|
+
// methods; this is purely a declaration mismatch.
|
|
135
|
+
const networkAdapter = new wsMod.BrowserWebSocketClientAdapter(
|
|
22
136
|
config.sync_server
|
|
23
|
-
);
|
|
137
|
+
) as unknown as NetworkAdapterInterface;
|
|
24
138
|
repoConfig.network = [networkAdapter];
|
|
25
139
|
}
|
|
26
140
|
|
|
27
|
-
return new
|
|
141
|
+
return new RepoClass(repoConfig);
|
|
28
142
|
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import * as fs from "fs/promises";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import * as tmp from "tmp";
|
|
4
|
+
import { execSync, execFile as execFileCb } from "child_process";
|
|
5
|
+
import { promisify } from "util";
|
|
6
|
+
import { SnapshotManager } from "../../src/core";
|
|
7
|
+
|
|
8
|
+
const execFile = promisify(execFileCb);
|
|
9
|
+
|
|
10
|
+
describe("--sub flag integration", () => {
|
|
11
|
+
let tmpDir: string;
|
|
12
|
+
let cleanup: () => void;
|
|
13
|
+
const cliPath = path.join(__dirname, "../../dist/cli.js");
|
|
14
|
+
|
|
15
|
+
beforeAll(() => {
|
|
16
|
+
execSync("pnpm build", { cwd: path.join(__dirname, "../.."), stdio: "pipe" });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
const tmpObj = tmp.dirSync({ unsafeCleanup: true });
|
|
21
|
+
tmpDir = tmpObj.name;
|
|
22
|
+
cleanup = tmpObj.removeCallback;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
cleanup();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Run pushwork CLI command and return stdout.
|
|
31
|
+
* Throws on non-zero exit code.
|
|
32
|
+
*/
|
|
33
|
+
async function pushwork(args: string[], timeoutMs = 30000): Promise<string> {
|
|
34
|
+
const { stdout } = await execFile("node", [cliPath, ...args], {
|
|
35
|
+
timeout: timeoutMs,
|
|
36
|
+
env: { ...process.env, NO_COLOR: "1" },
|
|
37
|
+
});
|
|
38
|
+
return stdout;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("init --sub", () => {
|
|
42
|
+
it("should initialize a directory with --sub flag", async () => {
|
|
43
|
+
await fs.writeFile(path.join(tmpDir, "hello.txt"), "Hello from sub!");
|
|
44
|
+
|
|
45
|
+
await pushwork(["init", "--sub", tmpDir]);
|
|
46
|
+
|
|
47
|
+
// Verify .pushwork was created
|
|
48
|
+
const pushworkDir = path.join(tmpDir, ".pushwork");
|
|
49
|
+
const stat = await fs.stat(pushworkDir);
|
|
50
|
+
expect(stat.isDirectory()).toBe(true);
|
|
51
|
+
|
|
52
|
+
// Verify snapshot exists and tracks the file
|
|
53
|
+
const snapshotManager = new SnapshotManager(tmpDir);
|
|
54
|
+
const snapshot = await snapshotManager.load();
|
|
55
|
+
expect(snapshot).not.toBeNull();
|
|
56
|
+
expect(snapshot!.rootDirectoryUrl).toBeDefined();
|
|
57
|
+
expect(snapshot!.rootDirectoryUrl).toMatch(/^automerge:/);
|
|
58
|
+
expect(snapshot!.files.has("hello.txt")).toBe(true);
|
|
59
|
+
}, 60000);
|
|
60
|
+
|
|
61
|
+
it("should track files in subdirectories", async () => {
|
|
62
|
+
await fs.mkdir(path.join(tmpDir, "src"), { recursive: true });
|
|
63
|
+
await fs.writeFile(path.join(tmpDir, "src", "index.ts"), "export default {}");
|
|
64
|
+
await fs.writeFile(path.join(tmpDir, "package.json"), '{"name": "test"}');
|
|
65
|
+
|
|
66
|
+
await pushwork(["init", "--sub", tmpDir]);
|
|
67
|
+
|
|
68
|
+
const snapshotManager = new SnapshotManager(tmpDir);
|
|
69
|
+
const snapshot = await snapshotManager.load();
|
|
70
|
+
expect(snapshot).not.toBeNull();
|
|
71
|
+
expect(snapshot!.files.has("src/index.ts")).toBe(true);
|
|
72
|
+
expect(snapshot!.files.has("package.json")).toBe(true);
|
|
73
|
+
}, 60000);
|
|
74
|
+
|
|
75
|
+
it("should respect default exclude patterns with --sub", async () => {
|
|
76
|
+
await fs.writeFile(path.join(tmpDir, "included.txt"), "keep me");
|
|
77
|
+
await fs.mkdir(path.join(tmpDir, "node_modules"));
|
|
78
|
+
await fs.writeFile(path.join(tmpDir, "node_modules", "dep.js"), "module");
|
|
79
|
+
await fs.mkdir(path.join(tmpDir, ".git"));
|
|
80
|
+
await fs.writeFile(path.join(tmpDir, ".git", "HEAD"), "ref: refs/heads/main");
|
|
81
|
+
|
|
82
|
+
await pushwork(["init", "--sub", tmpDir]);
|
|
83
|
+
|
|
84
|
+
const snapshotManager = new SnapshotManager(tmpDir);
|
|
85
|
+
const snapshot = await snapshotManager.load();
|
|
86
|
+
expect(snapshot).not.toBeNull();
|
|
87
|
+
expect(snapshot!.files.has("included.txt")).toBe(true);
|
|
88
|
+
expect(snapshot!.files.has("node_modules/dep.js")).toBe(false);
|
|
89
|
+
expect(snapshot!.files.has(".git/HEAD")).toBe(false);
|
|
90
|
+
}, 60000);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("sync --sub", () => {
|
|
94
|
+
it("should sync after init --sub", async () => {
|
|
95
|
+
await fs.writeFile(path.join(tmpDir, "file1.txt"), "initial content");
|
|
96
|
+
|
|
97
|
+
// Init with --sub
|
|
98
|
+
await pushwork(["init", "--sub", tmpDir]);
|
|
99
|
+
|
|
100
|
+
// Add a new file
|
|
101
|
+
await fs.writeFile(path.join(tmpDir, "file2.txt"), "new file");
|
|
102
|
+
|
|
103
|
+
// Sync with --sub
|
|
104
|
+
await pushwork(["sync", "--sub", tmpDir]);
|
|
105
|
+
|
|
106
|
+
// Verify the new file is now tracked
|
|
107
|
+
const snapshotManager = new SnapshotManager(tmpDir);
|
|
108
|
+
const snapshot = await snapshotManager.load();
|
|
109
|
+
expect(snapshot).not.toBeNull();
|
|
110
|
+
expect(snapshot!.files.has("file1.txt")).toBe(true);
|
|
111
|
+
expect(snapshot!.files.has("file2.txt")).toBe(true);
|
|
112
|
+
}, 60000);
|
|
113
|
+
|
|
114
|
+
it("should detect file modifications on sync --sub", async () => {
|
|
115
|
+
await fs.writeFile(path.join(tmpDir, "mutable.txt"), "version 1");
|
|
116
|
+
|
|
117
|
+
await pushwork(["init", "--sub", tmpDir]);
|
|
118
|
+
|
|
119
|
+
// Record initial heads
|
|
120
|
+
const snapshotManager = new SnapshotManager(tmpDir);
|
|
121
|
+
const snapshot1 = await snapshotManager.load();
|
|
122
|
+
const initialHead = snapshot1!.files.get("mutable.txt")!.head;
|
|
123
|
+
|
|
124
|
+
// Modify the file
|
|
125
|
+
await fs.writeFile(path.join(tmpDir, "mutable.txt"), "version 2");
|
|
126
|
+
|
|
127
|
+
// Sync
|
|
128
|
+
await pushwork(["sync", "--sub", tmpDir]);
|
|
129
|
+
|
|
130
|
+
// Heads should have changed
|
|
131
|
+
const snapshot2 = await snapshotManager.load();
|
|
132
|
+
const updatedHead = snapshot2!.files.get("mutable.txt")!.head;
|
|
133
|
+
expect(updatedHead).not.toEqual(initialHead);
|
|
134
|
+
}, 60000);
|
|
135
|
+
|
|
136
|
+
it("should handle file deletions on sync --sub", async () => {
|
|
137
|
+
await fs.writeFile(path.join(tmpDir, "ephemeral.txt"), "delete me");
|
|
138
|
+
await fs.writeFile(path.join(tmpDir, "keeper.txt"), "keep me");
|
|
139
|
+
|
|
140
|
+
await pushwork(["init", "--sub", tmpDir]);
|
|
141
|
+
|
|
142
|
+
// Delete a file
|
|
143
|
+
await fs.unlink(path.join(tmpDir, "ephemeral.txt"));
|
|
144
|
+
|
|
145
|
+
// Sync
|
|
146
|
+
await pushwork(["sync", "--sub", tmpDir]);
|
|
147
|
+
|
|
148
|
+
// Deleted file should be gone from snapshot
|
|
149
|
+
const snapshotManager = new SnapshotManager(tmpDir);
|
|
150
|
+
const snapshot = await snapshotManager.load();
|
|
151
|
+
expect(snapshot).not.toBeNull();
|
|
152
|
+
expect(snapshot!.files.has("ephemeral.txt")).toBe(false);
|
|
153
|
+
expect(snapshot!.files.has("keeper.txt")).toBe(true);
|
|
154
|
+
}, 60000);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("url after init --sub", () => {
|
|
158
|
+
it("should print a valid automerge URL", async () => {
|
|
159
|
+
await pushwork(["init", "--sub", tmpDir]);
|
|
160
|
+
|
|
161
|
+
const stdout = await pushwork(["url", tmpDir]);
|
|
162
|
+
expect(stdout.trim()).toMatch(/^automerge:/);
|
|
163
|
+
}, 60000);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("status after init --sub", () => {
|
|
167
|
+
it("should report status without errors", async () => {
|
|
168
|
+
await fs.writeFile(path.join(tmpDir, "test.txt"), "status check");
|
|
169
|
+
await pushwork(["init", "--sub", tmpDir]);
|
|
170
|
+
|
|
171
|
+
// status should not throw
|
|
172
|
+
const stdout = await pushwork(["status", tmpDir]);
|
|
173
|
+
expect(stdout).toBeDefined();
|
|
174
|
+
}, 60000);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("diff after init --sub", () => {
|
|
178
|
+
it("should show no changes immediately after init", async () => {
|
|
179
|
+
await fs.writeFile(path.join(tmpDir, "stable.txt"), "no changes");
|
|
180
|
+
await pushwork(["init", "--sub", tmpDir]);
|
|
181
|
+
|
|
182
|
+
const stdout = await pushwork(["diff", tmpDir]);
|
|
183
|
+
// After a fresh init+sync, there should be no pending changes
|
|
184
|
+
expect(stdout).not.toContain("modified");
|
|
185
|
+
}, 60000);
|
|
186
|
+
});
|
|
187
|
+
});
|
package/test/run-tests.sh
CHANGED
|
File without changes
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { waitForSync } from "../../src/utils/network-sync";
|
|
2
|
+
import { DocHandle, StorageId } from "@automerge/automerge-repo";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Create a mock DocHandle with controllable heads.
|
|
6
|
+
*
|
|
7
|
+
* @param headSequence - An array of head values the handle returns on
|
|
8
|
+
* successive calls to heads(). Once exhausted, the last value repeats.
|
|
9
|
+
* This lets us simulate heads that change (sync in progress) and then
|
|
10
|
+
* stabilize (sync complete).
|
|
11
|
+
*/
|
|
12
|
+
function mockHandle(headSequence: string[][]): DocHandle<unknown> {
|
|
13
|
+
let callCount = 0;
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
url: `automerge:mock-${Math.random().toString(36).slice(2)}`,
|
|
17
|
+
heads: () => {
|
|
18
|
+
const idx = Math.min(callCount++, headSequence.length - 1);
|
|
19
|
+
return headSequence[idx];
|
|
20
|
+
},
|
|
21
|
+
// getSyncInfo is only called in the StorageId path, not the head-stability path
|
|
22
|
+
getSyncInfo: jest.fn(),
|
|
23
|
+
on: jest.fn(),
|
|
24
|
+
off: jest.fn(),
|
|
25
|
+
} as unknown as DocHandle<unknown>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("waitForSync (Subduction / head-stability mode)", () => {
|
|
29
|
+
// When syncServerStorageId is undefined, waitForSync should use the
|
|
30
|
+
// head-stability polling path instead of the getSyncInfo-based path.
|
|
31
|
+
|
|
32
|
+
it("should return immediately for empty handle list", async () => {
|
|
33
|
+
const result = await waitForSync([], undefined);
|
|
34
|
+
expect(result.failed).toHaveLength(0);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("should resolve when handle heads are already stable", async () => {
|
|
38
|
+
// Heads never change — stable from the start
|
|
39
|
+
const handle = mockHandle([["head-a", "head-b"]]);
|
|
40
|
+
const result = await waitForSync([handle], undefined, 5000);
|
|
41
|
+
|
|
42
|
+
expect(result.failed).toHaveLength(0);
|
|
43
|
+
// getSyncInfo should never be called in head-stability mode
|
|
44
|
+
expect(handle.getSyncInfo).not.toHaveBeenCalled();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("should resolve after heads stabilize", async () => {
|
|
48
|
+
// Heads change for the first few polls, then stabilize
|
|
49
|
+
const handle = mockHandle([
|
|
50
|
+
["head-1"], // poll 1: initial
|
|
51
|
+
["head-2"], // poll 2: changed (reset stable count)
|
|
52
|
+
["head-3"], // poll 3: changed again
|
|
53
|
+
["head-3"], // poll 4: stable check 1
|
|
54
|
+
["head-3"], // poll 5: stable check 2
|
|
55
|
+
["head-3"], // poll 6: stable check 3 → converged
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
const result = await waitForSync([handle], undefined, 10000);
|
|
59
|
+
expect(result.failed).toHaveLength(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("should report handle as failed on timeout", async () => {
|
|
63
|
+
// Heads keep changing — never stabilize
|
|
64
|
+
let counter = 0;
|
|
65
|
+
const neverStable = {
|
|
66
|
+
url: "automerge:never-stable",
|
|
67
|
+
heads: () => [`head-${counter++}`],
|
|
68
|
+
getSyncInfo: jest.fn(),
|
|
69
|
+
on: jest.fn(),
|
|
70
|
+
off: jest.fn(),
|
|
71
|
+
} as unknown as DocHandle<unknown>;
|
|
72
|
+
|
|
73
|
+
const result = await waitForSync([neverStable], undefined, 500);
|
|
74
|
+
expect(result.failed).toHaveLength(1);
|
|
75
|
+
expect(result.failed[0]).toBe(neverStable);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("should handle a mix of stable and unstable handles", async () => {
|
|
79
|
+
const stable = mockHandle([["stable-head"]]);
|
|
80
|
+
|
|
81
|
+
let counter = 0;
|
|
82
|
+
const unstable = {
|
|
83
|
+
url: "automerge:unstable",
|
|
84
|
+
heads: () => [`changing-${counter++}`],
|
|
85
|
+
getSyncInfo: jest.fn(),
|
|
86
|
+
on: jest.fn(),
|
|
87
|
+
off: jest.fn(),
|
|
88
|
+
} as unknown as DocHandle<unknown>;
|
|
89
|
+
|
|
90
|
+
const result = await waitForSync([stable, unstable], undefined, 500);
|
|
91
|
+
|
|
92
|
+
// The stable handle should succeed, the unstable one should fail
|
|
93
|
+
expect(result.failed).toHaveLength(1);
|
|
94
|
+
expect(result.failed[0]).toBe(unstable);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("should not use getSyncInfo when no StorageId is provided", async () => {
|
|
98
|
+
const handle = mockHandle([["head-a"]]);
|
|
99
|
+
await waitForSync([handle], undefined, 5000);
|
|
100
|
+
|
|
101
|
+
// The head-stability path does not call getSyncInfo at all
|
|
102
|
+
expect(handle.getSyncInfo).not.toHaveBeenCalled();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("waitForSync (WebSocket / StorageId mode)", () => {
|
|
107
|
+
// When a StorageId IS provided, waitForSync should use getSyncInfo-based
|
|
108
|
+
// verification instead of head-stability polling.
|
|
109
|
+
|
|
110
|
+
it("should use getSyncInfo when StorageId is provided", async () => {
|
|
111
|
+
const storageId = "test-storage-id" as StorageId;
|
|
112
|
+
const heads = ["head-a"];
|
|
113
|
+
|
|
114
|
+
const handle = {
|
|
115
|
+
url: "automerge:ws-handle",
|
|
116
|
+
heads: () => heads,
|
|
117
|
+
getSyncInfo: jest.fn().mockReturnValue({ lastHeads: heads }),
|
|
118
|
+
on: jest.fn(),
|
|
119
|
+
off: jest.fn(),
|
|
120
|
+
} as unknown as DocHandle<unknown>;
|
|
121
|
+
|
|
122
|
+
const result = await waitForSync([handle], storageId, 5000);
|
|
123
|
+
|
|
124
|
+
expect(result.failed).toHaveLength(0);
|
|
125
|
+
expect(handle.getSyncInfo).toHaveBeenCalledWith(storageId);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("should detect already-synced handles via getSyncInfo", async () => {
|
|
129
|
+
const storageId = "test-storage-id" as StorageId;
|
|
130
|
+
const heads = ["same-head"];
|
|
131
|
+
|
|
132
|
+
const handle = {
|
|
133
|
+
url: "automerge:already-synced",
|
|
134
|
+
heads: () => heads,
|
|
135
|
+
// getSyncInfo returns matching heads → already synced
|
|
136
|
+
getSyncInfo: jest.fn().mockReturnValue({ lastHeads: heads }),
|
|
137
|
+
on: jest.fn(),
|
|
138
|
+
off: jest.fn(),
|
|
139
|
+
} as unknown as DocHandle<unknown>;
|
|
140
|
+
|
|
141
|
+
const result = await waitForSync([handle], storageId, 5000);
|
|
142
|
+
expect(result.failed).toHaveLength(0);
|
|
143
|
+
});
|
|
144
|
+
});
|