simplify 3
This commit is contained in:
parent
82929d7479
commit
96d720eed9
7 changed files with 1318 additions and 995 deletions
|
|
@ -13,7 +13,8 @@ Test-only code shared by the deterministic and seeded clients.
|
|||
| `restart-reconciliation.test.ts` | Interrupted file application followed by user edits, two-client/server convergence and a second restart |
|
||||
| `server-restore.test.ts` | History checkpoints, server backup restores and interrupted metadata resets |
|
||||
| `content-limits.test.ts`, `protocol-audit.test.ts` | Content validation and API invariants |
|
||||
| `real-disk.test.ts` | Host filesystem exclusive create/rename, unsafe links, and rename cycles |
|
||||
| `real-disk.test.ts` | Production Node adapter exclusive create/move, unsafe links, and rename cycles |
|
||||
| `production-adapters.test.ts` | Actual CLI processes, raw watchers, Obsidian adapter, complete metadata and restart after SIGKILL |
|
||||
|
||||
The filesystem interruption tests assert the supported contract: complete metadata
|
||||
remains usable, scans observe the current files, and a second scan is stable.
|
||||
|
|
@ -30,9 +31,12 @@ after replacement. Abandoned filesystem sessions are fenced against later writes
|
|||
|
||||
`crash-worker.ts` runs a complete client in a worker, which the parent terminates
|
||||
at accepted-response checkpoints without graceful shutdown. A new runtime receives
|
||||
the saved disk and metadata. The separate real-disk fixture uses a test-only native
|
||||
exclusive-rename helper and ordinary writes without flushes. It is not a production
|
||||
adapter or a sandbox against hostile concurrent changes to ancestor directories.
|
||||
the saved disk and metadata. Real-disk tests exercise the production Node adapter.
|
||||
The production integration suite launches built CLI processes and an Obsidian
|
||||
filesystem adapter against a real server. Obsidian unit tests exercise unsaved editor
|
||||
text, selections and mobile storage contracts; they do not launch the Obsidian app.
|
||||
Filesystem path checks are not a sandbox against hostile concurrent changes to
|
||||
ancestor directories.
|
||||
|
||||
Scripted and seeded suites run multiple clients against real servers. They check
|
||||
visible bytes, UUID mappings, canonical server state, content heads and event cursors.
|
||||
|
|
@ -46,7 +50,8 @@ Run from the repository root:
|
|||
E2E_WORKERS=32 E2E_SEED=12 E2E_TIMEOUT_SECONDS=9000 E2E_ITERATIONS=100 scripts/e2e.sh
|
||||
```
|
||||
|
||||
The command rebuilds peers, runs unit/harness/protocol/scripted suites, and then
|
||||
The command rebuilds peers and both production integrations, runs core and adapter
|
||||
unit tests plus harness/protocol/scripted suites, and then
|
||||
runs seeds 12 through 43. `E2E_ARTIFACTS` optionally selects the artifact directory.
|
||||
The runner tests also verify subprocess exit codes, log errors, spawn failures,
|
||||
timeouts, workspace dependency consistency and CI artifact wiring.
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { PermanentSyncError } from "../sync-client/src/errors/errors";
|
|||
import { test } from "node:test";
|
||||
import { Database, emptyState } from "../sync-client/src/persistence/database";
|
||||
import { Syncer } from "../sync-client/src/sync-operations/syncer";
|
||||
import { SyncClient } from "../sync-client/src/sync-client";
|
||||
import { SyncClient, type StoredClient } from "../sync-client/src/sync-client";
|
||||
import { toStoredSnapshot } from "../sync-client/src/sync-operations/content";
|
||||
import { allocatePortablePath } from "../sync-client/src/utils/portable-path";
|
||||
import { MemoryDisk, MemoryPersistence } from "./storage";
|
||||
|
|
@ -83,7 +83,7 @@ test("legacy content notifications preserve the acknowledged namespace prefix on
|
|||
.hash
|
||||
};
|
||||
initial.lastAppliedLocalChangeId = "consumed-update";
|
||||
const saved = {
|
||||
const saved: StoredClient = {
|
||||
database: initial,
|
||||
settings: { remoteUri: "http://offline.test", vaultName: "test" },
|
||||
localChangesVaultKey: initial.vaultKey,
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
/* Test-only POSIX primitives missing from Node's filesystem API. A preflight
|
||||
* exists() followed by fs.rename() is not an exclusive rename. */
|
||||
#define _GNU_SOURCE
|
||||
#include <fcntl.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#if defined(__linux__)
|
||||
#include <sys/syscall.h>
|
||||
#endif
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc == 4 && strcmp(argv[1], "rename") == 0) {
|
||||
int result;
|
||||
#if defined(__APPLE__)
|
||||
result = renamex_np(argv[2], argv[3], RENAME_EXCL);
|
||||
#elif defined(__linux__)
|
||||
result = syscall(SYS_renameat2, AT_FDCWD, argv[2], AT_FDCWD, argv[3], 1 /* RENAME_NOREPLACE */);
|
||||
#else
|
||||
#error "The real filesystem tests require macOS or Linux"
|
||||
#endif
|
||||
if (result != 0) { perror("exclusive rename"); return 1; }
|
||||
return 0;
|
||||
}
|
||||
fprintf(stderr, "usage: native-fs rename FROM TO\n");
|
||||
return 2;
|
||||
}
|
||||
207
frontend/test-support/production-adapters.test.ts
Normal file
207
frontend/test-support/production-adapters.test.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { Logger, SyncClient } from "sync-client";
|
||||
import { ServerControl } from "../deterministic-tests/src/server-control";
|
||||
import { ClientPersistence } from "../local-client-cli/src/client-persistence";
|
||||
import { NodeFileSystemOperations } from "../local-client-cli/src/node-filesystem";
|
||||
import { ObsidianFileSystemOperations } from "../obsidian-plugin/src/obsidian-file-system";
|
||||
import { getJson, type CanonicalSnapshot } from "./canonical";
|
||||
|
||||
const root = path.resolve(__dirname, "../..");
|
||||
const token = "test-token-change-me";
|
||||
const pause = () => new Promise((resolve) => setTimeout(resolve, 50));
|
||||
async function until(check: () => Promise<boolean>, message: string) {
|
||||
const deadline = Date.now() + 15_000;
|
||||
while (!(await check())) {
|
||||
assert(Date.now() < deadline, message);
|
||||
await pause();
|
||||
}
|
||||
}
|
||||
async function contents(file: string) {
|
||||
try {
|
||||
return await fs.readFile(file, "utf8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
||||
return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async function stop(child: ChildProcess, signal: NodeJS.Signals) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
const exited = once(child, "exit");
|
||||
child.kill(signal);
|
||||
await exited;
|
||||
}
|
||||
|
||||
test(
|
||||
"production CLI watchers, Obsidian adapter, and full metadata survive process replacement",
|
||||
{ timeout: 90_000 },
|
||||
async (t) => {
|
||||
const directory = await fs.mkdtemp(
|
||||
path.join(tmpdir(), "vaultlink-production-")
|
||||
);
|
||||
const server = new ServerControl(
|
||||
path.join(root, "sync-server/target/release/sync_server"),
|
||||
path.join(root, "sync-server/config-e2e.yml"),
|
||||
new Logger()
|
||||
);
|
||||
const children: ChildProcess[] = [];
|
||||
let obsidian: SyncClient | undefined;
|
||||
t.after(async () => {
|
||||
await obsidian?.destroy();
|
||||
await Promise.all(children.map((child) => stop(child, "SIGKILL")));
|
||||
await server.stop();
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
await server.start();
|
||||
const a = path.join(directory, "a"),
|
||||
b = path.join(directory, "b"),
|
||||
c = path.join(directory, "obsidian");
|
||||
await Promise.all([a, b, c].map((p) => fs.mkdir(p)));
|
||||
await fs.writeFile(path.join(a, "note.md"), "initial\n");
|
||||
async function launch(vault: string) {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(root, "frontend/local-client-cli/dist/cli.js"),
|
||||
"-l",
|
||||
vault,
|
||||
"-r",
|
||||
server.remoteUri,
|
||||
"-t",
|
||||
token,
|
||||
"-v",
|
||||
"production",
|
||||
"--ignore-pattern",
|
||||
"**/*.tmp"
|
||||
],
|
||||
{ stdio: ["ignore", "pipe", "pipe"] }
|
||||
);
|
||||
children.push(child);
|
||||
let log = "";
|
||||
child.stdout!.on("data", (data: Buffer) => {
|
||||
log += data.toString();
|
||||
});
|
||||
child.stderr!.on("data", (data: Buffer) => {
|
||||
log += data.toString();
|
||||
});
|
||||
await until(async () => {
|
||||
assert(
|
||||
child.exitCode === null && child.signalCode === null,
|
||||
`CLI exited: ${log}`
|
||||
);
|
||||
return log.includes("File watcher started");
|
||||
}, `CLI did not start: ${log}`);
|
||||
return child;
|
||||
}
|
||||
let first = await launch(a);
|
||||
await launch(b);
|
||||
await until(
|
||||
async () =>
|
||||
(await contents(path.join(b, "note.md"))) === "initial\n",
|
||||
"CLI initial download failed"
|
||||
);
|
||||
const persistence = (vault: string) =>
|
||||
new ClientPersistence(
|
||||
path.join(vault, ".vaultlink/sync-data.json"),
|
||||
{}
|
||||
);
|
||||
const original = (await persistence(a).load()).database!;
|
||||
const [documentId] = Object.keys(original.local!);
|
||||
assert(documentId);
|
||||
|
||||
const settings = {
|
||||
remoteUri: server.remoteUri,
|
||||
token,
|
||||
vaultName: "production",
|
||||
isSyncEnabled: true,
|
||||
enableTelemetry: false,
|
||||
ignorePatterns: [".vaultlink/**"],
|
||||
syncIntervalMs: 0
|
||||
};
|
||||
obsidian = await SyncClient.create({
|
||||
fs: new ObsidianFileSystemOperations(
|
||||
new NodeFileSystemOperations(c),
|
||||
() => undefined
|
||||
),
|
||||
persistence: new ClientPersistence(
|
||||
path.join(c, ".vaultlink/sync-data.json"),
|
||||
settings
|
||||
)
|
||||
});
|
||||
await obsidian.start();
|
||||
await obsidian.waitUntilFinished();
|
||||
assert.equal(await contents(path.join(c, "note.md")), "initial\n");
|
||||
await fs.writeFile(
|
||||
path.join(a, "replacement.tmp"),
|
||||
"atomic editor save\n"
|
||||
);
|
||||
await fs.rename(
|
||||
path.join(a, "replacement.tmp"),
|
||||
path.join(a, "note.md")
|
||||
);
|
||||
await until(
|
||||
async () =>
|
||||
(await contents(path.join(b, "note.md"))) ===
|
||||
"atomic editor save\n" &&
|
||||
(await contents(path.join(c, "note.md"))) ===
|
||||
"atomic editor save\n",
|
||||
"Editor replacement did not converge"
|
||||
);
|
||||
assert.equal(
|
||||
(await persistence(a).load()).database!.local![documentId],
|
||||
"note.md"
|
||||
);
|
||||
await fs.rename(path.join(b, "note.md"), path.join(b, "renamed.md"));
|
||||
await until(
|
||||
async () =>
|
||||
(await contents(path.join(a, "renamed.md"))) ===
|
||||
"atomic editor save\n" &&
|
||||
(await contents(path.join(a, "note.md"))) === undefined,
|
||||
"CLI rename did not converge"
|
||||
);
|
||||
await until(
|
||||
async () =>
|
||||
(await persistence(a).load()).database!.local![documentId] ===
|
||||
"renamed.md",
|
||||
"Rename changed document identity"
|
||||
);
|
||||
const saved = await persistence(a).load();
|
||||
assert(
|
||||
saved.historyCheckpoint?.checkpoint,
|
||||
"CLI dropped the history checkpoint"
|
||||
);
|
||||
await stop(first, "SIGKILL");
|
||||
await fs.writeFile(path.join(a, "renamed.md"), "edited after kill\n");
|
||||
first = await launch(a);
|
||||
await until(
|
||||
async () =>
|
||||
(await contents(path.join(b, "renamed.md"))) ===
|
||||
"edited after kill\n" &&
|
||||
(await contents(path.join(c, "renamed.md"))) ===
|
||||
"edited after kill\n",
|
||||
"Restart failed to reconcile current files"
|
||||
);
|
||||
const snapshot = await getJson<CanonicalSnapshot>(
|
||||
`${server.remoteUri}/vaults/production/vault-snapshot`,
|
||||
token
|
||||
);
|
||||
assert.deepEqual(snapshot.fileManifest.entries, {
|
||||
[documentId]: "renamed.md"
|
||||
});
|
||||
await fs.unlink(path.join(b, "renamed.md"));
|
||||
await until(
|
||||
async () =>
|
||||
(await contents(path.join(a, "renamed.md"))) === undefined &&
|
||||
(await contents(path.join(c, "renamed.md"))) === undefined,
|
||||
"Delete did not converge"
|
||||
);
|
||||
await stop(first, "SIGTERM");
|
||||
assert.equal(first.exitCode, 0, "CLI shutdown failed");
|
||||
}
|
||||
);
|
||||
|
|
@ -3,9 +3,7 @@ import { test } from "node:test";
|
|||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { RealDisk } from "./real-disk";
|
||||
import { NodeFileSystemOperations } from "../local-client-cli/src/node-filesystem";
|
||||
import { Database, emptyState } from "../sync-client/src/persistence/database";
|
||||
import { FileOperations } from "../sync-client/src/file-operations/file-operations";
|
||||
import type { ServerConfig } from "../sync-client/src/services/server-config";
|
||||
|
|
@ -21,16 +19,7 @@ test(
|
|||
try {
|
||||
const root = path.join(directory, "vault");
|
||||
await fs.mkdir(root);
|
||||
const helper = path.join(directory, "native-fs");
|
||||
await promisify(execFile)("cc", [
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-O2",
|
||||
path.join(__dirname, "native-fs.c"),
|
||||
"-o",
|
||||
helper
|
||||
]);
|
||||
const disk = new RealDisk(root, helper);
|
||||
const disk = new NodeFileSystemOperations(root);
|
||||
const snapshot = (text: string) => ({
|
||||
content: new TextEncoder().encode(text)
|
||||
});
|
||||
|
|
@ -50,6 +39,32 @@ test(
|
|||
).toString()
|
||||
)
|
||||
);
|
||||
for (const unsafe of [
|
||||
"../outside",
|
||||
"/absolute",
|
||||
"folder/../outside"
|
||||
]) {
|
||||
await assert.rejects(
|
||||
disk.write(unsafe, snapshot("blocked")),
|
||||
/Unsafe path/
|
||||
);
|
||||
await assert.rejects(disk.readSnapshot(unsafe), /Unsafe path/);
|
||||
}
|
||||
// POSIX filenames can contain colons. The engine, not the adapter,
|
||||
// allocates their portable names for sync.
|
||||
if (process.platform !== "win32") {
|
||||
await fs.writeFile(
|
||||
path.join(root, "local:name.md"),
|
||||
"local name"
|
||||
);
|
||||
assert.equal(
|
||||
Buffer.from(
|
||||
(await disk.readSnapshot("local:name.md"))!.content
|
||||
).toString(),
|
||||
"local name"
|
||||
);
|
||||
await disk.rename("local:name.md", "portable-name.md");
|
||||
}
|
||||
await disk.write("a.md", snapshot("A"));
|
||||
await disk.write("b.md", snapshot("B"));
|
||||
// A file in an ancestor position makes descendants absent.
|
||||
|
|
|
|||
|
|
@ -1,171 +0,0 @@
|
|||
import assert from "node:assert/strict";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { constants } from "node:fs";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type { FileSystemOperations, FileSnapshot } from "sync-client";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
/** Small integration fixture, NOT a supported application adapter. Safety
|
||||
* preflights detect static symlinks/hardlinks; they are not an openat sandbox
|
||||
* against a hostile process replacing ancestors concurrently. */
|
||||
export class RealDisk implements FileSystemOperations {
|
||||
public constructor(
|
||||
private readonly root: string,
|
||||
private readonly helper: string
|
||||
) {}
|
||||
private async resolve(relative: string): Promise<string> {
|
||||
assert(
|
||||
relative === "" ||
|
||||
relative
|
||||
.split("/")
|
||||
.every(
|
||||
(p) => p && p !== "." && p !== ".." && !p.includes("\\")
|
||||
),
|
||||
"Unsafe path"
|
||||
);
|
||||
let current = this.root;
|
||||
for (const component of relative.split("/").filter(Boolean)) {
|
||||
current = path.join(current, component);
|
||||
const entry = await this.info(current);
|
||||
if (entry)
|
||||
assert(
|
||||
entry.isDirectory() ||
|
||||
(entry.isFile() && entry.nlink === 1),
|
||||
"Symlink, hardlink or special file"
|
||||
);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
private async info(absolute: string) {
|
||||
try {
|
||||
return await fs.lstat(absolute);
|
||||
} catch (error) {
|
||||
if (
|
||||
["ENOENT", "ENOTDIR"].includes(
|
||||
(error as NodeJS.ErrnoException).code ?? ""
|
||||
)
|
||||
)
|
||||
return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
public async stat(relative: string) {
|
||||
const info = await this.info(await this.resolve(relative));
|
||||
return info
|
||||
? {
|
||||
kind: info.isFile()
|
||||
? ("file" as const)
|
||||
: ("directory" as const),
|
||||
size: info.size
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
public async exists(relative: string) {
|
||||
return (await this.stat(relative)) !== undefined;
|
||||
}
|
||||
public async readSnapshot(
|
||||
relative: string
|
||||
): Promise<FileSnapshot | undefined> {
|
||||
const absolute = await this.resolve(relative);
|
||||
let handle;
|
||||
try {
|
||||
handle = await fs.open(
|
||||
absolute,
|
||||
constants.O_RDONLY | constants.O_NOFOLLOW
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
["ENOENT", "ENOTDIR"].includes(
|
||||
(error as NodeJS.ErrnoException).code ?? ""
|
||||
)
|
||||
)
|
||||
return undefined;
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const before = await handle.stat({ bigint: true });
|
||||
assert(
|
||||
before.isFile() && before.nlink === 1n,
|
||||
"Not an independent regular file"
|
||||
);
|
||||
const content = new Uint8Array(await handle.readFile());
|
||||
const after = await handle.stat({ bigint: true });
|
||||
const atPath = await fs.lstat(absolute, { bigint: true });
|
||||
assert(
|
||||
before.ino === after.ino &&
|
||||
after.ino === atPath.ino &&
|
||||
before.size === after.size &&
|
||||
before.mtimeNs === after.mtimeNs &&
|
||||
before.ctimeNs === after.ctimeNs,
|
||||
"File changed during read"
|
||||
);
|
||||
return { content };
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
public async listFilesRecursively(root = ""): Promise<string[]> {
|
||||
const result: string[] = [];
|
||||
for (const name of await fs.readdir(await this.resolve(root))) {
|
||||
const relative = root ? `${root}/${name}` : name;
|
||||
if ((await this.stat(relative))!.kind === "directory")
|
||||
result.push(...(await this.listFilesRecursively(relative)));
|
||||
else result.push(relative);
|
||||
}
|
||||
return result.sort();
|
||||
}
|
||||
public async createDirectory(relative: string): Promise<void> {
|
||||
await this.resolve(relative);
|
||||
let current = this.root;
|
||||
for (const component of relative.split("/").filter(Boolean)) {
|
||||
const next = path.join(current, component);
|
||||
try {
|
||||
await fs.mkdir(next);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "EEXIST")
|
||||
throw error;
|
||||
}
|
||||
assert((await fs.lstat(next)).isDirectory());
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
public async write(
|
||||
relative: string,
|
||||
snapshot: FileSnapshot
|
||||
): Promise<void> {
|
||||
assert(
|
||||
!snapshot.cursors?.length,
|
||||
"This disk-only fixture has no editor selections"
|
||||
);
|
||||
const destination = await this.resolve(relative);
|
||||
await fs.writeFile(destination, snapshot.content, {
|
||||
flag: "wx",
|
||||
mode: 0o600
|
||||
});
|
||||
}
|
||||
public async rename(from: string, to: string): Promise<void> {
|
||||
const source = await this.resolve(from),
|
||||
destination = await this.resolve(to);
|
||||
assert((await this.info(source))?.isFile());
|
||||
await exec(this.helper, ["rename", source, destination]);
|
||||
}
|
||||
public async delete(relative: string): Promise<void> {
|
||||
assert(relative, "Cannot prune root");
|
||||
const absolute = await this.resolve(relative);
|
||||
const entry = await this.info(absolute);
|
||||
if (!entry) return;
|
||||
assert(entry.isDirectory(), "Cannot delete regular file");
|
||||
for (const child of await fs.readdir(absolute))
|
||||
await this.delete(`${relative}/${child}`);
|
||||
await fs.rmdir(absolute);
|
||||
}
|
||||
public async deleteFile(relative: string): Promise<void> {
|
||||
const absolute = await this.resolve(relative);
|
||||
const entry = await this.info(absolute);
|
||||
if (!entry) return;
|
||||
assert(entry.isFile() && entry.nlink === 1, "Cannot unlink non-file");
|
||||
await fs.unlink(absolute);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,14 +2,11 @@ import assert from "node:assert/strict";
|
|||
import type {
|
||||
FileSnapshot,
|
||||
FileSystemOperations,
|
||||
StoredDatabase,
|
||||
SyncSettings
|
||||
StoredDatabase
|
||||
} from "sync-client";
|
||||
|
||||
export type StoredClient = Partial<{
|
||||
settings: Partial<SyncSettings>;
|
||||
database: Partial<StoredDatabase>;
|
||||
}>;
|
||||
import type { StoredClient } from "sync-client";
|
||||
export type { StoredClient } from "sync-client";
|
||||
export type Boundary = (label: string) => void | Promise<void>;
|
||||
type Entry = FileSnapshot | null; // null is a directory; absence is not a directory.
|
||||
export interface DiskImage {
|
||||
|
|
|
|||
Loading…
Reference in a new issue