diff --git a/.dockerignore b/.dockerignore index a3ee67b0..d6d47438 100644 --- a/.dockerignore +++ b/.dockerignore @@ -19,7 +19,7 @@ production/ coverage/ _testdata/ test/bench-network/bench-results/ -src/apps/cli/testdeno/bench-results/ +test/e2e-cli/bench-results/ # Local environment / secrets .env diff --git a/.github/workflows/cli-deno-tests.yml b/.github/workflows/cli-node-tests.yml similarity index 65% rename from .github/workflows/cli-deno-tests.yml rename to .github/workflows/cli-node-tests.yml index 66f13e77..56f47f86 100644 --- a/.github/workflows/cli-deno-tests.yml +++ b/.github/workflows/cli-node-tests.yml @@ -1,4 +1,4 @@ -name: cli-deno-tests +name: cli-node-tests on: push: @@ -6,9 +6,11 @@ on: - main - beta paths: - - '.github/workflows/cli-deno-tests.yml' + - '.github/workflows/cli-node-tests.yml' - 'src/apps/cli/**' - '!src/apps/cli/**/*.md' + - 'test/e2e-cli/**' + - '!test/e2e-cli/**/*.md' - 'src/LiveSyncBaseCore.ts' - 'src/common/**' - 'src/serviceFeatures/**' @@ -24,9 +26,11 @@ on: pull_request: types: [opened, synchronize, reopened, ready_for_review] paths: - - '.github/workflows/cli-deno-tests.yml' + - '.github/workflows/cli-node-tests.yml' - 'src/apps/cli/**' - '!src/apps/cli/**/*.md' + - 'test/e2e-cli/**' + - '!test/e2e-cli/**/*.md' - 'src/LiveSyncBaseCore.ts' - 'src/common/**' - 'src/serviceFeatures/**' @@ -42,7 +46,7 @@ on: workflow_dispatch: inputs: test_task: - description: 'Deno test task to run' + description: 'Node.js test task to run' type: choice options: - test:ci @@ -67,7 +71,7 @@ concurrency: jobs: prepare: - if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} + if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name != 'workflow_dispatch' || inputs.test_task != 'test:local') }} runs-on: ubuntu-latest timeout-minutes: 15 env: @@ -89,10 +93,10 @@ jobs: case "$SELECTED_TASK" in test:ci) - TASK_MATRIX=$(jq -c . src/apps/cli/testdeno/ci-tasks.json) + TASK_MATRIX=$(jq -c . test/e2e-cli/ci-tasks.json) ;; test:local) - TASK_MATRIX='["test:setup-put-cat","test:mirror","test:daemon"]' + TASK_MATRIX='["test:local"]' ;; test:e2e-matrix) TASK_MATRIX='["test:e2e-matrix:couchdb-enc0","test:e2e-matrix:couchdb-enc1","test:e2e-matrix:minio-enc0","test:e2e-matrix:minio-enc1"]' @@ -108,7 +112,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v5 with: - node-version: '24.x' + node-version: '26.x' cache: 'npm' - name: Install dependencies @@ -127,12 +131,12 @@ jobs: retention-days: 1 test: - if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} + if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name != 'workflow_dispatch' || inputs.test_task != 'test:local') }} needs: prepare runs-on: ubuntu-latest timeout-minutes: 60 env: - DENO_DIR: ~/.cache/deno + NODE_OPTIONS: --max-old-space-size=3072 strategy: fail-fast: false matrix: @@ -144,23 +148,10 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v5 with: - node-version: '24.x' + node-version: '26.x' cache: 'npm' cache-dependency-path: package-lock.json - - name: Setup Deno - uses: denoland/setup-deno@v2 - with: - deno-version: v2.x - - - name: Cache Deno dependencies - uses: actions/cache@v5 - with: - path: ~/.cache/deno - key: ${{ runner.os }}-deno-${{ hashFiles('src/apps/cli/testdeno/deno.lock', 'src/apps/cli/testdeno/deno.json') }} - restore-keys: | - ${{ runner.os }}-deno- - - name: Install dependencies run: npm ci @@ -184,8 +175,8 @@ jobs: bucketName=livesync-test-bucket-ci EOF - - name: Run Deno tests - working-directory: src/apps/cli/testdeno + - name: Run Node.js tests + working-directory: test/e2e-cli env: LIVESYNC_DOCKER_MODE: native LIVESYNC_CLI_RETRY: 3 @@ -194,11 +185,61 @@ jobs: LIVESYNC_USE_COTURN: ${{ inputs.use_coturn == true && '1' || '0' }} run: | TASK="${{ matrix.task }}" - echo "[INFO] Running Deno task: $TASK" - deno task "$TASK" + echo "[INFO] Running Node.js task: $TASK" + node run-task.ts "$TASK" - name: Stop leftover containers if: always() run: | docker stop couchdb-test minio-test relay-test coturn-test >/dev/null 2>&1 || true docker rm couchdb-test minio-test relay-test coturn-test >/dev/null 2>&1 || true + + local-smoke: + if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name != 'workflow_dispatch' || inputs.test_task == 'test:local') }} + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + env: + NODE_OPTIONS: --max-old-space-size=3072 + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: '26.x' + cache: 'npm' + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Build CLI + if: ${{ matrix.os != 'ubuntu-latest' }} + env: + LIVESYNC_CLI_TEST_SUPPORT: '1' + run: npm run build --workspace self-hosted-livesync-cli + + - name: Run local smoke tests + if: ${{ matrix.os != 'ubuntu-latest' }} + working-directory: src/apps/cli + run: node ../../../test/e2e-cli/run-task.ts test:local + + - name: Run local smoke tests with coverage + if: ${{ matrix.os == 'ubuntu-latest' }} + run: npm run test:e2e:coverage --workspace self-hosted-livesync-cli + + - name: Upload local smoke coverage reports + if: ${{ always() && matrix.os == 'ubuntu-latest' }} + uses: actions/upload-artifact@v6 + with: + name: cli-e2e-local-coverage + path: | + coverage/cli-e2e/** + !coverage/cli-e2e/raw/** + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/cli-p2p-tests.yml b/.github/workflows/cli-p2p-tests.yml index 478afad4..5db10f35 100644 --- a/.github/workflows/cli-p2p-tests.yml +++ b/.github/workflows/cli-p2p-tests.yml @@ -9,6 +9,8 @@ on: - '.github/workflows/cli-p2p-tests.yml' - 'src/apps/cli/**' - '!src/apps/cli/**/*.md' + - 'test/e2e-cli/**' + - '!test/e2e-cli/**/*.md' - 'src/LiveSyncBaseCore.ts' - 'src/common/**' - 'src/serviceFeatures/**' @@ -29,6 +31,8 @@ on: - '.github/workflows/cli-p2p-tests.yml' - 'src/apps/cli/**' - '!src/apps/cli/**/*.md' + - 'test/e2e-cli/**' + - '!test/e2e-cli/**/*.md' - 'src/LiveSyncBaseCore.ts' - 'src/common/**' - 'src/serviceFeatures/**' diff --git a/.github/workflows/unit-ci.yml b/.github/workflows/unit-ci.yml index 80147fd4..7afc8320 100644 --- a/.github/workflows/unit-ci.yml +++ b/.github/workflows/unit-ci.yml @@ -39,7 +39,7 @@ on: - '.github/workflows/prepare-release.yml' - '.github/workflows/finalise-release.yml' - '.github/workflows/release.yml' - - '.github/workflows/cli-deno-tests.yml' + - '.github/workflows/cli-node-tests.yml' - '.github/workflows/cli-p2p-tests.yml' - '.github/workflows/cli-docker-validation.yml' - '.github/workflows/cli-docker.yml' @@ -78,7 +78,7 @@ on: - '.github/workflows/prepare-release.yml' - '.github/workflows/finalise-release.yml' - '.github/workflows/release.yml' - - '.github/workflows/cli-deno-tests.yml' + - '.github/workflows/cli-node-tests.yml' - '.github/workflows/cli-p2p-tests.yml' - '.github/workflows/cli-docker-validation.yml' - '.github/workflows/cli-docker.yml' diff --git a/AGENTS.md b/AGENTS.md index 93172ba0..a84f2832 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,7 +63,7 @@ Always adhere to the following stylistic and spelling rules: - Treat `@vrtmrz/livesync-commonlib` as an external, authoritative package. Make Commonlib changes in its repository, validate the packed artefact and downstream LiveSync consumer, and update the exact dependency here. Do not recreate a `src/lib` source mirror or generated `_types` fallback. 4. **Application Directories**: - The directory [src/apps](src/apps) contains independent application modules: - - `cli`: A Command Line Interface application. Tests specifically for the CLI (both unit and End-to-End tests) are located and executed within [src/apps/cli](src/apps/cli) using its local `package.json` scripts. + - `cli`: A Command Line Interface application. CLI unit tests are next to their source under [src/apps/cli](src/apps/cli), while CLI End-to-End tests are in [test/e2e-cli](test/e2e-cli) and run through the local [CLI package scripts](src/apps/cli/package.json). - `webapp`: A Web-based application. - `webpeer`: A Web-based peer utility. diff --git a/devs.md b/devs.md index 5fdbbb2b..6c34120a 100644 --- a/devs.md +++ b/devs.md @@ -78,7 +78,7 @@ See the [test procedures](test/README.md) for clean-installation checks, local v Regression tests remain in the suite owned by the implementation under test. Plug-in tests may be co-located with their source, while independent application tests remain under `test/apps/` or `test/browser-apps/` so that they stay outside the Community Review source boundary. Prefix a case or group with `compatibility:` when it protects a persisted input or state which current releases still accept, and with `retirement guard:` when it prevents a removed setting, control, or notification from returning. Remove or replace a compatibility case only when the corresponding input is no longer accepted or an equivalent maintained case preserves the contract. Remove a retirement guard only when another current contract makes the old behaviour unreachable. Do not preserve a disconnected historical test as an executable specification when no maintained runner invokes it; Git history is the reference for retired test infrastructure. -- **CLI E2E** (`src/apps/cli/testdeno/`): Host-independent consumer workflows. The canonical Compose P2P suite covers ordinary two-peer synchronisation, replacement of the current Replicator followed by transfer with the same peer, and explicit relay disconnection followed by paused and resumed reconnection. Its lifecycle entry point is included only in the Docker test build and does not add a public CLI command. Run `npm run test:e2e:cli` for the ordinary suite or `npm run test:e2e:cli:p2p` for P2P validation. +- **CLI E2E** (`test/e2e-cli/`): Node.js 26 native TypeScript and `node:test` subprocess workflows. Host-independent consumer workflows. The canonical Compose P2P suite covers ordinary two-peer synchronisation, replacement of the current Replicator followed by transfer with the same peer, and explicit relay disconnection followed by paused and resumed reconnection. Its lifecycle entry point is included only in the Docker test build and does not add a public CLI command. Run `npm run test:e2e:cli` for the ordinary suite or `npm run test:e2e:cli:p2p` for P2P validation. - **Self-hosted setup tools** (`utils/couchdb/`, `utils/setup/`, and `utils/flyio/`): Deno contract tests consume the exact locked Commonlib registry package, verify current CouchDB, Object Storage, and random-room P2P Setup URI defaults and remote profiles, and keep CouchDB administration separate from package-owned LiveSync database-version negotiation. `unit-ci` also provisions a real temporary CouchDB database and verifies its version document against the installed Commonlib package. Run `npm run test:setup-tools` for the local contract gate. - **Real Obsidian E2E** (`test/e2e-obsidian/`): Local-first scripts that launch real Obsidian with temporary vaults and the built Self-hosted LiveSync plug-in. Use these for boot-up sequence, vault reflection, RedFlag flows, Fast Setup (Simple Fetch), settings dialogues, restart-sensitive workflows, Object Storage regressions, and other behaviour that depends on Obsidian itself. After source changes, run the build-owning wrapper, for example `npm run test:e2e:obsidian:focused -- two-vault-sync`, with the required fixture already running. Direct scenario scripts reuse the existing bundle and are suitable for reruns only while that build remains unchanged. Use `npm run test:e2e:obsidian:local-suite:services` to run the broader local suite with CouchDB and RustFS fixtures managed by the wrapper. diff --git a/docs/specs_data_compression.md b/docs/specs_data_compression.md index 119bc879..ed419bcf 100644 --- a/docs/specs_data_compression.md +++ b/docs/specs_data_compression.md @@ -33,7 +33,7 @@ The current benchmark measures the Node CLI process. It does not establish Obsid ## Reproducible benchmark -The benchmark is implemented under `src/apps/cli/testdeno` and packaged by `test/bench-network`. It exercises the real CLI mirror and synchronisation path. The recorded three-repeat result below used Commonlib 0.1.0-rc.4, CouchDB 3.5.0, the V3 Rabin–Karp splitter, optional Data Compression, and E2EE V2. +The benchmark is implemented under `test/e2e-cli` and packaged by `test/bench-network`. It exercises the real CLI mirror and synchronisation path. The recorded three-repeat result below used Commonlib 0.1.0-rc.4, CouchDB 3.5.0, the V3 Rabin–Karp splitter, optional Data Compression, and E2EE V2. The current Compose runner installs the checked-out repository's package and lockfile with `npm ci`; Self-hosted LiveSync 1.0.42 selects Commonlib 0.2.2. Running the command below measures that checkout, rather than recreating the older package environment automatically. Keep the recorded results associated with their original measurement date and dependency version. diff --git a/eslint.community.config.mjs b/eslint.community.config.mjs index e81aeb31..64447714 100644 --- a/eslint.community.config.mjs +++ b/eslint.community.config.mjs @@ -20,7 +20,7 @@ export default defineConfig( "**/*.unit.spec.ts", "**/test/**", "src/apps/_test/**", - "src/apps/cli/testdeno/**", + "test/e2e-cli/**", ]), { languageOptions: { diff --git a/eslint.config.mjs b/eslint.config.mjs index bcbd8688..afb7bdeb 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -48,7 +48,7 @@ export default defineConfig([ "test/**", "**/test/**", "src/apps/_test/**", - "src/apps/cli/testdeno/**", + "test/e2e-cli/**", "**/*.test.ts", "**/*.unit.spec.ts", "**/test.ts", diff --git a/package-lock.json b/package-lock.json index e4cba913..bd8cd0f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,6 +57,7 @@ "@typescript-eslint/parser": "8.69.0", "@vitest/coverage-v8": "^4.1.12", "@vrtmrz/obsidian-test-session": "0.2.6", + "c8": "^12.0.0", "dotenv-cli": "^11.0.0", "esbuild": "0.28.1", "esbuild-plugin-inline-worker": "^0.1.1", @@ -1822,6 +1823,16 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -3427,6 +3438,13 @@ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -4275,6 +4293,19 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.5.0.tgz", + "integrity": "sha512-zhvbbbdt1kbw7EJ9OQoxmQcuaNR9P5kTj7e/YdmOSU10aHWbycofJxA6jKfoEy3vlLdctOF9ZHaxLtmSm998wQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -4617,6 +4648,40 @@ "dev": true, "license": "MIT" }, + "node_modules/c8": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/c8/-/c8-12.0.0.tgz", + "integrity": "sha512-4zpJvrd1nKWutnnKC2pXkFmb6iM1l+ffN//o1CzlTNwW7GSOs9a1xrLqkC48nU8oEkjmPZLPiwMsIaOvoF4Pqg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.1", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^3.1.1", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.6", + "test-exclude": "^8.0.0", + "v8-to-istanbul": "^9.0.0", + "yargs": "^18.0.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "c8": "bin/c8.js" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + }, + "peerDependencies": { + "monocart-coverage-reports": "^2" + }, + "peerDependenciesMeta": { + "monocart-coverage-reports": { + "optional": true + } + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -4728,6 +4793,39 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -5093,6 +5191,13 @@ "node": ">= 0.4" } }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, "node_modules/empathic": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.1.tgz", @@ -5429,6 +5534,16 @@ "node": ">=18" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -6529,6 +6644,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -6610,6 +6742,29 @@ "node": ">= 0.4" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -6680,6 +6835,24 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -8206,6 +8379,16 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/ltgt": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/ltgt/-/ltgt-2.2.1.tgz", @@ -8371,6 +8554,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/module-replacements": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/module-replacements/-/module-replacements-2.11.0.tgz", @@ -8798,6 +8991,23 @@ "dev": true, "license": "MIT" }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -9892,6 +10102,19 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -9986,6 +10209,23 @@ ], "license": "MIT" }, + "node_modules/string-width": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz", + "integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -10084,6 +10324,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -10408,6 +10664,21 @@ "node": ">=10" } }, + "node_modules/test-exclude": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", + "integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^13.0.6", + "minimatch": "^10.2.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/through2": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/through2/-/through2-3.0.2.tgz", @@ -10844,6 +11115,21 @@ "uuid": "dist/esm/bin/uuid" } }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, "node_modules/vite": { "version": "8.1.6", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.6.tgz", @@ -11280,6 +11566,55 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/write-stream": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/write-stream/-/write-stream-0.4.3.tgz", @@ -11325,6 +11660,16 @@ "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==", "license": "MIT" }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", @@ -11358,6 +11703,44 @@ "url": "https://github.com/sponsors/ota-meshi" } }, + "node_modules/yargs": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz", + "integrity": "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 5af71d1a..29cd5e20 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "lint:community:tools": "eslint --config eslint.community.config.mjs --concurrency off --max-warnings 0 _tools", "svelte-check": "svelte-check --tsconfig ./tsconfig.json --fail-on-warnings", "tsc-check": "tsc --noEmit", - "tsc-check:apps": "tsc --noEmit -p src/apps/browser/tsconfig.json && tsc --noEmit -p src/apps/cli/tsconfig.json && tsc --noEmit -p src/apps/webapp/tsconfig.json && tsc --noEmit -p src/apps/webpeer/tsconfig.app.json && tsc --noEmit -p src/apps/webpeer/tsconfig.node.json", + "tsc-check:apps": "tsc --noEmit -p src/apps/browser/tsconfig.json && tsc --noEmit -p src/apps/cli/tsconfig.json && tsc --noEmit -p test/e2e-cli/tsconfig.json && tsc --noEmit -p src/apps/webapp/tsconfig.json && tsc --noEmit -p src/apps/webpeer/tsconfig.app.json && tsc --noEmit -p src/apps/webpeer/tsconfig.node.json", "pretty:importpath": "cd utilsdeno && deno run -A ./normalise-imports.ts", "pretty:json": "prettier --config ./.prettierrc.mjs \"**/*.json\" --write --log-level error", "pretty": "npm run prettyNoWrite -- --write --log-level error", @@ -41,7 +41,7 @@ "test:setup-tools": "bash utils/flyio/setenv.test.sh && deno test -A --config=utils/flyio/deno.jsonc --frozen --lock=utils/flyio/deno.lock utils/livesync-commonlib-version.test.ts utils/couchdb/provision.test.ts utils/setup/generate_setup_uri.test.ts utils/flyio/generate_setupuri.test.ts", "test:contract:contexts": "vitest run --config vitest.config.unit.ts src/modules/services/ObsidianServiceContext.unit.spec.ts src/apps/cli/services/NodeServiceContext.unit.spec.ts src/apps/browser/createLiveSyncBrowserServiceHub.unit.spec.ts", "test:contract:context:webapp": "vitest run --config vitest.config.unit.ts src/apps/browser/createLiveSyncBrowserServiceHub.unit.spec.ts", - "test:contract:context:cli": "npm run build --workspace self-hosted-livesync-cli && deno task --cwd src/apps/cli/testdeno test:setup-put-cat", + "test:contract:context:cli": "npm run build --workspace self-hosted-livesync-cli && npm run test:e2e:task --workspace self-hosted-livesync-cli -- test:setup-put-cat", "test:contract:context:obsidian": "npm run build && npm run test:e2e:obsidian:smoke", "test:e2e:cli": "npm run test:e2e:ci --workspace self-hosted-livesync-cli", "test:e2e:cli:p2p": "npm run test:e2e:p2p --workspace self-hosted-livesync-cli", @@ -158,6 +158,7 @@ "@typescript-eslint/parser": "8.69.0", "@vitest/coverage-v8": "^4.1.12", "@vrtmrz/obsidian-test-session": "0.2.6", + "c8": "^12.0.0", "dotenv-cli": "^11.0.0", "esbuild": "0.28.1", "esbuild-plugin-inline-worker": "^0.1.1", diff --git a/src/apps/cli/main.bootstrap.unit.spec.ts b/src/apps/cli/main.bootstrap.unit.spec.ts index 033771a1..14bbf804 100644 --- a/src/apps/cli/main.bootstrap.unit.spec.ts +++ b/src/apps/cli/main.bootstrap.unit.spec.ts @@ -3,6 +3,7 @@ import { appendFile, mkdtemp, mkdir, readFile, rm, stat, writeFile } from "node: import { tmpdir } from "node:os"; import { join } from "node:path"; import * as chokidar from "chokidar"; +import { path } from "@vrtmrz/livesync-commonlib/node"; import * as offlineScanner from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner"; import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService"; import { @@ -20,6 +21,11 @@ import { main, type CliCommandRunner } from "./main"; import { cloneSettings } from "./settingsPersistence"; import type { CLICommand } from "./commands/types"; +vi.mock("@vrtmrz/livesync-commonlib/node", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, path: { ...actual.path } }; +}); + vi.mock("chokidar", { spy: true }); vi.mock("@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner", { spy: true }); @@ -88,6 +94,36 @@ describe("CLI database preparation", () => { await expect(main(standardIo, runner)).rejects.toThrow(`__EXIT__:${exitCode}`); } + it.each([ + { separator: "/", platform: "POSIX" }, + { separator: "\\", platform: "Windows" }, + ])("excludes internal Vault paths with the $platform host separator", async ({ separator }) => { + const targets = [ + "headless-vault-livesync-v2/LOCK", + ".livesync/settings.json", + "notes/.private/secret.md", + "notes/local.md", + ]; + const observed: boolean[] = []; + const runner = vi.fn(async (_options, { core }) => { + const originalSeparator = path.sep; + try { + Object.defineProperty(path, "sep", { value: separator }); + for (const target of targets) { + observed.push(await core.services.vault.isTargetFile(target as FilePathWithPrefix)); + } + } finally { + Object.defineProperty(path, "sep", { value: originalSeparator }); + } + return false; + }); + + await start("ls", runner); + + expect(runner).toHaveBeenCalledOnce(); + expect(observed).toEqual([false, false, false, true]); + }); + it("loads legacy device values and persists configuration through the service-owned store", async () => { const localStoragePath = join(directory, ".livesync", "runtime", "local-storage.json"); await mkdir(join(directory, ".livesync", "runtime"), { recursive: true }); diff --git a/src/apps/cli/main.ts b/src/apps/cli/main.ts index cea0ec35..9522ba21 100644 --- a/src/apps/cli/main.ts +++ b/src/apps/cli/main.ts @@ -550,7 +550,7 @@ export async function main( // Add target filter to prevent internal files are handled core.services.vault.isTargetFile.addHandler(async (target) => { const targetPath = stripAllPrefixes(getPathFromUXFileInfo(target)); - const parts = targetPath.split(path.sep); + const parts = targetPath.split("/"); // if some part of the path starts with dot, treat it as internal file and ignore. if (parts.some((part) => part.startsWith("."))) { return await Promise.resolve(false); diff --git a/src/apps/cli/package.json b/src/apps/cli/package.json index d0c9f2b1..c8748f72 100644 --- a/src/apps/cli/package.json +++ b/src/apps/cli/package.json @@ -20,19 +20,21 @@ "test:e2e:setup-put-cat": "bash test/test-setup-put-cat-linux.sh", "test:e2e:sync-two-local": "bash test/test-sync-two-local-databases-linux.sh", "pretest:e2e:ci": "npm run build", - "test:e2e:ci": "deno task --cwd testdeno test:ci", - "test:e2e:p2p": "deno task --cwd testdeno test:p2p:compose", + "test:e2e:ci": "node ../../../test/e2e-cli/run-ci-suite.ts", + "test:e2e:p2p": "node ../../../test/e2e-cli/run-compose-p2p.ts", "test:e2e:mirror": "bash test/test-mirror-linux.sh", "test:e2e:remote-commands": "bash test/test-remote-commands-linux.sh", "pretest:e2e:all": "npm run build", - "test:e2e:all": "deno task --cwd testdeno test:ci && deno task --cwd testdeno test:p2p:compose", + "test:e2e:all": "node ../../../test/e2e-cli/run-ci-suite.ts && node ../../../test/e2e-cli/run-compose-p2p.ts", "pretest:e2e:docker:all": "npm run build:docker", "test:e2e:docker:push-pull": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-push-pull-linux.sh", "test:e2e:docker:setup-put-cat": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-setup-put-cat-linux.sh", "test:e2e:docker:mirror": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-mirror-linux.sh", "test:e2e:docker:remote-commands": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-remote-commands-linux.sh", "test:e2e:docker:sync-two-local": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-sync-two-local-databases-linux.sh", - "test:e2e:docker:all": "export RUN_BUILD=0 && npm run test:e2e:docker:setup-put-cat && npm run test:e2e:docker:push-pull && npm run test:e2e:docker:sync-two-local && npm run test:e2e:docker:mirror && npm run test:e2e:docker:remote-commands" + "test:e2e:docker:all": "export RUN_BUILD=0 && npm run test:e2e:docker:setup-put-cat && npm run test:e2e:docker:push-pull && npm run test:e2e:docker:sync-two-local && npm run test:e2e:docker:mirror && npm run test:e2e:docker:remote-commands", + "test:e2e:task": "node ../../../test/e2e-cli/run-task.ts", + "test:e2e:coverage": "node ../../../test/e2e-cli/run-coverage.ts" }, "dependencies": { "chokidar": "^4.0.0", diff --git a/src/apps/cli/test-support/p2p-automatic-host.test.ts b/src/apps/cli/test-support/p2p-automatic-host.test.ts index 229fcc57..f4e03930 100644 --- a/src/apps/cli/test-support/p2p-automatic-host.test.ts +++ b/src/apps/cli/test-support/p2p-automatic-host.test.ts @@ -82,7 +82,7 @@ export async function runP2PAutomaticHostFixture(options: CLIOptions, context: C } const commandOutcome = runCommand(options, context).then( (result) => ({ type: "result" as const, result }), - (error) => ({ type: "error" as const, error }) + (error: unknown) => ({ type: "error" as const, error }) ); const connectedWaitStarted = Date.now(); while (!service.transportLifecycle.isConnected) { diff --git a/src/apps/cli/testdeno/CONTRIBUTING_TESTS.md b/src/apps/cli/testdeno/CONTRIBUTING_TESTS.md index 051cfa9c..2bb1999a 100644 --- a/src/apps/cli/testdeno/CONTRIBUTING_TESTS.md +++ b/src/apps/cli/testdeno/CONTRIBUTING_TESTS.md @@ -1,151 +1,3 @@ -# Writing CLI Tests on Deno +# Moved -This guide explains how to add or update tests under `src/apps/cli/testdeno/`. -Note that new tests should be added to the Deno suite rather than the existing bash suite due to the cross-platform execution and TypeScript benefits. - -## Scope - -The Deno suite is designed for cross-platform execution, with a strong focus on Windows compatibility while keeping behaviour equivalent to existing bash tests. - -## Principles - -- Keep one scenario per file when practical. -- Reuse helpers from `helpers/` rather than duplicating process, Docker, or settings logic. -- Prefer deterministic data over random inputs unless randomness is explicitly required. -- Ensure every test can clean up automatically. -- Keep assertions actionable with clear failure messages. - -## Directory structure - -``` -src/apps/cli/testdeno/ - helpers/ - backgroundCli.ts - cli.ts - docker.ts - env.ts - p2p.ts - settings.ts - temp.ts - test-*.ts - deno.json -``` - -## Test file naming - -- Use `test-.ts`. -- Use names aligned with existing bash tests when porting, for example: - - `test-sync-locked-remote.ts` - - `test-p2p-sync.ts` - -## Core helper usage - -### Temporary workspace - -Use `TempDir` and `await using` so cleanup is automatic: - -```ts -await using workDir = await TempDir.create("livesync-cli-my-test"); -``` - -### CLI execution - -- `runCli(...)`: returns code and combined output. -- `runCliOrFail(...)`: throws on non-zero exit. -- `runCliWithInputOrFail(input, ...)`: for `put` and stdin-driven commands. - -### Settings - -- `initSettingsFile(...)`: creates a baseline settings file. -- `applyCouchdbSettings(...)`: applies CouchDB fields. -- `applyRemoteSyncSettings(...)`: applies remote and encryption fields. -- `applyP2pSettings(...)`: applies P2P fields. -- `applyP2pTestTweaks(...)`: enables P2P-only test profile. - -### Docker services - -- `startCouchdb(...)`, `stopCouchdb()` -- `startP2pRelay()`, `stopP2pRelay()` - -### P2P discovery - -- `discoverPeer(...)` -- `maybeStartLocalRelay(...)` -- `stopLocalRelayIfStarted(...)` - -### Background host process - -Use `startCliInBackground(...)` for long-running host mode such as `p2p-host`. - -## Recommended test structure - -1. Arrange -2. Act -3. Assert -4. Cleanup in `finally` - -Example skeleton: - -```ts -Deno.test("feature: behaviour", async () => { - await using workDir = await TempDir.create("example"); - // Arrange - - try { - // Act - - // Assert - } finally { - // Optional explicit cleanup - } -}); -``` - -## Reliability guidelines - -- Use explicit waits only when needed for eventual consistency. -- Re-run sync operations where the protocol is eventually consistent. -- For network-sensitive commands, use `LIVESYNC_CLI_RETRY` during debugging. -- Keep Docker container reuse disabled by default unless debugging. -- Use `npm run test:e2e:cli:p2p` for canonical P2P validation. It runs the Deno scenario in Compose because host networking and WebRTC candidate selection are not reproducible across environments. Individual `deno task test:p2p-*` tasks remain available when explicitly invoked for cross-platform diagnostics, but are not selected by the default suite or CI. - -## Environment variables - -Common variables: - -- `LIVESYNC_DOCKER_MODE` -- `LIVESYNC_DOCKER_COMMAND` -- `LIVESYNC_TEST_TEE` -- `LIVESYNC_DOCKER_TEE` -- `LIVESYNC_CLI_DEBUG` -- `LIVESYNC_CLI_VERBOSE` -- `LIVESYNC_CLI_RETRY` -- `LIVESYNC_DEBUG_KEEP_DOCKER` - -P2P variables: - -- `RELAY` -- `ROOM_ID` -- `PASSPHRASE` -- `APP_ID` -- `PEERS_TIMEOUT` -- `SYNC_TIMEOUT` -- `USE_INTERNAL_RELAY` - -## Adding a new test task - -1. Add the test file under `src/apps/cli/testdeno/`. -2. Add a task in `src/apps/cli/testdeno/deno.json`. -3. Update `src/apps/cli/testdeno/test_dev_deno.md`. -4. Run the new task locally. - -## Validation checklist - -- The test passes on a clean workspace. -- The test does not leave persistent artefacts unless explicitly requested. -- Failure messages identify both expected and actual behaviour. -- The corresponding task is documented. - -## Out of scope for this suite - -- One-off reproduction scripts that are not intended as stable regression tests. +The CLI test authoring guide is now [test/e2e-cli/CONTRIBUTING_TESTS.md](../../../../test/e2e-cli/CONTRIBUTING_TESTS.md). diff --git a/src/apps/cli/testdeno/deno.json b/src/apps/cli/testdeno/deno.json deleted file mode 100644 index 4d114f82..00000000 --- a/src/apps/cli/testdeno/deno.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "tasks": { - "test": "deno task test:ci", - "test:ci": "deno run -A --no-check run-ci-suite.ts", - "test:p2p:compose": "deno run -A --no-check run-compose-p2p.ts", - "test:local": "deno test --env-file=.test.env -A --no-check test-setup-put-cat.ts test-mirror.ts test-daemon.ts", - "test:daemon": "deno test --env-file=.test.env -A --no-check test-daemon.ts", - "test:daemon-startup": "deno test --env-file=.test.env -A --no-check test-daemon-startup.ts", - "test:decoupled-vault": "deno test --env-file=.test.env -A --no-check test-decoupled-vault.ts", - "test:remote-commands": "deno test --env-file=.test.env -A --no-check test-remote-commands.ts", - "test:settings-writeback": "deno test -A --no-check test-settings-writeback.ts", - "test:remote-administration-exit-codes": "deno test -A --no-check test-remote-administration-exit-codes.ts", - "test:push-pull": "deno test --env-file=.test.env -A --no-check test-push-pull.ts", - "test:setup-put-cat": "deno test --env-file=.test.env -A --no-check test-setup-put-cat.ts", - "test:mirror": "deno test --env-file=.test.env -A --no-check test-mirror.ts", - "test:sync-two-local": "deno test --env-file=.test.env -A --no-check test-sync-two-local-databases.ts", - "test:sync-locked-remote": "deno test --env-file=.test.env -A --no-check test-sync-locked-remote.ts", - "test:p2p-host": "deno test --env-file=.test.env -A --no-check test-p2p-host.ts", - "test:p2p-peers": "deno test --env-file=.test.env -A --no-check test-p2p-peers-local-relay.ts", - "test:p2p-sync": "deno test --env-file=.test.env -A --no-check test-p2p-sync.ts", - "test:p2p-automatic-host": "deno test --env-file=.test.env -A --no-check test-p2p-automatic-host.ts", - "test:p2p-replacement": "deno test --env-file=.test.env -A --no-check test-p2p-replicator-replacement.ts", - "test:p2p-relay-disconnect": "deno test --env-file=.test.env -A --no-check test-p2p-relay-disconnect.ts", - "test:p2p:ci": "deno test --env-file=.test.env -A --no-check test-p2p-sync.ts test-p2p-automatic-host.ts test-p2p-replicator-replacement.ts test-p2p-relay-disconnect.ts", - "test:p2p-three-nodes": "deno test --env-file=.test.env -A --no-check test-p2p-three-nodes-conflict.ts", - "test:p2p-upload-download": "deno test --env-file=.test.env -A --no-check test-p2p-upload-download-repro.ts", - "test:benchmark-contract": "deno test --env-file=.test.env -A --no-check test-benchmark-contract.ts", - "bench:p2p": "deno run --env-file=.test.env -A --no-check bench-p2p.ts", - "bench:couchdb": "deno run --env-file=.test.env -A --no-check bench-couchdb.ts", - "bench:compression": "deno run --env-file=.test.env -A --no-check bench-compression.ts", - "bench:cases": "deno run --env-file=.test.env -A --no-check bench-network-cases.ts", - "bench:latency-sweep": "deno run --env-file=.test.env -A --no-check bench-latency-sweep.ts", - "bench:p2p-split-node": "deno run --env-file=.test.env -A --no-check bench-p2p-split-node.ts", - "bench:item1": "bash ./bench-run-item1.sh", - "bench:item1:full": "BENCH_MD_FILE_COUNT=1500 BENCH_MD_MIN_SIZE_BYTES=1024 BENCH_MD_MAX_SIZE_BYTES=20480 BENCH_BIN_FILE_COUNT=500 BENCH_BIN_SIZE_BYTES=102400 BENCH_COUCHDB_RTT_MS=50 bash ./bench-run-item1.sh", - "test:e2e-couchdb": "deno test --env-file=.test.env -A --no-check test-e2e-two-vaults-couchdb.ts", - "test:e2e-matrix": "deno test --env-file=.test.env -A --no-check test-e2e-two-vaults-matrix.ts", - "test:e2e-matrix:couchdb-enc0": "deno test --env-file=.test.env -A --no-check --filter='e2e matrix: COUCHDB-enc0' test-e2e-two-vaults-matrix.ts", - "test:e2e-matrix:couchdb-enc1": "deno test --env-file=.test.env -A --no-check --filter='e2e matrix: COUCHDB-enc1' test-e2e-two-vaults-matrix.ts", - "test:e2e-matrix:minio-enc0": "deno test --env-file=.test.env -A --no-check --filter='e2e matrix: MINIO-enc0' test-e2e-two-vaults-matrix.ts", - "test:e2e-matrix:minio-enc1": "deno test --env-file=.test.env -A --no-check --filter='e2e matrix: MINIO-enc1' test-e2e-two-vaults-matrix.ts" - }, - "imports": { - "@std/assert": "jsr:@std/assert@^1.0.13", - "@std/path": "jsr:@std/path@^1.0.9" - } -} diff --git a/src/apps/cli/testdeno/deno.lock b/src/apps/cli/testdeno/deno.lock deleted file mode 100644 index a65fb541..00000000 --- a/src/apps/cli/testdeno/deno.lock +++ /dev/null @@ -1,31 +0,0 @@ -{ - "version": "5", - "specifiers": { - "jsr:@std/assert@^1.0.13": "1.0.19", - "jsr:@std/internal@^1.0.12": "1.0.12", - "jsr:@std/path@^1.0.9": "1.1.4" - }, - "jsr": { - "@std/assert@1.0.19": { - "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", - "dependencies": [ - "jsr:@std/internal" - ] - }, - "@std/internal@1.0.12": { - "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" - }, - "@std/path@1.1.4": { - "integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5", - "dependencies": [ - "jsr:@std/internal" - ] - } - }, - "workspace": { - "dependencies": [ - "jsr:@std/assert@^1.0.13", - "jsr:@std/path@^1.0.9" - ] - } -} diff --git a/src/apps/cli/testdeno/helpers/net.ts b/src/apps/cli/testdeno/helpers/net.ts deleted file mode 100644 index 35a9d473..00000000 --- a/src/apps/cli/testdeno/helpers/net.ts +++ /dev/null @@ -1,68 +0,0 @@ -type WaitForPortOptions = { - timeoutMs?: number; - intervalMs?: number; - connectTimeoutMs?: number; -}; - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function connectWithTimeout(hostname: string, port: number, timeoutMs: number): Promise { - let timer: ReturnType | undefined; - try { - const connPromise = Deno.connect({ hostname, port }); - const timeoutPromise = new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(`connect timeout after ${timeoutMs}ms`)), timeoutMs); - }); - const conn = await Promise.race([connPromise, timeoutPromise]); - conn.close(); - } finally { - if (timer !== undefined) { - clearTimeout(timer); - } - } -} - -export async function waitForPort(hostname: string, port: number, options: WaitForPortOptions = {}): Promise { - const timeoutMs = options.timeoutMs ?? 15000; - const intervalMs = options.intervalMs ?? 250; - const connectTimeoutMs = options.connectTimeoutMs ?? 1000; - - const started = Date.now(); - let lastError: unknown; - - while (Date.now() - started < timeoutMs) { - try { - await connectWithTimeout(hostname, port, connectTimeoutMs); - return; - } catch (error) { - lastError = error; - await sleep(intervalMs); - } - } - - throw new Error( - `Port ${hostname}:${port} did not become ready within ${timeoutMs}ms` + - (lastError ? ` (last error: ${String(lastError)})` : "") - ); -} - -export async function getOptimalLoopbackIp(): Promise { - const ipv4 = "127.0.0.1"; - const ipv6 = "::1"; - - try { - const l = Deno.listen({ hostname: ipv4, port: 0 }); - l.close(); - return ipv4; - } catch { - try { - const l = Deno.listen({ hostname: ipv6, port: 0 }); - l.close(); - return ipv6; - } catch { - return ipv4; // fallback to default - } - } -} diff --git a/src/apps/cli/testdeno/helpers/temp.ts b/src/apps/cli/testdeno/helpers/temp.ts deleted file mode 100644 index 655d18d7..00000000 --- a/src/apps/cli/testdeno/helpers/temp.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { join } from "@std/path"; - -/** - * A temporary directory that cleans itself up via `await using`. - * Requires TypeScript 5.2+ / Deno 1.40+ for the AsyncDisposable protocol. - * - * @example - * ```ts - * await using tmp = await TempDir.create(); - * const file = tmp.join("data.json"); - * ``` - */ -export class TempDir implements AsyncDisposable { - readonly path: string; - - private constructor(path: string) { - this.path = path; - } - - static async create(prefix = "livesync-deno-test"): Promise { - const path = await Deno.makeTempDir({ prefix: `${prefix}.` }); - return new TempDir(path); - } - - /** Return an OS path joined to the temp directory root. */ - join(...parts: string[]): string { - return join(this.path, ...parts); - } - - async [Symbol.asyncDispose](): Promise { - await Deno.remove(this.path, { recursive: true }).catch(() => {}); - } -} diff --git a/src/apps/cli/testdeno/run-compose-p2p.ts b/src/apps/cli/testdeno/run-compose-p2p.ts deleted file mode 100644 index 66ed898f..00000000 --- a/src/apps/cli/testdeno/run-compose-p2p.ts +++ /dev/null @@ -1,50 +0,0 @@ -const repositoryRoot = await Deno.realPath(new URL("../../../../", import.meta.url)); -const composeArgs = ["compose", "-f", "test/bench-network/compose.yml"]; -const p2pEnvironment = { - CLI_E2E_TASK: Deno.env.get("CLI_E2E_TASK") ?? "test:p2p:ci", - RELAY: Deno.env.get("RELAY") ?? "ws://nostr-relay:7777/", - PEERS_TIMEOUT: Deno.env.get("PEERS_TIMEOUT") ?? "20", - SYNC_TIMEOUT: Deno.env.get("SYNC_TIMEOUT") ?? "60", - LIVESYNC_USE_COTURN: Deno.env.get("LIVESYNC_USE_COTURN") ?? "0", - TURN_SERVERS: Deno.env.get("TURN_SERVERS") ?? "none", - LIVESYNC_P2P_PEERS_RETRY: Deno.env.get("LIVESYNC_P2P_PEERS_RETRY") ?? "1", - LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: Deno.env.get("LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS") ?? "60000", - BENCH_LIVESYNC_TEST_TEE: Deno.env.get("BENCH_LIVESYNC_TEST_TEE") ?? "0", - LIVESYNC_CLI_DEBUG: Deno.env.get("LIVESYNC_CLI_DEBUG") ?? "0", - LIVESYNC_CLI_VERBOSE: Deno.env.get("LIVESYNC_CLI_VERBOSE") ?? "0", -}; - -async function runDocker(args: string[], env?: Record): Promise { - return await new Deno.Command("docker", { - args, - cwd: repositoryRoot, - env, - stdin: "inherit", - stdout: "inherit", - stderr: "inherit", - }).spawn().status; -} - -let testStatus: Deno.CommandStatus | undefined; -try { - testStatus = await runDocker( - [...composeArgs, "run", "--build", "--rm", "bench-runner", "run-livesync-cli-e2e"], - p2pEnvironment - ); -} finally { - const cleanupStatus = await runDocker([...composeArgs, "down", "-v", "--remove-orphans"]); - if (!cleanupStatus.success) { - console.error(`[CLI E2E] Compose cleanup failed with exit code ${cleanupStatus.code}.`); - if (testStatus?.success) { - Deno.exit(cleanupStatus.code); - } - } -} - -if (!testStatus?.success) { - const code = testStatus?.code ?? 1; - console.error(`[CLI E2E] Compose P2P suite failed with exit code ${code}.`); - Deno.exit(code); -} - -console.log("\n[CLI E2E] Compose P2P suite passed."); diff --git a/src/apps/cli/testdeno/test-p2p-relay-disconnect.ts b/src/apps/cli/testdeno/test-p2p-relay-disconnect.ts deleted file mode 100644 index 1dc00778..00000000 --- a/src/apps/cli/testdeno/test-p2p-relay-disconnect.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { assert, assertEquals } from "@std/assert"; - -Deno.test("p2p lifecycle: explicit disconnect closes and pauses relay WebSockets", async () => { - const command = new Deno.Command("node", { - args: [new URL("./relay-disconnect-probe.mjs", import.meta.url).pathname], - env: { - RELAY: Deno.env.get("RELAY") ?? "ws://nostr-relay:7777/", - RELAY_TIMEOUT_MS: Deno.env.get("LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS") ?? "15000", - }, - stdout: "piped", - stderr: "piped", - }); - const result = await command.output(); - const stdout = new TextDecoder().decode(result.stdout).trim(); - const stderr = new TextDecoder().decode(result.stderr).trim(); - - assert(result.success, `Relay disconnect probe failed\nstdout: ${stdout}\nstderr: ${stderr}`); - const report = JSON.parse(stdout.split("\n").at(-1) ?? "{}") as { - socketsClosed?: number; - stayedDisconnectedWhilePaused?: boolean; - reconnectedAfterResume?: boolean; - }; - assert((report.socketsClosed ?? 0) > 0, "The probe did not observe an open relay WebSocket"); - assertEquals(report.stayedDisconnectedWhilePaused, true); - assertEquals(report.reconnectedAfterResume, true); -}); diff --git a/src/apps/cli/testdeno/test_dev_deno.md b/src/apps/cli/testdeno/test_dev_deno.md index bdd67142..ca5065f8 100644 --- a/src/apps/cli/testdeno/test_dev_deno.md +++ b/src/apps/cli/testdeno/test_dev_deno.md @@ -1,351 +1,3 @@ -# CLI Deno Test Development Notes +# Moved -This document provides an overview of the Deno-based compatibility tests under `src/apps/cli/testdeno/`. -The Deno suite is the canonical CLI E2E entry point. P2P scenarios run through the repository Compose entry point so that networking, signalling, and the runner environment are reproducible. Existing Bash tests under `src/apps/cli/test/` remain as legacy implementation references, but are not exposed as supported P2P entry points. - ---- - -## Goals - -- Keep the existing Bash tests as migration references while using Deno and Compose for supported execution. -- Provide direct execution from Windows PowerShell. -- Establish a TypeScript (Deno) foundation for core end-to-end and integration scenarios. - ---- - -## Directory structure - -``` -src/apps/cli/testdeno/ - deno.json - run-ci-suite.ts - run-compose-p2p.ts - CONTRIBUTING_TESTS.md - helpers/ - backgroundCli.ts - cli.ts - docker.ts - env.ts - p2p.ts - settings.ts - temp.ts - test-e2e-two-vaults-couchdb.ts - test-push-pull.ts - test-p2p-host.ts - test-p2p-peers-local-relay.ts - test-p2p-sync.ts - test-p2p-automatic-host.ts - test-p2p-three-nodes-conflict.ts - test-p2p-upload-download-repro.ts - test-e2e-two-vaults-matrix.ts - test-setup-put-cat.ts - test-mirror.ts - test-sync-two-local-databases.ts - test-sync-locked-remote.ts - test-daemon.ts - test-decoupled-vault.ts - test-remote-commands.ts -``` - ---- - -## Key files - -### `deno.json` - -- Defines Deno tasks. -- Defines import maps for `@std/assert` and `@std/path`. - -Main tasks: - -- `deno task test` -- `deno task test:ci` -- `deno task test:p2p:compose` -- `deno task test:local` -- `deno task test:daemon` -- `deno task test:decoupled-vault` -- `deno task test:remote-commands` -- `deno task test:push-pull` -- `deno task test:setup-put-cat` -- `deno task test:mirror` -- `deno task test:sync-two-local` -- `deno task test:sync-locked-remote` -- `deno task test:p2p-host` -- `deno task test:p2p-peers` -- `deno task test:p2p-sync` -- `deno task test:p2p-automatic-host` -- `deno task test:p2p-three-nodes` -- `deno task test:p2p-upload-download` -- `deno task test:e2e-couchdb` -- `deno task test:e2e-matrix` - -`deno task test` is an alias for the non-P2P `test:ci` suite. The individual P2P tasks are explicit host-direct entry points for cross-platform diagnostics; they are never selected by the default suite or CI. Use `test:p2p:compose` for canonical P2P verification. - -### `helpers/cli.ts` - -- CLI execution wrappers. -- `runCli`, `runCliOrFail`, `runCliWithInput`. -- Output normalisation via `sanitiseCatStdout`. -- Comparison utilities, including `assertFilesEqual`. - -This file corresponds to `run_cli` and common assertions in `test-helpers.sh`. - -### `helpers/settings.ts` - -- Executes `init-settings --force`. -- Marks `isConfigured = true`. -- Applies CouchDB and P2P settings. -- Applies remote synchronisation settings and P2P test tweaks. - -This file corresponds to settings helpers in `test-helpers.sh`. - -### `helpers/docker.ts` - -- Starts, stops, and initialises CouchDB and RustFS directly from Deno. -- Configures CouchDB via `fetch + retry`. -- Initialises S3 buckets using the RustFS `rc` client, including CORS for signed browser requests. -- Starts and stops the P2P relay through the same Docker runner. - -These flows do not require Bash on the host. The S3 matrix tasks, environment variables, and container name retain their existing `minio` names for compatibility; RustFS provides the test backend. The RustFS server and `rc` client images are pinned by version and digest. - -### `helpers/backgroundCli.ts` - -- Starts long-running commands such as `p2p-host` in the background. -- Waits for readiness logs and handles termination. - -### `helpers/p2p.ts` - -- Determines whether a local relay should be started. -- Parses `p2p-peers` output. -- Discovers peer IDs with a fallback based on advertisement logs. - -### `helpers/env.ts` - -- Loads `.test.env`. -- Supports `KEY=value`, single-quoted values, and double-quoted values. - -### `helpers/temp.ts` - -- Provides `TempDir`. -- Uses `await using` to auto-clean temporary directories. - ---- - -## Implemented tests - -### `test-push-pull.ts` - -- Verifies push and pull round trips. -- Uses environment variables or `.test.env` for CouchDB values. - -### `test-setup-put-cat.ts` - -- Verifies `setup` with full setup URI generation via `encodeSettingsToSetupURI`. -- Verifies `push`, `cat`, `ls`, `info`, `rm`, `resolve`, `cat-rev`, and `pull-rev`. -- Does not require an external remote. - -### `test-mirror.ts` - -- Verifies seven core mirror scenarios, including an unrecognised revision origin. -- Does not require an external remote. - -### `test-sync-two-local-databases.ts` - -- Verifies sync between two vaults and CouchDB. -- Verifies conflict detection and resolve propagation. -- Starts Docker CouchDB by default when `LIVESYNC_START_DOCKER != 0`. - -### `test-sync-locked-remote.ts` - -- Updates the CouchDB milestone `locked` flag. -- Verifies sync success when unlocked. -- Verifies actionable CLI error when locked. - -### `test-p2p-host.ts` - -- Verifies that `p2p-host` starts and emits readiness output. - -### `test-p2p-peers-local-relay.ts` - -- Verifies peer discovery through a local relay. - -### `test-p2p-sync.ts` - -- Verifies that `p2p-sync` completes after peer discovery. - -### `test-p2p-automatic-host.ts` - -- Verifies that `p2p-host` restores its configured P2P automation policy and stays connected while multiple clients synchronise through it. -- Covers automatic baseline transfer, watched updates, client disconnection, and host restart persistence without manual P2P sync commands. - -### `test-p2p-three-nodes-conflict.ts` - -- Uses one host and two clients. -- Verifies conflict creation, detection via `info`, and resolution via `resolve`. - -### `test-p2p-upload-download-repro.ts` - -- Uses host, upload, and download nodes. -- Verifies transfer of text files and binary files, including larger files. - -### `test-e2e-two-vaults-couchdb.ts` - -- Verifies two-vault end-to-end scenarios on CouchDB. -- Runs both encryption-off and encryption-on cases. -- Includes conflict marker checks in `ls` and resolve propagation checks. - -### `test-e2e-two-vaults-matrix.ts` - -- Verifies the matrix equivalent of the bash script. -- Runs four combinations: - - `COUCHDB-enc0` - - `COUCHDB-enc1` - - `MINIO-enc0` - - `MINIO-enc1` - -### `test-daemon.ts` - -- Verifies daemon-related ignore rules behaviour. -- Exercises scenarios with `.livesync/ignore` wildcard rules, missing ignore rules, and imported `.gitignore` rules. - -### `test-decoupled-vault.ts` - -- Verifies push, pull, and mirror command behaviour when the vault directory is decoupled from the database directory. - -### `test-remote-commands.ts` - -- Verifies remote database management commands: `remote-status`, `lock-remote`, `unlock-remote`, and `mark-resolved`. - ---- - -## Running tests (PowerShell) - -From the repository root, use the canonical package scripts. `test:e2e:cli` runs the same non-P2P task set selected by the default CLI CI workflow. P2P validation runs in Compose so peer discovery does not depend on host loopback, firewall, or WebRTC candidate behaviour. - -```powershell -npm run test:e2e:cli -npm run test:e2e:cli:p2p -npm run test:e2e:cli:all -``` - -From `src/apps/cli/testdeno`: - -```powershell -cd src/apps/cli/testdeno - -# Canonical suites -deno task test:ci -deno task test:p2p:compose - -# Local-only set -deno task test:local - -# Individual tests -deno task test:setup-put-cat -deno task test:mirror -deno task test:daemon -deno task test:push-pull -deno task test:sync-locked-remote - -# CouchDB and Object Storage tests -deno task test:sync-two-local -deno task test:decoupled-vault -deno task test:remote-commands -deno task test:e2e-couchdb -deno task test:e2e-matrix - -# Explicit host-direct P2P diagnostics for cross-platform investigations. -# These are not part of the default suite or release evidence. -deno task test:p2p-host -deno task test:p2p-peers -deno task test:p2p-sync -deno task test:p2p-three-nodes -deno task test:p2p-upload-download -``` - ---- - -## Environment variables - -### CouchDB - -- `COUCHDB_URI` -- `COUCHDB_USER` -- `COUCHDB_PASSWORD` -- `COUCHDB_DBNAME` - -Equivalent keys in `src/apps/cli/.test.env`: - -- `hostname` -- `username` -- `password` -- `dbname` - -### Behaviour switches - -- `LIVESYNC_START_DOCKER=0`: use existing CouchDB. -- `REMOTE_PATH`: override target path for selected tests. -- `LIVESYNC_TEST_TEE=1`: stream CLI stdout and stderr during execution. -- `LIVESYNC_DOCKER_TEE=1`: stream Docker stdout and stderr. -- `LIVESYNC_CLI_RETRY=`: retry transient network failures. -- `LIVESYNC_DEBUG_KEEP_DOCKER=1`: keep `couchdb-test` after test completion. - -### Docker command selection - -`helpers/docker.ts` supports command selection via environment variables. - -- `LIVESYNC_DOCKER_MODE=auto` (default) - - Windows: tries `wsl docker` first, then `docker`. - - Non-Windows: tries `docker` first, then `wsl docker`. -- `LIVESYNC_DOCKER_MODE=native`: always uses `docker`. -- `LIVESYNC_DOCKER_MODE=wsl`: always uses `wsl docker`. -- `LIVESYNC_DOCKER_COMMAND="..."`: custom command, for example `wsl docker`. - -`LIVESYNC_DOCKER_COMMAND` has priority over `LIVESYNC_DOCKER_MODE`. - -PowerShell examples: - -```powershell -# Use Docker in WSL explicitly -$env:LIVESYNC_DOCKER_MODE = "wsl" -deno task test:sync-two-local - -# Full custom command -$env:LIVESYNC_DOCKER_COMMAND = "wsl docker" -deno task test:sync-two-local -``` - -### P2P - -- `RELAY` -- `ROOM_ID` -- `PASSPHRASE` -- `APP_ID` -- `PEERS_TIMEOUT` -- `SYNC_TIMEOUT` -- `USE_INTERNAL_RELAY=0|1` -- `TIMEOUT_SECONDS` - ---- - -## Continuous Integration - -The GitHub Actions workflow `.github/workflows/cli-deno-tests.yml` runs automatically on pushes and pull requests affecting the CLI or its shared runtime inputs. It builds the CLI once, then distributes the bundle to the non-P2P test jobs. The local `test:ci` runner and the automatic matrix both read `ci-tasks.json`. During a manual dispatch, select a test subset and optionally enable 'Enable verbose and debug logging' for detailed troubleshooting traces. - -The separate `.github/workflows/cli-p2p-tests.yml` workflow runs the canonical Compose P2P suite automatically for changes to the CLI, its shared runtime inputs, or `test/bench-network/`. A change confined to the Compose fixtures does not start the non-P2P matrix. Host-direct `test:p2p` tasks remain available for explicit diagnostics. - -The `.github/workflows/cli-docker-validation.yml` workflow builds one production JavaScript bundle with Node.js 22, then tests Docker images in parallel on native amd64 and arm64 runners. Each image uses the shared bundle and prepares its own runtime dependencies. Validation runs without registry credentials, and both architecture results are collected before publication. The publishing workflow, `.github/workflows/cli-docker.yml`, imports the validated OCI image from each architecture, collects the image digests, and publishes the combined manifest. It retains the existing publication conditions for main, release tags, and manual dispatch. A dry run builds both images and checks the digest pair while skipping registry uploads and manifest publication. - ---- - -## Current limitations - -- S3 startup and matrix coverage use RustFS. Current limits are elsewhere, not setup URI generation. - ---- - -## Maintenance policy - -- Existing bash tests remain available. -- Deno tests are expanded in parallel for cross-platform usage. -- New scenarios should be added through reusable helpers in `helpers/`. +The CLI test development guide is now [test/e2e-cli/test_dev_node.md](../../../../test/e2e-cli/test_dev_node.md). diff --git a/src/apps/cli/tsconfig.json b/src/apps/cli/tsconfig.json index 0d9fbb57..9828ca59 100644 --- a/src/apps/cli/tsconfig.json +++ b/src/apps/cli/tsconfig.json @@ -27,5 +27,5 @@ } }, "include": ["*.ts", "**/*.ts", "**/*.tsx"], - "exclude": ["node_modules", "dist", "test", "testdeno"] + "exclude": ["node_modules", "dist", "test"] } diff --git a/src/apps/cli/vite.config.ts b/src/apps/cli/vite.config.ts index a3212a34..7496ee23 100644 --- a/src/apps/cli/vite.config.ts +++ b/src/apps/cli/vite.config.ts @@ -49,26 +49,6 @@ if (typeof globalThis.FileReader === "undefined") { } `; -function injectBanner(): import("vite").Plugin { - return { - name: "inject-banner", - generateBundle(_options, bundle) { - for (const chunk of Object.values(bundle)) { - if (chunk.type === "chunk" && chunk.isEntry) { - // Insert after the shebang line if present, otherwise at the top. - if (chunk.code.startsWith("#!")) { - const newline = chunk.code.indexOf("\n"); - chunk.code = - chunk.code.slice(0, newline + 1) + fileReaderPolyfillBanner + chunk.code.slice(newline + 1); - } else { - chunk.code = fileReaderPolyfillBanner + chunk.code; - } - } - } - }, - }; -} - const buildInputs: Record = { index: resolve(__dirname, "entrypoint.ts"), }; @@ -77,7 +57,7 @@ if (process.env.LIVESYNC_CLI_TEST_SUPPORT === "1") { } export default defineConfig({ - plugins: [svelte(), injectBanner()], + plugins: [svelte()], resolve: { // This bundle runs in Node. Vite's client defaults include the `browser` // export condition, which would select Commonlib's inline Web Worker. @@ -98,7 +78,16 @@ export default defineConfig({ outDir: "dist", emptyOutDir: true, minify: false, + sourcemap: process.env.LIVESYNC_CLI_COVERAGE === "1", rollupOptions: { + output: { + banner: (chunk) => (chunk.isEntry ? fileReaderPolyfillBanner : ""), + // c8 filters mapped ranges before resolving relative source paths. + sourcemapPathTransform: + process.env.LIVESYNC_CLI_COVERAGE === "1" + ? (source: string, mapPath: string) => path.resolve(path.dirname(mapPath), source) + : undefined, + }, input: buildInputs, external: (id) => { if (isBuiltin(id)) return true; diff --git a/src/features/ReviewHarness/reviewHarnessFileContentHashBenchmarkRuntime.ts b/src/features/ReviewHarness/reviewHarnessFileContentHashBenchmarkRuntime.ts index 8d6b8831..12a49dbc 100644 --- a/src/features/ReviewHarness/reviewHarnessFileContentHashBenchmarkRuntime.ts +++ b/src/features/ReviewHarness/reviewHarnessFileContentHashBenchmarkRuntime.ts @@ -5,16 +5,41 @@ import { type FileContentHashBenchmarkOperations, } from "./reviewHarnessFileContentHashBenchmark"; +type XxHash64Engine = { + h64Raw(content: Uint8Array): unknown; +}; + +function isXxHashFactory(value: unknown): value is () => unknown { + return typeof value === "function"; +} + +function isXxHash64Engine(value: unknown): value is XxHash64Engine { + return typeof value === "object" && value !== null && "h64Raw" in value && typeof value.h64Raw === "function"; +} + export async function createFileContentHashBenchmarkOperations( clock: Pick = performance ): Promise { + const factory: unknown = xxhashNew; + if (!isXxHashFactory(factory)) { + throw new TypeError("The xxHash64 factory must be callable."); + } const started = clock.now(); - const xxHash = await xxhashNew(); + const xxHash: unknown = await factory(); const xxHashInitialisationMs = clock.now() - started; + if (!isXxHash64Engine(xxHash)) { + throw new TypeError("The xxHash64 engine must provide h64Raw."); + } return { xxHashInitialisationMs, createInput: createFixedFileContentHashInput, sha256: (content) => computeFileContentHash(content), - xxHash64: async (content) => xxHash.h64Raw(new Uint8Array(await content.arrayBuffer())), + xxHash64: async (content) => { + const hash = xxHash.h64Raw(new Uint8Array(await content.arrayBuffer())); + if (typeof hash !== "bigint") { + throw new TypeError("The xxHash64 result must be a bigint."); + } + return hash; + }, }; } diff --git a/src/features/ReviewHarness/reviewHarnessFileContentHashBenchmarkRuntime.unit.spec.ts b/src/features/ReviewHarness/reviewHarnessFileContentHashBenchmarkRuntime.unit.spec.ts index a5d6db38..c8f60005 100644 --- a/src/features/ReviewHarness/reviewHarnessFileContentHashBenchmarkRuntime.unit.spec.ts +++ b/src/features/ReviewHarness/reviewHarnessFileContentHashBenchmarkRuntime.unit.spec.ts @@ -1,6 +1,11 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { xxhashNew } from "octagonal-wheels/hash/xxhash.js"; import { createFileContentHashBenchmarkOperations } from "./reviewHarnessFileContentHashBenchmarkRuntime"; +vi.mock("octagonal-wheels/hash/xxhash.js", { spy: true }); + +afterEach(() => vi.restoreAllMocks()); + describe("Review Harness file content hash benchmark adapter", () => { it("uses the packaged SHA-256 helper and xxHash64 engine with fixed Blob content", async () => { const operations = await createFileContentHashBenchmarkOperations(); @@ -11,5 +16,19 @@ describe("Review Harness file content hash benchmark adapter", () => { expect(typeof (await operations.xxHash64(content))).toBe("bigint"); expect(await operations.sha256(content)).toBe(await operations.sha256(content)); expect(await operations.xxHash64(content)).toBe(await operations.xxHash64(content)); + expect(xxhashNew).toHaveBeenCalledOnce(); + }); + + it("rejects an engine without an xxHash64 operation", async () => { + vi.mocked(xxhashNew).mockResolvedValueOnce({}); + + await expect(createFileContentHashBenchmarkOperations()).rejects.toBeInstanceOf(TypeError); + }); + + it("rejects a hash result which is not a bigint", async () => { + vi.mocked(xxhashNew).mockResolvedValueOnce({ h64Raw: () => 123 }); + const operations = await createFileContentHashBenchmarkOperations(); + + await expect(operations.xxHash64(new Blob(["fixed content"]))).rejects.toBeInstanceOf(TypeError); }); }); diff --git a/test/README.md b/test/README.md index 9387f5e4..83429d4b 100644 --- a/test/README.md +++ b/test/README.md @@ -32,7 +32,7 @@ NODE_OPTIONS=--max-old-space-size=3072 npm run test:unit -- --maxWorkers=1 | --- | --- | | Multiple-device file conflicts and stale-file protection | [CouchDB procedure below](#multiple-device-conflict-regression-tests) | | Obsidian startup, file watching, persistence, and UI | [Real Obsidian E2E](e2e-obsidian/README.md) | -| CLI subprocesses, filesystem workflows, and P2P | [CLI Deno tests](../src/apps/cli/testdeno/test_dev_deno.md) and [test authoring](../src/apps/cli/testdeno/CONTRIBUTING_TESTS.md) | +| CLI subprocesses, filesystem workflows, and P2P | [CLI Node.js tests](../test/e2e-cli/test_dev_node.md) and [test authoring](../test/e2e-cli/CONTRIBUTING_TESTS.md) | | WebApp, WebPeer, and browser interoperability | [Browser application tests](browser-apps/README.md) | ## Community Review checks and CI confirmation @@ -58,8 +58,8 @@ Run the native CLI subprocess suite after building the CLI: ```bash NODE_OPTIONS=--max-old-space-size=3072 npm run build --workspace self-hosted-livesync-cli -cd src/apps/cli/testdeno -deno test -A --no-check test-mirror.ts +cd test/e2e-cli +node --test --test-concurrency=1 test-mirror.ts ``` The expected result is seven passing steps. These cover storage-only and database-only files, database deletion, an ordinary local edit, incoming database content, an omitted Vault path, and local content with unknown provenance. @@ -75,7 +75,7 @@ NODE_OPTIONS=--max-old-space-size=3072 npm run build:docker --workspace self-hos npm run test:e2e:docker:mirror --workspace self-hosted-livesync-cli ``` -The expected result is `PASS=7 FAIL=0`. These mirror suites use temporary local databases and need no CouchDB service. The complete `test:e2e:docker:all` command also runs the other Docker CLI suites and manages a disposable CouchDB fixture; use it to check the full Docker CI gate. Keep the ordinary-edit and unknown-provenance scenarios aligned between the Deno and Bash suites. +The expected result is `PASS=7 FAIL=0`. These mirror suites use temporary local databases and need no CouchDB service. The complete `test:e2e:docker:all` command also runs the other Docker CLI suites and manages a disposable CouchDB fixture; use it to check the full Docker CI gate. Keep the ordinary-edit and unknown-provenance scenarios aligned between the Node.js and Bash suites. ## Multiple-device conflict regression tests diff --git a/test/bench-network/Dockerfile.runner b/test/bench-network/Dockerfile.runner index 180141dd..12dbb8f0 100644 --- a/test/bench-network/Dockerfile.runner +++ b/test/bench-network/Dockerfile.runner @@ -1,14 +1,11 @@ # syntax=docker/dockerfile:1 -FROM node:24-slim +FROM node:26-slim RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates curl unzip python3 make g++ iproute2 libjpeg-turbo-progs time \ && rm -rf /var/lib/apt/lists/* -ENV DENO_INSTALL=/usr/local -RUN curl -fsSL https://deno.land/install.sh | sh - WORKDIR /workspace COPY package.json package-lock.json ./ @@ -20,22 +17,10 @@ RUN npm ci COPY . . RUN NODE_OPTIONS=--max-old-space-size=3072 LIVESYNC_CLI_TEST_SUPPORT=1 npm run build -w self-hosted-livesync-cli -WORKDIR /workspace/src/apps/cli/testdeno - -RUN deno cache --lock=deno.lock \ - bench-network-cases.ts \ - bench-latency-sweep.ts \ - bench-p2p-split-node.ts \ - bench-p2p.ts \ - bench-couchdb.ts \ - bench-compression.ts \ - test-p2p-sync.ts \ - test-p2p-automatic-host.ts \ - test-p2p-replicator-replacement.ts \ - test-p2p-relay-disconnect.ts +WORKDIR /workspace/test/e2e-cli COPY test/bench-network/run-bench.sh /usr/local/bin/run-livesync-bench -COPY src/apps/cli/testdeno/run-cli-e2e.sh /usr/local/bin/run-livesync-cli-e2e +COPY test/e2e-cli/run-cli-e2e.sh /usr/local/bin/run-livesync-cli-e2e RUN chmod +x /usr/local/bin/run-livesync-bench /usr/local/bin/run-livesync-cli-e2e CMD ["run-livesync-bench"] diff --git a/test/bench-network/compose.yml b/test/bench-network/compose.yml index 2c2b2407..a4211c62 100644 --- a/test/bench-network/compose.yml +++ b/test/bench-network/compose.yml @@ -74,10 +74,10 @@ services: BENCH_COMMAND: ${BENCH_COMMAND:-cases} BENCH_CASES: ${BENCH_CASES:-couchdb-baseline,p2p-direct-local} BENCH_REPEAT_COUNT: ${BENCH_REPEAT_COUNT:-1} - BENCH_CASES_ROOT: /workspace/src/apps/cli/testdeno/bench-results - BENCH_COMPRESSION_RESULT_ROOT: /workspace/src/apps/cli/testdeno/bench-results + BENCH_CASES_ROOT: /workspace/test/e2e-cli/bench-results + BENCH_COMPRESSION_RESULT_ROOT: /workspace/test/e2e-cli/bench-results BENCH_COMPRESSION_REPEAT_COUNT: ${BENCH_COMPRESSION_REPEAT_COUNT:-1} - BENCH_SWEEP_ROOT: /workspace/src/apps/cli/testdeno/bench-results + BENCH_SWEEP_ROOT: /workspace/test/e2e-cli/bench-results BENCH_SWEEP_RTT_MS: ${BENCH_SWEEP_RTT_MS:-20,50,100,150,300} BENCH_SWEEP_INCLUDE_P2P: ${BENCH_SWEEP_INCLUDE_P2P:-true} BENCH_COUCHDB_MANAGED: "false" @@ -110,7 +110,7 @@ services: TURN_SERVERS: ${TURN_SERVERS:-none} LIVESYNC_P2P_PEERS_RETRY: ${LIVESYNC_P2P_PEERS_RETRY:-1} volumes: - - ./bench-results:/workspace/src/apps/cli/testdeno/bench-results + - ./bench-results:/workspace/test/e2e-cli/bench-results couchdb-shim: build: @@ -156,8 +156,8 @@ services: BENCH_COMMAND: ${BENCH_COMMAND:-cases} BENCH_CASES: ${BENCH_CASES:-couchdb-netem-home-wifi} BENCH_REPEAT_COUNT: ${BENCH_REPEAT_COUNT:-1} - BENCH_CASES_ROOT: /workspace/src/apps/cli/testdeno/bench-results - BENCH_SWEEP_ROOT: /workspace/src/apps/cli/testdeno/bench-results + BENCH_CASES_ROOT: /workspace/test/e2e-cli/bench-results + BENCH_SWEEP_ROOT: /workspace/test/e2e-cli/bench-results BENCH_COUCHDB_MANAGED: "false" BENCH_COUCHDB_BACKEND_URI: http://couchdb-shim:5984 BENCH_SHIM_COUCHDB_URI: http://couchdb-shim:5984 @@ -173,7 +173,7 @@ services: BENCH_SYNC_TIMEOUT: ${BENCH_SYNC_TIMEOUT:-300} BENCH_LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0} volumes: - - ./bench-results:/workspace/src/apps/cli/testdeno/bench-results + - ./bench-results:/workspace/test/e2e-cli/bench-results p2p-signalling-shim: build: @@ -219,7 +219,7 @@ services: BENCH_COMMAND: ${BENCH_COMMAND:-cases} BENCH_CASES: ${BENCH_CASES:-p2p-signalling-netem-home-wifi} BENCH_REPEAT_COUNT: ${BENCH_REPEAT_COUNT:-1} - BENCH_CASES_ROOT: /workspace/src/apps/cli/testdeno/bench-results + BENCH_CASES_ROOT: /workspace/test/e2e-cli/bench-results BENCH_SIGNAL_SHIM_RELAY: ws://p2p-signalling-shim:7777/ BENCH_MD_FILE_COUNT: ${BENCH_MD_FILE_COUNT:-20} BENCH_MD_MIN_SIZE_BYTES: ${BENCH_MD_MIN_SIZE_BYTES:-512} @@ -232,7 +232,7 @@ services: LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: ${LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS:-60000} BENCH_LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0} volumes: - - ./bench-results:/workspace/src/apps/cli/testdeno/bench-results + - ./bench-results:/workspace/test/e2e-cli/bench-results p2p-split-host: build: @@ -250,7 +250,7 @@ services: BENCH_P2P_SPLIT_ROLE: host BENCH_SPLIT_RUN_ID: ${BENCH_SPLIT_RUN_ID:-bench-split-run} BENCH_SPLIT_WORK_ROOT: /p2p-work - BENCH_SPLIT_RESULT_ROOT: /workspace/src/apps/cli/testdeno/bench-results + BENCH_SPLIT_RESULT_ROOT: /workspace/test/e2e-cli/bench-results BENCH_RELAY: ws://nostr-relay:7777/ BENCH_APP_ID: ${BENCH_APP_ID:-self-hosted-livesync-cli-benchmark} BENCH_ROOM_ID: ${BENCH_ROOM_ID:-bench-split-room} @@ -276,7 +276,7 @@ services: LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0} volumes: - p2p-split-work:/p2p-work - - ./bench-results:/workspace/src/apps/cli/testdeno/bench-results + - ./bench-results:/workspace/test/e2e-cli/bench-results p2p-split-client: build: @@ -296,7 +296,7 @@ services: BENCH_P2P_SPLIT_ROLE: client BENCH_SPLIT_RUN_ID: ${BENCH_SPLIT_RUN_ID:-bench-split-run} BENCH_SPLIT_WORK_ROOT: /p2p-work - BENCH_SPLIT_RESULT_ROOT: /workspace/src/apps/cli/testdeno/bench-results + BENCH_SPLIT_RESULT_ROOT: /workspace/test/e2e-cli/bench-results BENCH_RELAY: ws://nostr-relay:7777/ BENCH_APP_ID: ${BENCH_APP_ID:-self-hosted-livesync-cli-benchmark} BENCH_ROOM_ID: ${BENCH_ROOM_ID:-bench-split-room} @@ -317,7 +317,7 @@ services: LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0} volumes: - p2p-split-work:/p2p-work - - ./bench-results:/workspace/src/apps/cli/testdeno/bench-results + - ./bench-results:/workspace/test/e2e-cli/bench-results netem-smoke: build: diff --git a/test/bench-network/run-bench.sh b/test/bench-network/run-bench.sh index 5cb158f0..8727aece 100644 --- a/test/bench-network/run-bench.sh +++ b/test/bench-network/run-bench.sh @@ -3,16 +3,16 @@ set -eu case "${BENCH_COMMAND:-cases}" in cases) - exec deno task bench:cases + exec node run-task.ts bench:cases ;; latency-sweep) - exec deno task bench:latency-sweep + exec node run-task.ts bench:latency-sweep ;; compression) - exec deno task bench:compression + exec node run-task.ts bench:compression ;; p2p-split-node) - exec deno task bench:p2p-split-node + exec node run-task.ts bench:p2p-split-node ;; *) echo "Unknown BENCH_COMMAND: ${BENCH_COMMAND}" >&2 diff --git a/src/apps/cli/testdeno/.test.env b/test/e2e-cli/.test.env similarity index 100% rename from src/apps/cli/testdeno/.test.env rename to test/e2e-cli/.test.env diff --git a/test/e2e-cli/CONTRIBUTING_TESTS.md b/test/e2e-cli/CONTRIBUTING_TESTS.md new file mode 100644 index 00000000..4219b281 --- /dev/null +++ b/test/e2e-cli/CONTRIBUTING_TESTS.md @@ -0,0 +1,70 @@ +# Writing CLI Tests with Node.js + +Add CLI end-to-end tests under `test/e2e-cli/`. This suite uses Node.js 26 native TypeScript support and the built-in `node:test` runner so that local filesystem and subprocess scenarios can run on Windows, macOS, and Linux. + +## Principles + +- Keep one scenario per file when practical. +- Reuse helpers from `helpers/` rather than duplicating process, Docker, or settings logic. +- Prefer deterministic data over random inputs unless randomness is explicitly required. +- Ensure every test can clean up automatically. +- Keep assertions actionable with clear failure messages. +- Keep test concurrency at one. Tests share the built CLI and may start local services. +- Use TypeScript syntax which Node can strip without compilation; `test/e2e-cli/tsconfig.json` checks this boundary. + +## Directory structure + +```text +test/e2e-cli/ + helpers/ + backgroundCli.ts + cli.ts + docker.ts + env.ts + p2p.ts + settings.ts + temp.ts + test-*.ts + run-task.ts + tasks.json + ci-tasks.json +``` + +## Test file naming and assertions + +Use `test-.ts`, aligned with existing CLI test names where practical. Import the built-in test and assertion APIs: `test` and `describe` from `node:test`, and assertions from `node:assert/strict`. For example: + +```ts +import assert from "node:assert/strict"; +import { test } from "node:test"; + +test("feature: behaviour", async (t) => { + await t.test("preserves the expected result", () => { + assert.equal(actualValue, expectedValue); + }); +}); +``` + +## Core helper usage + +- `runCli(...)` and `runCliOrFail(...)` execute the CLI and return or validate its result. +- `runCliWithInputOrFail(input, ...)` supports `put` and other stdin-driven commands. +- `TempDir` and `await using` provide automatic temporary-directory cleanup. +- Settings helpers create baseline files and apply CouchDB or P2P configuration. +- Docker helpers start, initialise, and stop local CouchDB, RustFS, and relay services when a test needs them. +- `startCliInBackground(...)` manages long-running host commands such as `p2p-host`. + +Prefer `try`/`finally` when a test needs explicit cleanup in addition to registered test hooks. For network-sensitive commands, use `LIVESYNC_CLI_RETRY` during debugging. Keep Docker container reuse disabled by default unless debugging. + +## Task registration + +Add each supported task name to `tasks.json`. A task record uses one of these forms: + +- `{ "test": ["test-file.ts"] }`, optionally with a `filter` regular expression for named subtests. +- `{ "script": "script.ts" }` for a TypeScript script. +- `{ "tasks": ["task-name"] }` for an ordered task group. +- `{ "command": "...", "args": [...] }` only for the retained legacy benchmark shell scripts. + +Add a task name to `ci-tasks.json` only when it belongs in the standard service-backed CI suite. That file is authoritative for both `run-ci-suite.ts` and the automatic CI matrix. Keep the local `test:local` group service-free so it remains suitable for Windows, macOS, and Linux smoke runs. + +Use `npm run test:e2e:task --workspace self-hosted-livesync-cli -- ` from the repository root to run one task after building the CLI. The standard suite and canonical P2P suite use `npm run test:e2e:cli` and `npm run test:e2e:cli:p2p`, respectively. P2P network scenarios use Compose because host networking and WebRTC candidate selection are not reproducible across environments; individual host-direct P2P tasks are for explicit diagnostics. diff --git a/src/apps/cli/testdeno/bench-compression.ts b/test/e2e-cli/bench-compression.ts similarity index 96% rename from src/apps/cli/testdeno/bench-compression.ts rename to test/e2e-cli/bench-compression.ts index dafef1d5..2db4ba00 100644 --- a/src/apps/cli/testdeno/bench-compression.ts +++ b/test/e2e-cli/bench-compression.ts @@ -1,4 +1,6 @@ -import { join } from "@std/path"; +import { REPOSITORY_ROOT } from "./helpers/paths.ts"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; import { TempDir } from "./helpers/temp.ts"; import { applyRemoteSyncSettings, initSettingsFile } from "./helpers/settings.ts"; import { assertFilesEqual } from "./helpers/cli.ts"; @@ -82,12 +84,12 @@ const DATASET_KINDS: DatasetKind[] = ["md", "jpg", "png", "json", "ts", "gz", "b const COMPRESSED_MARKER = "\u000eLZ\u001d"; function readEnvString(name: string, fallback: string): string { - const value = Deno.env.get(name)?.trim(); + const value = process.env[name]?.trim(); return value ? value : fallback; } function readEnvPositiveInteger(name: string, fallback: number): number { - const raw = Deno.env.get(name)?.trim(); + const raw = process.env[name]?.trim(); if (!raw) return fallback; const value = Number(raw); if (!Number.isInteger(value) || value <= 0) { @@ -97,7 +99,7 @@ function readEnvPositiveInteger(name: string, fallback: number): number { } function readEnvPositiveNumber(name: string, fallback: number): number { - const raw = Deno.env.get(name)?.trim(); + const raw = process.env[name]?.trim(); if (!raw) return fallback; const value = Number(raw); if (!Number.isFinite(value) || value <= 0) { @@ -107,7 +109,7 @@ function readEnvPositiveNumber(name: string, fallback: number): number { } function readEnvBoolean(name: string, fallback: boolean): boolean { - const raw = Deno.env.get(name)?.trim(); + const raw = process.env[name]?.trim(); if (!raw) return fallback; return /^(1|true|yes|on)$/i.test(raw); } @@ -429,14 +431,14 @@ async function main(): Promise { const passphrase = readEnvString("BENCH_PASSPHRASE", "compression-benchmark-passphrase"); const resultRoot = readEnvString("BENCH_COMPRESSION_RESULT_ROOT", "bench-results"); const resultPath = - Deno.env.get("BENCH_COMPRESSION_RESULT_JSON")?.trim() || + process.env.BENCH_COMPRESSION_RESULT_JSON?.trim() || join(resultRoot, `compression-${new Date().toISOString().replaceAll(":", "-")}.json`); const createdDatabases = new Set(); const results: RunResult[] = []; let managedStarted = false; - await Deno.mkdir(resultRoot, { recursive: true }); - const proxy = startCouchdbProxy({ backendUri, proxyUri, requestedRttMs }); + await mkdir(resultRoot, { recursive: true }); + const proxy = await startCouchdbProxy({ backendUri, proxyUri, requestedRttMs }); try { for (let repeatIndex = 1; repeatIndex <= repeatCount; repeatIndex++) { @@ -457,8 +459,8 @@ async function main(): Promise { const vaultB = workDir.join("vault-b"); const settingsA = workDir.join("settings-a.json"); const settingsB = workDir.join("settings-b.json"); - await Deno.mkdir(vaultA, { recursive: true }); - await Deno.mkdir(vaultB, { recursive: true }); + await mkdir(vaultA, { recursive: true }); + await mkdir(vaultB, { recursive: true }); await initSettingsFile(settingsA); await initSettingsFile(settingsB); await Promise.all( @@ -502,7 +504,7 @@ async function main(): Promise { databaseName, dataset, }); - const settings = JSON.parse(await Deno.readTextFile(settingsA)) as Record; + const settings = JSON.parse(await readFile(settingsA, "utf8")) as Record; const effectiveSettings = Object.fromEntries( [ "encrypt", @@ -553,8 +555,9 @@ async function main(): Promise { generatedAt: new Date().toISOString(), commonlibVersion: ( JSON.parse( - await Deno.readTextFile( - join(import.meta.dirname!, "../../../../node_modules/@vrtmrz/livesync-commonlib/package.json") + await readFile( + join(REPOSITORY_ROOT, "node_modules/@vrtmrz/livesync-commonlib/package.json"), + "utf8" ) ) as { version: string } ).version, @@ -581,7 +584,7 @@ async function main(): Promise { summary: summariseResults(results), runs: results, }; - await Deno.writeTextFile(resultPath, JSON.stringify(output, null, 2)); + await writeFile(resultPath, JSON.stringify(output, null, 2)); console.log(JSON.stringify(output, null, 2)); console.error(`[Compression benchmark] wrote ${resultPath}`); } finally { @@ -598,6 +601,6 @@ async function main(): Promise { if (import.meta.main) { main().catch((error) => { console.error("[Compression benchmark fatal]", error); - Deno.exit(1); + process.exitCode = 1; }); } diff --git a/src/apps/cli/testdeno/bench-couchdb.ts b/test/e2e-cli/bench-couchdb.ts similarity index 83% rename from src/apps/cli/testdeno/bench-couchdb.ts rename to test/e2e-cli/bench-couchdb.ts index 1bfe616c..b0d0fe2c 100644 --- a/src/apps/cli/testdeno/bench-couchdb.ts +++ b/test/e2e-cli/bench-couchdb.ts @@ -1,3 +1,5 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; import { TempDir } from "./helpers/temp.ts"; import { applyRemoteSyncSettings, @@ -48,12 +50,12 @@ type BenchmarkConfig = { }; function readEnvString(name: string, fallback: string): string { - const value = Deno.env.get(name)?.trim(); + const value = process.env[name]?.trim(); return value && value.length > 0 ? value : fallback; } function readEnvNumber(name: string, fallback: number): number { - const raw = Deno.env.get(name); + const raw = process.env[name]; if (raw === undefined || raw.trim() === "") { return fallback; } @@ -66,7 +68,7 @@ function readEnvNumber(name: string, fallback: number): number { } function readEnvBool(name: string, fallback: boolean): boolean { - const raw = Deno.env.get(name); + const raw = process.env[name]; if (raw === undefined || raw.trim() === "") { return fallback; } @@ -74,7 +76,7 @@ function readEnvBool(name: string, fallback: boolean): boolean { } function readEnvStringArray(name: string, fallback: string[]): string[] { - const raw = Deno.env.get(name)?.trim(); + const raw = process.env[name]?.trim(); if (!raw) { return fallback; } @@ -171,7 +173,7 @@ function buildConfig(): BenchmarkConfig { "This benchmark result is scoped to the configured dataset, remote store, and network model.", ]), verificationMode: parseBenchmarkVerificationMode( - Deno.env.get("BENCH_VERIFY_MODE"), + process.env.BENCH_VERIFY_MODE, ), repeatIndex: Math.floor(readEnvNumber("BENCH_REPEAT_INDEX", 1)), repeatCount: Math.floor(readEnvNumber("BENCH_REPEAT_COUNT", 1)), @@ -179,7 +181,7 @@ function buildConfig(): BenchmarkConfig { } function readOptionalResultPath(): string | undefined { - const raw = Deno.env.get("BENCH_RESULT_JSON")?.trim(); + const raw = process.env.BENCH_RESULT_JSON?.trim(); if (!raw) { return undefined; } @@ -201,52 +203,51 @@ export type CouchdbProxyCounters = { responseBodyBytes: number; }; -export function startCouchdbProxy( +export async function startCouchdbProxy( options: { backendUri: string; proxyUri: string; requestedRttMs: number; delay?: (milliseconds: number) => Promise; }, -): CouchdbProxyHandle { +): Promise { const backend = new URL(options.backendUri); const proxy = new URL(options.proxyUri); const halfDelayMs = options.requestedRttMs / 2; const delay = options.delay ?? ((milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds))); - const controller = new AbortController(); const counters: CouchdbProxyCounters = { requestCount: 0, requestBodyBytes: 0, responseBodyBytes: 0, }; - const listener = Deno.serve( - { - hostname: proxy.hostname, - port: Number(proxy.port), - signal: controller.signal, - onError(error) { - console.error(`[Proxy] ${String(error)}`); - return new Response("proxy error", { status: 502 }); - }, - }, - async (request) => { + const server = createServer(async (request, response) => { + try { await delay(halfDelayMs); - const targetUrl = new URL(request.url); + const targetUrl = new URL(request.url ?? "/", proxy); targetUrl.protocol = backend.protocol; targetUrl.host = backend.host; - const headers = new Headers(request.headers); - headers.delete("host"); - headers.delete("content-length"); - - let requestBody: ArrayBuffer | undefined; + const headers = new Headers(); + for (const [name, value] of Object.entries(request.headers)) { + if (value === undefined || name === "host" || name === "content-length") continue; + if (Array.isArray(value)) { + for (const item of value) headers.append(name, item); + } else { + headers.append(name, value); + } + } + let requestBody: Buffer | undefined; if (request.method !== "GET" && request.method !== "HEAD") { try { - requestBody = await request.arrayBuffer(); + const chunks: Buffer[] = []; + for await (const chunk of request as AsyncIterable) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + requestBody = Buffer.concat(chunks); } catch { requestBody = undefined; } @@ -257,7 +258,7 @@ export function startCouchdbProxy( const upstream = await fetch(targetUrl, { method: request.method, headers, - body: requestBody, + body: requestBody === undefined ? undefined : Uint8Array.from(requestBody), redirect: "manual", }); @@ -267,13 +268,33 @@ export function startCouchdbProxy( counters.responseBodyBytes += responseBody.byteLength; await delay(halfDelayMs); - return new Response(responseBody, { - status: upstream.status, - statusText: upstream.statusText, - headers: responseHeaders, - }); - }, - ); + response.statusCode = upstream.status; + response.statusMessage = upstream.statusText; + responseHeaders.forEach((value, name) => response.setHeader(name, value)); + response.end(Buffer.from(responseBody)); + } catch (error) { + console.error(`[Proxy] ${String(error)}`); + if (!response.headersSent) { + response.statusCode = 502; + response.end("proxy error"); + } else { + response.destroy(); + } + } + }); + await new Promise((resolve, reject) => { + const onListening = () => { + server.off("error", onError); + resolve(); + }; + const onError = (error: Error) => { + server.off("listening", onListening); + reject(error); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(Number(proxy.port), proxy.hostname); + }); return { applied: true, @@ -287,8 +308,9 @@ export function startCouchdbProxy( }, snapshotCounters: () => ({ ...counters }), stop: async () => { - controller.abort(); - await listener.finished.catch(() => {}); + await new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + }); }, }; } @@ -302,8 +324,8 @@ async function main(): Promise { const vaultB = workDir.join("vault-b"); const settingsA = workDir.join("settings-a.json"); const settingsB = workDir.join("settings-b.json"); - await Deno.mkdir(vaultA, { recursive: true }); - await Deno.mkdir(vaultB, { recursive: true }); + await mkdir(vaultA, { recursive: true }); + await mkdir(vaultB, { recursive: true }); await initSettingsFile(settingsA); await initSettingsFile(settingsB); @@ -327,7 +349,7 @@ async function main(): Promise { ); } - const proxy = startCouchdbProxy({ + const proxy = await startCouchdbProxy({ backendUri: config.couchdbBackendUri, proxyUri: config.couchdbProxyUri, requestedRttMs: config.requestedRttMs, @@ -448,7 +470,7 @@ async function main(): Promise { }; if (resultPath) { - await Deno.writeTextFile( + await writeFile( resultPath, JSON.stringify(result, null, 2), ); @@ -477,6 +499,6 @@ async function main(): Promise { if (import.meta.main) { main().catch((error) => { console.error(`[Fatal Error]`, error); - Deno.exit(1); + process.exitCode = 1; }); } diff --git a/src/apps/cli/testdeno/bench-latency-sweep.ts b/test/e2e-cli/bench-latency-sweep.ts similarity index 85% rename from src/apps/cli/testdeno/bench-latency-sweep.ts rename to test/e2e-cli/bench-latency-sweep.ts index 3624b1d5..93489ada 100644 --- a/src/apps/cli/testdeno/bench-latency-sweep.ts +++ b/test/e2e-cli/bench-latency-sweep.ts @@ -1,3 +1,8 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { runProcess } from "./helpers/process.ts"; + type SweepResult = { name: string; runner: "p2p" | "couchdb"; @@ -8,7 +13,7 @@ type SweepResult = { }; function readEnvString(name: string, fallback: string): string { - const value = Deno.env.get(name)?.trim(); + const value = process.env[name]?.trim(); return value && value.length > 0 ? value : fallback; } @@ -68,9 +73,8 @@ async function runBenchmark(options: { const suffix = options.repeatCount > 1 ? `-r${String(options.repeatIndex).padStart(2, "0")}` : ""; - const resultPath = `${options.outputDir}/${options.name}${suffix}.json`; + const resultPath = join(options.outputDir, `${options.name}${suffix}.json`); const env = { - ...Deno.env.toObject(), ...options.env, BENCH_RESULT_JSON: resultPath, BENCH_REPEAT_INDEX: String(options.repeatIndex), @@ -78,29 +82,28 @@ async function runBenchmark(options: { }; console.log(`[latency-sweep] running ${options.name}`); - const child = new Deno.Command("deno", { - args: ["task", options.taskName], + const child = await runProcess(process.execPath, { + args: [fileURLToPath(new URL("./run-task.ts", import.meta.url)), options.taskName], cwd: import.meta.dirname, env, stdin: "null", stdout: "inherit", stderr: "inherit", - }).spawn(); - const status = await child.status; - if (status.code !== 0) { - throw new Error(`benchmark failed: ${options.name} (exit ${status.code})`); + }); + if (!child.success) { + throw new Error(`benchmark failed: ${options.name} (exit ${child.code})`); } - return JSON.parse(await Deno.readTextFile(resultPath)) as Record; + return JSON.parse(await readFile(resultPath, "utf8")) as Record; } async function main(): Promise { - const outRoot = readEnvString("BENCH_SWEEP_ROOT", `${import.meta.dirname}/bench-results`); - const outputDir = `${outRoot}/latency-sweep-${timestamp()}`; + const outRoot = readEnvString("BENCH_SWEEP_ROOT", join(import.meta.dirname, "bench-results")); + const outputDir = join(outRoot, `latency-sweep-${timestamp()}`); const rtts = parseRttList(readEnvString("BENCH_SWEEP_RTT_MS", "20,50,100,150,300")); const repeatCount = readEnvInteger("BENCH_REPEAT_COUNT", 1); const base = buildBaseEnv(); - await Deno.mkdir(outputDir, { recursive: true }); + await mkdir(outputDir, { recursive: true }); const results: SweepResult[] = []; if (readEnvString("BENCH_SWEEP_INCLUDE_P2P", "true") !== "false") { @@ -162,7 +165,7 @@ async function main(): Promise { repeatCount, results, }; - await Deno.writeTextFile(`${outputDir}/summary.json`, JSON.stringify(summary, null, 2)); + await writeFile(join(outputDir, "summary.json"), JSON.stringify(summary, null, 2)); console.log(JSON.stringify(summary, null, 2)); console.log(`[latency-sweep] result directory: ${outputDir}`); } @@ -170,6 +173,6 @@ async function main(): Promise { if (import.meta.main) { main().catch((error) => { console.error("[Fatal Error]", error); - Deno.exit(1); + process.exitCode = 1; }); } diff --git a/src/apps/cli/testdeno/bench-network-cases.ts b/test/e2e-cli/bench-network-cases.ts similarity index 94% rename from src/apps/cli/testdeno/bench-network-cases.ts rename to test/e2e-cli/bench-network-cases.ts index 5795800b..0bad3bf2 100644 --- a/src/apps/cli/testdeno/bench-network-cases.ts +++ b/test/e2e-cli/bench-network-cases.ts @@ -1,3 +1,8 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { runProcess } from "./helpers/process.ts"; + export type BenchmarkCase = { name: string; runner: "p2p" | "couchdb"; @@ -10,7 +15,7 @@ export type BenchmarkCase = { }; function readEnvString(name: string, fallback: string): string { - const value = Deno.env.get(name)?.trim(); + const value = process.env[name]?.trim(); return value && value.length > 0 ? value : fallback; } @@ -324,10 +329,9 @@ async function runCase( const suffix = repeatCount > 1 ? `-r${String(repeatIndex).padStart(2, "0")}` : ""; - const resultPath = `${outputDir}/${testCase.name}${suffix}.json`; + const resultPath = join(outputDir, `${testCase.name}${suffix}.json`); const taskName = testCase.runner === "p2p" ? "bench:p2p" : "bench:couchdb"; const env = { - ...Deno.env.toObject(), ...testCase.env, BENCH_RESULT_JSON: resultPath, BENCH_REPEAT_INDEX: String(repeatIndex), @@ -340,8 +344,8 @@ async function runCase( console.log( `[bench-cases] running ${testCase.name}${repeatLabel}: ${testCase.description}`, ); - const command = new Deno.Command("deno", { - args: ["task", taskName], + const child = await runProcess(process.execPath, { + args: [fileURLToPath(new URL("./run-task.ts", import.meta.url)), taskName], cwd: import.meta.dirname, env, stdin: "null", @@ -349,13 +353,11 @@ async function runCase( stderr: "inherit", }); - const child = command.spawn(); - const status = await child.status; - if (status.code !== 0) { - throw new Error(`case failed: ${testCase.name} (exit ${status.code})`); + if (!child.success) { + throw new Error(`case failed: ${testCase.name} (exit ${child.code})`); } - const result = JSON.parse(await Deno.readTextFile(resultPath)) as Record< + const result = JSON.parse(await readFile(resultPath, "utf8")) as Record< string, unknown >; @@ -394,16 +396,16 @@ function selectCases(allCases: BenchmarkCase[]): BenchmarkCase[] { async function main(): Promise { const outRoot = readEnvString( "BENCH_CASES_ROOT", - `${import.meta.dirname}/bench-results`, + join(import.meta.dirname, "bench-results"), ); - const outputDir = `${outRoot}/cases-${timestamp()}`; - await Deno.mkdir(outputDir, { recursive: true }); + const outputDir = join(outRoot, `cases-${timestamp()}`); + await mkdir(outputDir, { recursive: true }); const allCases = buildCases(); const cases = selectCases(allCases); const repeatCount = readEnvInteger("BENCH_REPEAT_COUNT", 1); - await Deno.writeTextFile( - `${outputDir}/case-manifest.json`, + await writeFile( + join(outputDir, "case-manifest.json"), JSON.stringify( { generatedAt: new Date().toISOString(), @@ -431,8 +433,8 @@ async function main(): Promise { repeatCount, results, }; - await Deno.writeTextFile( - `${outputDir}/summary.json`, + await writeFile( + join(outputDir, "summary.json"), JSON.stringify(summary, null, 2), ); console.log(JSON.stringify(summary, null, 2)); @@ -442,6 +444,6 @@ async function main(): Promise { if (import.meta.main) { main().catch((error) => { console.error("[Fatal Error]", error); - Deno.exit(1); + process.exitCode = 1; }); } diff --git a/src/apps/cli/testdeno/bench-p2p-split-node.ts b/test/e2e-cli/bench-p2p-split-node.ts similarity index 88% rename from src/apps/cli/testdeno/bench-p2p-split-node.ts rename to test/e2e-cli/bench-p2p-split-node.ts index 14bc9456..928a72bc 100644 --- a/src/apps/cli/testdeno/bench-p2p-split-node.ts +++ b/test/e2e-cli/bench-p2p-split-node.ts @@ -1,9 +1,11 @@ -import { join } from "@std/path"; +import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { join } from "node:path"; import { startCliInBackground } from "./helpers/backgroundCli.ts"; import { assertFilesEqual, runCliOrFail } from "./helpers/cli.ts"; import { createDeterministicDataset, type DatasetEntry } from "./helpers/dataset.ts"; import { discoverPeer } from "./helpers/p2p.ts"; import { applyP2pSettings, applyP2pTestTweaks, initSettingsFile } from "./helpers/settings.ts"; +import { runProcess } from "./helpers/process.ts"; type Role = "host" | "client"; @@ -53,12 +55,12 @@ function errorToRecord(error: unknown): Record { } function readEnvString(name: string, fallback: string): string { - const value = Deno.env.get(name)?.trim(); + const value = process.env[name]?.trim(); return value && value.length > 0 ? value : fallback; } function readEnvNumber(name: string, fallback: number): number { - const raw = Deno.env.get(name); + const raw = process.env[name]; if (raw === undefined || raw.trim() === "") { return fallback; } @@ -74,12 +76,12 @@ function nowMs(): number { } async function commandOutput(command: string, args: string[]): Promise { - const output = await new Deno.Command(command, { + const output = await runProcess(command, { args, stdin: "null", stdout: "piped", stderr: "piped", - }).output(); + }); const stdout = new TextDecoder().decode(output.stdout); const stderr = new TextDecoder().decode(output.stderr); if (!output.success) { @@ -118,7 +120,7 @@ async function applyNetemIfRequested(): Promise { } await commandOk("ip", ["link", "set", "dev", iface, "mtu", String(mtu)]); - await new Deno.Command("tc", { args: ["qdisc", "del", "dev", iface, "root"] }).output(); + await runProcess("tc", { args: ["qdisc", "del", "dev", iface, "root"] }); await commandOk("tc", [ "qdisc", "add", @@ -144,8 +146,8 @@ async function waitForFile(path: string, timeoutMs: number): Promise { const started = Date.now(); while (Date.now() - started < timeoutMs) { try { - const stat = await Deno.stat(path); - if (stat.isFile) { + const fileStat = await stat(path); + if (fileStat.isFile()) { return; } } catch { @@ -157,7 +159,7 @@ async function waitForFile(path: string, timeoutMs: number): Promise { } async function readJsonFile(path: string): Promise { - return JSON.parse(await Deno.readTextFile(path)) as T; + return JSON.parse(await readFile(path, "utf8")) as T; } function pickSampleFiles(entries: DatasetEntry[]): DatasetEntry[] { @@ -172,7 +174,7 @@ function pickSampleFiles(entries: DatasetEntry[]): DatasetEntry[] { async function readLatestP2PConnectionStats(path: string): Promise { try { - const lines = (await Deno.readTextFile(path)) + const lines = (await readFile(path, "utf8")) .split(/\r?\n/) .map((line) => line.trim()) .filter((line) => line.length > 0); @@ -188,7 +190,7 @@ function buildCommonConfig() { return { runId, workRoot: join(baseWorkRoot, runId), - resultRoot: readEnvString("BENCH_SPLIT_RESULT_ROOT", "/workspace/src/apps/cli/testdeno/bench-results"), + resultRoot: readEnvString("BENCH_SPLIT_RESULT_ROOT", "/workspace/test/e2e-cli/bench-results"), relay: readEnvString("BENCH_RELAY", "ws://nostr-relay:7777/"), appId: readEnvString("BENCH_APP_ID", "self-hosted-livesync-cli-benchmark"), roomId: readEnvString("BENCH_ROOM_ID", "bench-split-room"), @@ -229,12 +231,12 @@ async function prepareP2PSettings( async function runHost(): Promise { const config = buildCommonConfig(); const netem = await applyNetemIfRequested(); - await Deno.mkdir(config.workRoot, { recursive: true }); - await Deno.mkdir(config.resultRoot, { recursive: true }); + await mkdir(config.workRoot, { recursive: true }); + await mkdir(config.resultRoot, { recursive: true }); const hostVault = join(config.workRoot, "vault-host"); const hostSettings = join(config.workRoot, "settings-host.json"); - await Deno.mkdir(hostVault, { recursive: true }); + await mkdir(hostVault, { recursive: true }); await prepareP2PSettings(hostSettings, "p2p-split-host", config); const seedFiles = await createDeterministicDataset({ @@ -247,7 +249,7 @@ async function runHost(): Promise { binCount: config.binFileCount, binSizeBytes: config.binSizeBytes, }); - await Deno.writeTextFile( + await writeFile( join(config.workRoot, "sample-files.json"), JSON.stringify(pickSampleFiles(seedFiles.entries), null, 2) ); @@ -264,12 +266,12 @@ async function runHost(): Promise { mirrorElapsedMs, netem, }; - await Deno.writeTextFile(join(config.workRoot, "host-ready.json"), JSON.stringify(hostReady, null, 2)); + await writeFile(join(config.workRoot, "host-ready.json"), JSON.stringify(hostReady, null, 2)); const host = startCliInBackground(hostVault, "--settings", hostSettings, "p2p-host"); try { await host.waitUntilContains("P2P host is running", 20_000); - await Deno.writeTextFile( + await writeFile( join(config.workRoot, "p2p-host-ready.json"), JSON.stringify({ generatedAt: new Date().toISOString() }) ); @@ -282,23 +284,23 @@ async function runHost(): Promise { async function runClient(): Promise { const config = buildCommonConfig(); const netem = await applyNetemIfRequested(); - await Deno.mkdir(config.resultRoot, { recursive: true }); + await mkdir(config.resultRoot, { recursive: true }); await waitForFile(join(config.workRoot, "host-ready.json"), config.nodeTimeoutMs); await waitForFile(join(config.workRoot, "p2p-host-ready.json"), config.nodeTimeoutMs); const clientVault = join(config.workRoot, "vault-client"); const clientSettings = join(config.workRoot, "settings-client.json"); const statsPath = join(config.workRoot, "p2p-connection-stats.jsonl"); - await Deno.mkdir(clientVault, { recursive: true }); + await mkdir(clientVault, { recursive: true }); await prepareP2PSettings(clientSettings, "p2p-split-client", config); const hostReady = await readJsonFile(join(config.workRoot, "host-ready.json")); const timestamp = new Date().toISOString().replace(/[-:]/g, "").slice(0, 15); const outputDir = join(config.resultRoot, `p2p-split-${config.profile}-${timestamp}`); - await Deno.mkdir(outputDir, { recursive: true }); + await mkdir(outputDir, { recursive: true }); - const previousStatsPath = Deno.env.get("LIVESYNC_P2P_STATS_JSONL"); - Deno.env.set("LIVESYNC_P2P_STATS_JSONL", statsPath); + const previousStatsPath = process.env.LIVESYNC_P2P_STATS_JSONL; + process.env.LIVESYNC_P2P_STATS_JSONL = statsPath; let stage = "peer-discovery"; let peerDiscoveryCommandElapsedMs: number | undefined; let syncElapsedMs: number | undefined; @@ -338,10 +340,10 @@ async function runClient(): Promise { caseName: "p2p-split-compose", mode: "p2p-split-compose-benchmark", runId: config.runId, - simulationTier: Deno.env.get("BENCH_NETEM_ENABLED") === "1" ? "2" : "1", + simulationTier: process.env.BENCH_NETEM_ENABLED === "1" ? "2" : "1", networkProfile: config.profile, networkModel: - Deno.env.get("BENCH_NETEM_ENABLED") === "1" ? "split-compose-egress-netem" : "split-compose-no-netem", + process.env.BENCH_NETEM_ENABLED === "1" ? "split-compose-egress-netem" : "split-compose-no-netem", relay: config.relay, turnServers: config.turnServers, turnEnabled: config.turnServers.trim().length > 0, @@ -360,8 +362,8 @@ async function runClient(): Promise { throughputBytesPerSec: Number((hostReady.totalBytes / (syncElapsedMs / 1000)).toFixed(2)), throughputMiBPerSec: Number((hostReady.totalBytes / (syncElapsedMs / 1000) / 1024 / 1024).toFixed(4)), }; - await Deno.writeTextFile(join(outputDir, "summary.json"), JSON.stringify(result, null, 2)); - await Deno.writeTextFile( + await writeFile(join(outputDir, "summary.json"), JSON.stringify(result, null, 2)); + await writeFile( join(config.workRoot, "client-done.json"), JSON.stringify({ generatedAt: new Date().toISOString(), outputDir, ok: true }) ); @@ -374,10 +376,10 @@ async function runClient(): Promise { caseName: "p2p-split-compose", mode: "p2p-split-compose-benchmark", runId: config.runId, - simulationTier: Deno.env.get("BENCH_NETEM_ENABLED") === "1" ? "2" : "1", + simulationTier: process.env.BENCH_NETEM_ENABLED === "1" ? "2" : "1", networkProfile: config.profile, networkModel: - Deno.env.get("BENCH_NETEM_ENABLED") === "1" ? "split-compose-egress-netem" : "split-compose-no-netem", + process.env.BENCH_NETEM_ENABLED === "1" ? "split-compose-egress-netem" : "split-compose-no-netem", relay: config.relay, turnServers: config.turnServers, turnEnabled: config.turnServers.trim().length > 0, @@ -398,8 +400,8 @@ async function runClient(): Promise { ...errorToRecord(error), }, }; - await Deno.writeTextFile(join(outputDir, "summary.json"), JSON.stringify(result, null, 2)); - await Deno.writeTextFile( + await writeFile(join(outputDir, "summary.json"), JSON.stringify(result, null, 2)); + await writeFile( join(config.workRoot, "client-done.json"), JSON.stringify({ generatedAt: new Date().toISOString(), outputDir, ok: false }) ); @@ -407,9 +409,9 @@ async function runClient(): Promise { throw error; } finally { if (previousStatsPath === undefined) { - Deno.env.delete("LIVESYNC_P2P_STATS_JSONL"); + delete process.env.LIVESYNC_P2P_STATS_JSONL; } else { - Deno.env.set("LIVESYNC_P2P_STATS_JSONL", previousStatsPath); + process.env.LIVESYNC_P2P_STATS_JSONL = previousStatsPath; } } } @@ -430,6 +432,6 @@ async function main(): Promise { if (import.meta.main) { main().catch((error) => { console.error("[Fatal Error]", error); - Deno.exit(1); + process.exitCode = 1; }); } diff --git a/src/apps/cli/testdeno/bench-p2p.ts b/test/e2e-cli/bench-p2p.ts similarity index 95% rename from src/apps/cli/testdeno/bench-p2p.ts rename to test/e2e-cli/bench-p2p.ts index 05512694..fd6eed8c 100644 --- a/src/apps/cli/testdeno/bench-p2p.ts +++ b/test/e2e-cli/bench-p2p.ts @@ -1,3 +1,4 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; import { TempDir } from "./helpers/temp.ts"; import { applyP2pSettings, @@ -82,12 +83,12 @@ type P2PConnectionStats = { }; function readEnvString(name: string, fallback: string): string { - const value = Deno.env.get(name)?.trim(); + const value = process.env[name]?.trim(); return value && value.length > 0 ? value : fallback; } function readEnvNumber(name: string, fallback: number): number { - const raw = Deno.env.get(name); + const raw = process.env[name]; if (raw === undefined || raw.trim() === "") { return fallback; } @@ -100,7 +101,7 @@ function readEnvNumber(name: string, fallback: number): number { } function readEnvStringArray(name: string, fallback: string[]): string[] { - const raw = Deno.env.get(name)?.trim(); + const raw = process.env[name]?.trim(); if (!raw) { return fallback; } @@ -187,7 +188,7 @@ function buildConfig(): BenchmarkConfig { "This benchmark result is scoped to the configured dataset, network model, and selected ICE path.", ]), verificationMode: parseBenchmarkVerificationMode( - Deno.env.get("BENCH_VERIFY_MODE"), + process.env.BENCH_VERIFY_MODE, ), repeatIndex: Math.floor(readEnvNumber("BENCH_REPEAT_INDEX", 1)), repeatCount: Math.floor(readEnvNumber("BENCH_REPEAT_COUNT", 1)), @@ -195,7 +196,7 @@ function buildConfig(): BenchmarkConfig { } function readOptionalResultPath(): string | undefined { - const raw = Deno.env.get("BENCH_RESULT_JSON")?.trim(); + const raw = process.env.BENCH_RESULT_JSON?.trim(); if (!raw) { return undefined; } @@ -206,7 +207,7 @@ async function readLatestP2PConnectionStats( statsPath: string, ): Promise { try { - const text = await Deno.readTextFile(statsPath); + const text = await readFile(statsPath, "utf8"); const lines = text .split(/\r?\n/) .map((line) => line.trim()) @@ -233,13 +234,13 @@ async function main(): Promise { const hostSettings = workDir.join("settings-host.json"); const clientSettings = workDir.join("settings-client.json"); const p2pStatsPath = workDir.join("p2p-connection-stats.jsonl"); - const previousStatsPath = Deno.env.get("LIVESYNC_P2P_STATS_JSONL"); - Deno.env.set("LIVESYNC_P2P_STATS_JSONL", p2pStatsPath); + const previousStatsPath = process.env.LIVESYNC_P2P_STATS_JSONL; + process.env.LIVESYNC_P2P_STATS_JSONL = p2pStatsPath; try { await Promise.all([ - Deno.mkdir(hostVault, { recursive: true }), - Deno.mkdir(clientVault, { recursive: true }), + mkdir(hostVault, { recursive: true }), + mkdir(clientVault, { recursive: true }), initSettingsFile(hostSettings), initSettingsFile(clientSettings), ]); @@ -407,7 +408,7 @@ async function main(): Promise { }; if (resultPath) { - await Deno.writeTextFile( + await writeFile( resultPath, JSON.stringify(result, null, 2), ); @@ -428,9 +429,9 @@ async function main(): Promise { } } finally { if (previousStatsPath === undefined) { - Deno.env.delete("LIVESYNC_P2P_STATS_JSONL"); + delete process.env.LIVESYNC_P2P_STATS_JSONL; } else { - Deno.env.set("LIVESYNC_P2P_STATS_JSONL", previousStatsPath); + process.env.LIVESYNC_P2P_STATS_JSONL = previousStatsPath; } await stopCoturnIfStarted(coturnStarted); await stopLocalRelayIfStarted(relayStarted); @@ -440,6 +441,6 @@ async function main(): Promise { if (import.meta.main) { main().catch((error) => { console.error(`[Fatal Error]`, error); - Deno.exit(1); + process.exitCode = 1; }); } diff --git a/src/apps/cli/testdeno/bench-run-item1.sh b/test/e2e-cli/bench-run-item1.sh similarity index 87% rename from src/apps/cli/testdeno/bench-run-item1.sh rename to test/e2e-cli/bench-run-item1.sh index 53d2998f..95881ffb 100644 --- a/src/apps/cli/testdeno/bench-run-item1.sh +++ b/test/e2e-cli/bench-run-item1.sh @@ -13,13 +13,13 @@ echo "[bench-wrapper] output directory: ${OUT_DIR}" echo "[bench-wrapper] running p2p benchmark" ( cd "${SCRIPT_DIR}" - BENCH_RESULT_JSON="${OUT_DIR}/p2p.json" deno task bench:p2p + BENCH_RESULT_JSON="${OUT_DIR}/p2p.json" node run-task.ts bench:p2p ) echo "[bench-wrapper] running couchdb benchmark with RTT ${BENCH_COUCHDB_RTT_MS:-default} ms (emulating HTTP network latency)" ( cd "${SCRIPT_DIR}" - BENCH_RESULT_JSON="${OUT_DIR}/couchdb.json" deno task bench:couchdb + BENCH_RESULT_JSON="${OUT_DIR}/couchdb.json" node run-task.ts bench:couchdb ) cat > "${OUT_DIR}/README.txt" < }; +const reportPath = + process.env.LIVESYNC_CLI_COVERAGE_REPORT ?? join(REPOSITORY_ROOT, "coverage", "cli-e2e", "coverage-final.json"); +const sourceRoot = (join(REPOSITORY_ROOT, "src") + sep).replaceAll("\\", "/"); +const report = JSON.parse(await readFile(reportPath, "utf8")) as Record; +function coverageFor(suffix: string): FileCoverage { + const found = Object.entries(report).find(([path]) => path.replaceAll("\\", "/").endsWith(suffix)); + assert.ok(found, `Missing coverage for ${suffix}`); + return found[1]; +} + +test("CLI E2E coverage retains executed application functions after source-map remapping", () => { + assert.ok( + Object.keys(report).every((path) => path.replaceAll("\\", "/").startsWith(sourceRoot)), + "The report must contain only source files from this repository" + ); + const counts = Object.values(coverageFor("/src/apps/cli/main.ts").f); + assert.ok(counts.length > 0, "The report must retain application function ranges, rather than default line counts"); + assert.ok( + counts.some((count) => count > 0), + "The CLI application must have executed" + ); + assert.ok( + Object.keys(report).every((path) => !path.replaceAll("\\", "/").includes("/node_modules/")), + "The application report must exclude bundled dependencies" + ); +}); + +test( + "local CLI coverage retains unexecuted P2P functions", + { + skip: (process.env.LIVESYNC_CLI_COVERAGE_TASK ?? "test:local") !== "test:local", + }, + () => { + const counts = Object.values(coverageFor("/src/apps/cli/commands/p2p.ts").f); + assert.ok(counts.length > 0, "The report must retain P2P function ranges"); + assert.ok( + counts.some((count) => count === 0), + "The local suite must not report every P2P function as executed" + ); + } +); diff --git a/src/apps/cli/testdeno/helpers/backgroundCli.ts b/test/e2e-cli/helpers/backgroundCli.ts similarity index 84% rename from src/apps/cli/testdeno/helpers/backgroundCli.ts rename to test/e2e-cli/helpers/backgroundCli.ts index ef81feab..e4c74f89 100644 --- a/src/apps/cli/testdeno/helpers/backgroundCli.ts +++ b/test/e2e-cli/helpers/backgroundCli.ts @@ -1,9 +1,11 @@ +import { writeSync } from "node:fs"; +import { spawnProcess, type RunningProcess } from "./process.ts"; import { CLI_DIR, TEE_ENABLED, formatTeeCommand, createLineTeeWriter } from "./cli.ts"; -import { join } from "@std/path"; +import { join } from "node:path"; const CLI_DIST = join(CLI_DIR, "dist", "index.cjs"); -const VERBOSE_ENABLED = Deno.env.get("LIVESYNC_CLI_VERBOSE") === "1"; -const DEBUG_ENABLED = Deno.env.get("LIVESYNC_CLI_DEBUG") === "1"; +const VERBOSE_ENABLED = process.env.LIVESYNC_CLI_VERBOSE === "1"; +const DEBUG_ENABLED = process.env.LIVESYNC_CLI_DEBUG === "1"; function decorateArgs(args: string[]): string[] { return DEBUG_ENABLED ? ["-d", ...args] : VERBOSE_ENABLED ? ["-v", ...args] : args; @@ -38,24 +40,26 @@ export class BackgroundCliProcess { #stdoutDone: Promise; #stderrDone: Promise; - constructor( - readonly child: Deno.ChildProcess, - readonly args: string[] - ) { + readonly child: RunningProcess; + readonly args: string[]; + + constructor(child: RunningProcess, args: string[]) { + this.child = child; + this.args = args; const cliArgs = decorateArgs(args); this.#stdoutDone = pump( child.stdout, (text) => { this.#stdout += text; }, - TEE_ENABLED ? createLineTeeWriter(child.pid, "stdout", (chunk) => Deno.stdout.writeSync(chunk)) : null + TEE_ENABLED ? createLineTeeWriter(child.pid, "stdout", (chunk) => writeSync(1, chunk)) : null ); this.#stderrDone = pump( child.stderr, (text) => { this.#stderr += text; }, - TEE_ENABLED ? createLineTeeWriter(child.pid, "stderr", (chunk) => Deno.stderr.writeSync(chunk)) : null + TEE_ENABLED ? createLineTeeWriter(child.pid, "stderr", (chunk) => writeSync(2, chunk)) : null ); } @@ -102,16 +106,17 @@ export class BackgroundCliProcess { export function startCliInBackground(...args: string[]): BackgroundCliProcess { const cliArgs = decorateArgs(args); - const child = new Deno.Command("node", { + const child = spawnProcess(process.execPath, { args: [CLI_DIST, ...cliArgs], cwd: CLI_DIR, stdin: "null", stdout: "piped", stderr: "piped", - }).spawn(); + }); if (TEE_ENABLED) { - Deno.stdout.writeSync( + writeSync( + 1, new TextEncoder().encode(`[CLI tee pid=${child.pid}] process(bg): ${formatTeeCommand(cliArgs)}\n`) ); } diff --git a/src/apps/cli/testdeno/helpers/benchmarkVerification.ts b/test/e2e-cli/helpers/benchmarkVerification.ts similarity index 80% rename from src/apps/cli/testdeno/helpers/benchmarkVerification.ts rename to test/e2e-cli/helpers/benchmarkVerification.ts index ace22ed3..c29692ae 100644 --- a/src/apps/cli/testdeno/helpers/benchmarkVerification.ts +++ b/test/e2e-cli/helpers/benchmarkVerification.ts @@ -1,3 +1,5 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; import type { DatasetEntry } from "./dataset.ts"; export type BenchmarkVerificationMode = "all" | "sample"; @@ -9,16 +11,8 @@ export type BenchmarkVerificationResult = { datasetDigestSha256: string; }; -function toHex(bytes: ArrayBuffer): string { - return [...new Uint8Array(bytes)] - .map((value) => value.toString(16).padStart(2, "0")) - .join(""); -} - -async function sha256(bytes: Uint8Array): Promise { - const input = new ArrayBuffer(bytes.byteLength); - new Uint8Array(input).set(bytes); - return toHex(await crypto.subtle.digest("SHA-256", input)); +function sha256(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); } export function parseBenchmarkVerificationMode( @@ -53,12 +47,12 @@ export async function computeDatasetDigestSha256( ): Promise { const manifest: string[] = []; for (const entry of entries) { - const contentDigest = await sha256(await Deno.readFile(entry.absolutePath)); + const contentDigest = sha256(await readFile(entry.absolutePath)); manifest.push( `${entry.kind}\t${entry.relativePath}\t${entry.size}\t${contentDigest}`, ); } - return await sha256(new TextEncoder().encode(manifest.join("\n"))); + return sha256(new TextEncoder().encode(manifest.join("\n"))); } export async function verifyBenchmarkDataset( diff --git a/src/apps/cli/testdeno/helpers/cli.ts b/test/e2e-cli/helpers/cli.ts similarity index 84% rename from src/apps/cli/testdeno/helpers/cli.ts rename to test/e2e-cli/helpers/cli.ts index 6318f553..ad6f59f1 100644 --- a/src/apps/cli/testdeno/helpers/cli.ts +++ b/test/e2e-cli/helpers/cli.ts @@ -1,13 +1,10 @@ -import { join } from "@std/path"; +import { writeSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { spawnProcess } from "./process.ts"; +import { createHash } from "node:crypto"; -// --------------------------------------------------------------------------- -// Path resolution -// --------------------------------------------------------------------------- -// This file lives at: src/apps/cli/testdeno/helpers/cli.ts -// CLI root (src/apps/cli/) is two levels up. -// import.meta.dirname is available in Deno 1.40+ as an OS-native path string. -export const CLI_DIR: string = join(import.meta.dirname!, "..", ".."); -export const CLI_DIST = join(CLI_DIR, "dist", "index.cjs"); +import { CLI_DIR, CLI_DIST } from "./paths.ts"; +export { CLI_DIR, CLI_DIST } from "./paths.ts"; // --------------------------------------------------------------------------- // Result type @@ -20,9 +17,9 @@ export interface CliResult { code: number; } -export const TEE_ENABLED = Deno.env.get("LIVESYNC_TEST_TEE") === "1"; -const VERBOSE_ENABLED = Deno.env.get("LIVESYNC_CLI_VERBOSE") === "1"; -const DEBUG_ENABLED = Deno.env.get("LIVESYNC_CLI_DEBUG") === "1"; +export const TEE_ENABLED = process.env.LIVESYNC_TEST_TEE === "1"; +const VERBOSE_ENABLED = process.env.LIVESYNC_CLI_VERBOSE === "1"; +const DEBUG_ENABLED = process.env.LIVESYNC_CLI_DEBUG === "1"; function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -40,7 +37,7 @@ function concatChunks(chunks: Uint8Array[]): Uint8Array { } export function formatTeeCommand(args: string[]): string { - return ["node", CLI_DIST, ...args].map((part) => JSON.stringify(part)).join(" "); + return [process.execPath, CLI_DIST, ...args].map((part) => JSON.stringify(part)).join(" "); } export function createLineTeeWriter( @@ -114,27 +111,25 @@ async function collectStream( async function runNodeCommand(args: string[], stdinData?: Uint8Array): Promise { const cliArgs = DEBUG_ENABLED ? ["-d", ...args] : VERBOSE_ENABLED ? ["-v", ...args] : args; - const child = new Deno.Command("node", { + const child = spawnProcess(process.execPath, { args: [CLI_DIST, ...cliArgs], cwd: CLI_DIR, stdin: stdinData ? "piped" : "null", stdout: "piped", stderr: "piped", - }).spawn(); + }); if (TEE_ENABLED) { - Deno.stdout.writeSync( - new TextEncoder().encode(`[CLI tee pid=${child.pid}] process: ${formatTeeCommand(cliArgs)}\n`) - ); + writeSync(1, new TextEncoder().encode(`[CLI tee pid=${child.pid}] process: ${formatTeeCommand(cliArgs)}\n`)); } const stdoutPromise = collectStream( child.stdout, - TEE_ENABLED ? createLineTeeWriter(child.pid, "stdout", (chunk) => Deno.stdout.writeSync(chunk)) : null + TEE_ENABLED ? createLineTeeWriter(child.pid, "stdout", (chunk) => writeSync(1, chunk)) : null ); const stderrPromise = collectStream( child.stderr, - TEE_ENABLED ? createLineTeeWriter(child.pid, "stderr", (chunk) => Deno.stderr.writeSync(chunk)) : null + TEE_ENABLED ? createLineTeeWriter(child.pid, "stderr", (chunk) => writeSync(2, chunk)) : null ); if (stdinData) { @@ -172,7 +167,7 @@ function isTransientNetworkError(message: string): boolean { * do. Does NOT throw on non-zero exit — check `.code` yourself. */ export async function runCli(...args: string[]): Promise { - const retries = Number(Deno.env.get("LIVESYNC_CLI_RETRY") ?? "0"); + const retries = Number(process.env.LIVESYNC_CLI_RETRY ?? "0"); for (let attempt = 0; ; attempt++) { const result = await runNodeCommand(args); if (result.code === 0) return result; @@ -204,7 +199,7 @@ export async function runCliOrFail(...args: string[]): Promise { export async function runCliWithInput(input: string | Uint8Array, ...args: string[]): Promise { const data = typeof input === "string" ? new TextEncoder().encode(input) : input; - const retries = Number(Deno.env.get("LIVESYNC_CLI_RETRY") ?? "0"); + const retries = Number(process.env.LIVESYNC_CLI_RETRY ?? "0"); for (let attempt = 0; ; attempt++) { const result = await runNodeCommand(args, data); if (result.code === 0) return result; @@ -258,12 +253,9 @@ export function assertNotContains(haystack: string, needle: string, message: str } export async function assertFilesEqual(expectedPath: string, actualPath: string, message: string): Promise { - const [expected, actual] = await Promise.all([Deno.readFile(expectedPath), Deno.readFile(actualPath)]); + const [expected, actual] = await Promise.all([readFile(expectedPath), readFile(actualPath)]); if (expected.length !== actual.length || expected.some((b, i) => b !== actual[i])) { - const hex = async (d: Uint8Array) => { - const h = await crypto.subtle.digest("SHA-256", d); - return [...new Uint8Array(h)].map((b) => b.toString(16).padStart(2, "0")).join(""); - }; + const hex = (data: Uint8Array) => createHash("sha256").update(data).digest("hex"); throw new Error( `[FAIL] ${message}\nexpected SHA-256: ${await hex(expected)}\nactual SHA-256: ${await hex(actual)}` ); @@ -275,7 +267,7 @@ export async function assertFilesEqual(expectedPath: string, actualPath: string, // --------------------------------------------------------------------------- export async function readJsonFile>(filePath: string): Promise { - return JSON.parse(await Deno.readTextFile(filePath)) as T; + return JSON.parse(await readFile(filePath, "utf8")) as T; } export function jsonStringField(jsonText: string, field: string): string { diff --git a/src/apps/cli/testdeno/helpers/compressionDataset.ts b/test/e2e-cli/helpers/compressionDataset.ts similarity index 83% rename from src/apps/cli/testdeno/helpers/compressionDataset.ts rename to test/e2e-cli/helpers/compressionDataset.ts index 308836d8..2785ce8d 100644 --- a/src/apps/cli/testdeno/helpers/compressionDataset.ts +++ b/test/e2e-cli/helpers/compressionDataset.ts @@ -1,5 +1,8 @@ -import { join } from "@std/path"; +import { copyFile, mkdtemp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { runProcess } from "./process.ts"; import type { DatasetEntry, DatasetKind } from "./dataset.ts"; +import { REPOSITORY_ROOT } from "./paths.ts"; export type CompressionDatasetEntry = DatasetEntry & { source: string; @@ -18,8 +21,6 @@ export type JpegEncoder = (inputPpm: string, outputJpeg: string) => Promise { } export async function encodeJpegWithCjpeg(inputPpm: string, outputJpeg: string): Promise { - const command = new Deno.Command("cjpeg", { - args: ["-quality", "85", "-optimize", "-outfile", outputJpeg, inputPpm], - stdin: "null", - stdout: "piped", - stderr: "piped", - }); - let result: Deno.CommandOutput; + let result: Awaited>; try { - result = await command.output(); + result = await runProcess("cjpeg", { + args: ["-quality", "85", "-optimize", "-outfile", outputJpeg, inputPpm], + stdin: "null", + stdout: "piped", + stderr: "piped", + }); } catch (error) { - if (error instanceof Deno.errors.NotFound) { + if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") { throw new Error( "cjpeg is required for the compression benchmark. Use the Compose runner or install libjpeg tools." ); @@ -95,7 +95,7 @@ async function writeRandomBinary(path: string, size: number, seed: string): Prom for (let index = 0; index < bytes.length; index++) { bytes[index] = nextRandom() & 0xff; } - await Deno.writeFile(path, bytes); + await writeFile(path, bytes); } export async function createCompressionBenchmarkDataset(options: { @@ -114,7 +114,7 @@ export async function createCompressionBenchmarkDataset(options: { let jpegGenerator = ""; for (const kind of ALL_KINDS) { - await Deno.mkdir(join(datasetRoot, kind), { recursive: true }); + await mkdir(join(datasetRoot, kind), { recursive: true }); } const addFile = async (kind: DatasetKind, absolutePath: string, source: string) => { @@ -122,13 +122,13 @@ export async function createCompressionBenchmarkDataset(options: { .slice(options.rootDir.length + 1) .split("\\") .join("/"); - const size = (await Deno.stat(absolutePath)).size; + const size = (await stat(absolutePath)).size; entries.push({ kind, relativePath, absolutePath, size, source }); }; const copyRepositoryFile = async (kind: DatasetKind, sourcePath: string, targetName: string) => { const destination = join(datasetRoot, kind, targetName); - await Deno.copyFile(join(repositoryRoot, sourcePath), destination); + await copyFile(join(repositoryRoot, sourcePath), destination); await addFile(kind, destination, sourcePath); }; @@ -150,9 +150,9 @@ export async function createCompressionBenchmarkDataset(options: { "ReplicateResultProcessor.ts" ); - const markdownBytes = await Deno.readFile(join(repositoryRoot, "docs/settings.md")); + const markdownBytes = await readFile(join(repositoryRoot, "docs/settings.md")); const gzipPath = join(datasetRoot, "gz", "settings.md.gz"); - await Deno.writeFile(gzipPath, await gzip(markdownBytes)); + await writeFile(gzipPath, await gzip(markdownBytes)); await addFile("gz", gzipPath, "generated gzip of docs/settings.md"); const randomPath = join(datasetRoot, "bin", "deterministic-random.bin"); @@ -163,13 +163,14 @@ export async function createCompressionBenchmarkDataset(options: { ["smooth-gradient.jpg", false], ["textured-gradient.jpg", true], ] as const) { - const ppmPath = await Deno.makeTempFile({ dir: options.rootDir, prefix: "compression-jpeg-", suffix: ".ppm" }); + const temporaryDirectory = await mkdtemp(join(options.rootDir, "compression-jpeg-")); + const ppmPath = join(temporaryDirectory, "input.ppm"); const jpegPath = join(datasetRoot, "jpg", name); try { - await Deno.writeFile(ppmPath, createSyntheticPpm(640, 480, `${seed}-${name}`, textured)); + await writeFile(ppmPath, createSyntheticPpm(640, 480, `${seed}-${name}`, textured)); jpegGenerator = await jpegEncoder(ppmPath, jpegPath); } finally { - await Deno.remove(ppmPath).catch(() => {}); + await rm(temporaryDirectory, { recursive: true, force: true }).catch(() => {}); } await addFile("jpg", jpegPath, `${jpegGenerator}; ${textured ? "textured" : "smooth"}`); } diff --git a/src/apps/cli/testdeno/helpers/dataset.ts b/test/e2e-cli/helpers/dataset.ts similarity index 85% rename from src/apps/cli/testdeno/helpers/dataset.ts rename to test/e2e-cli/helpers/dataset.ts index 78af167a..8b52800e 100644 --- a/src/apps/cli/testdeno/helpers/dataset.ts +++ b/test/e2e-cli/helpers/dataset.ts @@ -1,3 +1,6 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + export type DeterministicDatasetConfig = { rootDir: string; datasetDirName: string; @@ -65,10 +68,6 @@ function createTextBytes(size: number, fileIndex: number, seed: string): Uint8Ar return out; } -function toPath(rootDir: string, relativePath: string): string { - return `${rootDir}/${relativePath}`; -} - export async function createDeterministicDataset(config: DeterministicDatasetConfig): Promise { if (config.mdCount < 0 || config.binCount < 0) { throw new Error("mdCount and binCount must be non-negative"); @@ -80,11 +79,11 @@ export async function createDeterministicDataset(config: DeterministicDatasetCon throw new Error("mdMinSizeBytes must be <= mdMaxSizeBytes"); } - const datasetRoot = toPath(config.rootDir, config.datasetDirName); - const mdDir = `${datasetRoot}/md`; - const binDir = `${datasetRoot}/bin`; - await Deno.mkdir(mdDir, { recursive: true }); - await Deno.mkdir(binDir, { recursive: true }); + const datasetRoot = join(config.rootDir, config.datasetDirName); + const mdDir = join(datasetRoot, "md"); + const binDir = join(datasetRoot, "bin"); + await mkdir(mdDir, { recursive: true }); + await mkdir(binDir, { recursive: true }); const nextRandom = createXorshift32(fnv1a32(config.seed)); const mdRange = config.mdMaxSizeBytes - config.mdMinSizeBytes + 1; @@ -93,21 +92,21 @@ export async function createDeterministicDataset(config: DeterministicDatasetCon for (let index = 0; index < config.mdCount; index++) { const size = config.mdMinSizeBytes + (nextRandom() % mdRange); const relativePath = `${config.datasetDirName}/md/file-${String(index).padStart(4, "0")}.md`; - const absolutePath = toPath(config.rootDir, relativePath); + const absolutePath = join(config.rootDir, ...relativePath.split("/")); const body = createTextBytes(size, index, config.seed); - await Deno.writeFile(absolutePath, body); + await writeFile(absolutePath, body); entries.push({ kind: "md", relativePath, absolutePath, size }); } for (let index = 0; index < config.binCount; index++) { const size = config.binSizeBytes; const relativePath = `${config.datasetDirName}/bin/file-${String(index).padStart(4, "0")}.bin`; - const absolutePath = toPath(config.rootDir, relativePath); + const absolutePath = join(config.rootDir, ...relativePath.split("/")); const body = new Uint8Array(size); for (let i = 0; i < size; i++) { body[i] = nextRandom() & 0xff; } - await Deno.writeFile(absolutePath, body); + await writeFile(absolutePath, body); entries.push({ kind: "bin", relativePath, absolutePath, size }); } diff --git a/src/apps/cli/testdeno/helpers/docker.ts b/test/e2e-cli/helpers/docker.ts similarity index 95% rename from src/apps/cli/testdeno/helpers/docker.ts rename to test/e2e-cli/helpers/docker.ts index 822f0211..018dc226 100644 --- a/src/apps/cli/testdeno/helpers/docker.ts +++ b/test/e2e-cli/helpers/docker.ts @@ -1,3 +1,5 @@ +import { writeSync } from "node:fs"; +import { spawnProcess } from "./process.ts"; /** * Docker service management for tests. * @@ -13,12 +15,12 @@ type DockerInvoker = { }; let dockerInvokerPromise: Promise | null = null; -const DOCKER_TEE = Deno.env.get("LIVESYNC_DOCKER_TEE") === "1" || Deno.env.get("LIVESYNC_TEST_TEE") === "1"; +const DOCKER_TEE = process.env.LIVESYNC_DOCKER_TEE === "1" || process.env.LIVESYNC_TEST_TEE === "1"; const trackedContainers = new Set(); -const CLEANUP_SIGNALS: Deno.Signal[] = ["SIGINT", "SIGTERM"]; +const CLEANUP_SIGNALS: NodeJS.Signals[] = ["SIGINT", "SIGTERM"]; let signalCleanupHandlersInstalled = false; let signalCleanupInProgress = false; -const signalCleanupHandlers = new Map void>(); +const signalCleanupHandlers = new Map void>(); // --------------------------------------------------------------------------- // Low-level docker wrapper @@ -64,14 +66,14 @@ async function collectStream( async function runCommand(bin: string, args: string[]): Promise<{ code: number; stdout: string; stderr: string }> { try { - const child = new Deno.Command(bin, { + const child = spawnProcess(bin, { args, stdin: "null", stdout: "piped", stderr: "piped", - }).spawn(); - const stdoutPromise = collectStream(child.stdout, DOCKER_TEE ? (chunk) => Deno.stdout.writeSync(chunk) : null); - const stderrPromise = collectStream(child.stderr, DOCKER_TEE ? (chunk) => Deno.stderr.writeSync(chunk) : null); + }); + const stdoutPromise = collectStream(child.stdout, DOCKER_TEE ? (chunk) => writeSync(1, chunk) : null); + const stderrPromise = collectStream(child.stderr, DOCKER_TEE ? (chunk) => writeSync(2, chunk) : null); const [status, stdout, stderr] = await Promise.all([child.status, stdoutPromise, stderrPromise]); const dec = new TextDecoder(); const result = { @@ -81,7 +83,7 @@ async function runCommand(bin: string, args: string[]): Promise<{ code: number; }; return result; } catch (err) { - if (err instanceof Deno.errors.NotFound) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { return { code: 127, stdout: "", @@ -93,7 +95,7 @@ async function runCommand(bin: string, args: string[]): Promise<{ code: number; } async function resolveDockerInvoker(): Promise { - const custom = Deno.env.get("LIVESYNC_DOCKER_COMMAND")?.trim(); + const custom = process.env.LIVESYNC_DOCKER_COMMAND?.trim(); if (custom) { const parsed = parseCommand(custom); const runner: DockerInvoker = { @@ -110,8 +112,8 @@ async function resolveDockerInvoker(): Promise { return runner; } - const mode = (Deno.env.get("LIVESYNC_DOCKER_MODE") ?? "auto").toLowerCase(); - const onWindows = Deno.build.os === "windows"; + const mode = (process.env.LIVESYNC_DOCKER_MODE ?? "auto").toLowerCase(); + const onWindows = process.platform === "win32"; const native: DockerInvoker = { bin: "docker", prefix: [], label: "docker" }; const wsl: DockerInvoker = { bin: "wsl", prefix: [], label: "wsl docker" }; @@ -204,13 +206,13 @@ async function cleanupTrackedContainers(reason: string): Promise { } } -async function handleSignalCleanup(signal: Deno.Signal): Promise { +async function handleSignalCleanup(signal: NodeJS.Signals): Promise { if (signalCleanupInProgress) return; signalCleanupInProgress = true; try { await cleanupTrackedContainers(`signal ${signal}`); } finally { - Deno.exit(signal === "SIGINT" ? 130 : 143); + process.exit(signal === "SIGINT" ? 130 : 143); } } @@ -222,7 +224,7 @@ function ensureSignalCleanupHandlers(): void { void handleSignalCleanup(signal); }; try { - Deno.addSignalListener(signal, listener); + process.on(signal, listener); signalCleanupHandlers.set(signal, listener); } catch { // Unsupported signal on this platform. @@ -234,7 +236,7 @@ function removeSignalCleanupHandlers(): void { if (!signalCleanupHandlersInstalled) return; for (const [signal, listener] of signalCleanupHandlers) { try { - Deno.removeSignalListener(signal, listener); + process.off(signal, listener); } catch { // Ignore if already removed or unsupported. } diff --git a/src/apps/cli/testdeno/helpers/env.ts b/test/e2e-cli/helpers/env.ts similarity index 90% rename from src/apps/cli/testdeno/helpers/env.ts rename to test/e2e-cli/helpers/env.ts index 4187d399..4d890c20 100644 --- a/src/apps/cli/testdeno/helpers/env.ts +++ b/test/e2e-cli/helpers/env.ts @@ -1,3 +1,4 @@ +import { readFile } from "node:fs/promises"; /** * Load a .env-style file (KEY=value per line) into a plain object. * Equivalent to `source $TEST_ENV_FILE; set -a` in bash. @@ -10,7 +11,7 @@ * # comment lines are ignored */ export async function loadEnvFile(filePath: string): Promise> { - const text = await Deno.readTextFile(filePath); + const text = await readFile(filePath, "utf8"); const result: Record = {}; for (const line of text.split("\n")) { const trimmed = line.trim(); diff --git a/src/apps/cli/testdeno/helpers/measuredCli.ts b/test/e2e-cli/helpers/measuredCli.ts similarity index 85% rename from src/apps/cli/testdeno/helpers/measuredCli.ts rename to test/e2e-cli/helpers/measuredCli.ts index 52e9d98f..bcf292d6 100644 --- a/src/apps/cli/testdeno/helpers/measuredCli.ts +++ b/test/e2e-cli/helpers/measuredCli.ts @@ -1,3 +1,4 @@ +import { runProcess } from "./process.ts"; import { CLI_DIR, CLI_DIST } from "./cli.ts"; export type CliProcessMeasurement = { @@ -13,17 +14,17 @@ const MARKER = "__LIVESYNC_GNU_TIME__"; export async function runMeasuredCliOrFail(...args: string[]): Promise { const started = performance.now(); - let output: Deno.CommandOutput; + let output: Awaited>; try { - output = await new Deno.Command("/usr/bin/time", { - args: ["-f", `${MARKER}%U\t%S\t%M`, "node", CLI_DIST, ...args], + output = await runProcess("/usr/bin/time", { + args: ["-f", `${MARKER}%U\t%S\t%M`, process.execPath, CLI_DIST, ...args], cwd: CLI_DIR, stdin: "null", stdout: "piped", stderr: "piped", - }).output(); + }); } catch (error) { - if (error instanceof Deno.errors.NotFound) { + if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") { throw new Error( "GNU /usr/bin/time is required for the compression benchmark. Use the Compose runner or install GNU time." ); diff --git a/test/e2e-cli/helpers/net.ts b/test/e2e-cli/helpers/net.ts new file mode 100644 index 00000000..52520187 --- /dev/null +++ b/test/e2e-cli/helpers/net.ts @@ -0,0 +1,59 @@ +import { createConnection, createServer } from "node:net"; + +type WaitForPortOptions = { timeoutMs?: number; intervalMs?: number; connectTimeoutMs?: number }; + +async function connectWithTimeout(hostname: string, port: number, timeoutMs: number): Promise { + await new Promise((resolve, reject) => { + const socket = createConnection({ host: hostname, port }); + socket.setTimeout(timeoutMs, () => socket.destroy(new Error(`connect timeout after ${timeoutMs}ms`))); + socket.once("error", reject); + socket.once("connect", () => { + socket.destroy(); + resolve(); + }); + }); +} + +export async function waitForPort(hostname: string, port: number, options: WaitForPortOptions = {}): Promise { + const timeoutMs = options.timeoutMs ?? 15000; + const intervalMs = options.intervalMs ?? 250; + const started = Date.now(); + let lastError: unknown; + while (Date.now() - started < timeoutMs) { + try { + await connectWithTimeout(hostname, port, options.connectTimeoutMs ?? 1000); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + } + throw new Error( + `Port ${hostname}:${port} did not become ready within ${timeoutMs}ms` + + (lastError ? ` (last error: ${String(lastError)})` : "") + ); +} + +export async function getFreePort(hostname = "127.0.0.1"): Promise { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, hostname, resolve); + }); + const address = server.address(); + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + if (!address || typeof address === "string") throw new Error("The listener did not bind a TCP port"); + return address.port; +} + +export async function getOptimalLoopbackIp(): Promise { + for (const hostname of ["127.0.0.1", "::1"]) { + try { + await getFreePort(hostname); + return hostname; + } catch { + /* Try the other loopback family. */ + } + } + return "127.0.0.1"; +} diff --git a/src/apps/cli/testdeno/helpers/p2p.ts b/test/e2e-cli/helpers/p2p.ts similarity index 90% rename from src/apps/cli/testdeno/helpers/p2p.ts rename to test/e2e-cli/helpers/p2p.ts index 6be7ebcf..af730686 100644 --- a/src/apps/cli/testdeno/helpers/p2p.ts +++ b/test/e2e-cli/helpers/p2p.ts @@ -1,3 +1,4 @@ + import { runCli } from "./cli.ts"; import { isLocalP2pRelay, startP2pRelay, stopP2pRelay, startCoturn, stopCoturn } from "./docker.ts"; import { waitForPort } from "./net.ts"; @@ -35,7 +36,7 @@ export async function discoverPeer( timeoutSeconds: number, targetPeer?: string ): Promise { - const retries = Math.max(0, Number(Deno.env.get("LIVESYNC_P2P_PEERS_RETRY") ?? "3")); + const retries = Math.max(0, Number(process.env.LIVESYNC_P2P_PEERS_RETRY ?? "3")); let lastCombined = ""; for (let attempt = 0; attempt <= retries; attempt++) { @@ -82,9 +83,9 @@ export async function maybeStartLocalRelay(relay: string): Promise { } const endpoint = parseRelayEndpoint(relay); await waitForPort(endpoint.hostname, endpoint.port, { - timeoutMs: Number(Deno.env.get("LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS") ?? "15000"), - intervalMs: Number(Deno.env.get("LIVESYNC_P2P_RELAY_READY_INTERVAL_MS") ?? "250"), - connectTimeoutMs: Number(Deno.env.get("LIVESYNC_P2P_RELAY_CONNECT_TIMEOUT_MS") ?? "1000"), + timeoutMs: Number(process.env.LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS ?? "15000"), + intervalMs: Number(process.env.LIVESYNC_P2P_RELAY_READY_INTERVAL_MS ?? "250"), + connectTimeoutMs: Number(process.env.LIVESYNC_P2P_RELAY_CONNECT_TIMEOUT_MS ?? "1000"), }); // Docker proxy accepts TCP connections instantly before the container's internal process is fully ready. // Wait an additional few seconds to ensure strfry is actually accepting WebSockets. diff --git a/test/e2e-cli/helpers/paths.ts b/test/e2e-cli/helpers/paths.ts new file mode 100644 index 00000000..0cb38d28 --- /dev/null +++ b/test/e2e-cli/helpers/paths.ts @@ -0,0 +1,5 @@ +import { join, resolve } from "node:path"; + +export const REPOSITORY_ROOT = resolve(import.meta.dirname, "../../.."); +export const CLI_DIR = join(REPOSITORY_ROOT, "src", "apps", "cli"); +export const CLI_DIST = join(CLI_DIR, "dist", "index.cjs"); diff --git a/test/e2e-cli/helpers/process.test.ts b/test/e2e-cli/helpers/process.test.ts new file mode 100644 index 00000000..43636fa0 --- /dev/null +++ b/test/e2e-cli/helpers/process.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { runProcess, spawnProcess } from "./process.ts"; + +test("subprocesses preserve arguments, environment overrides, and both output streams", async () => { + const argument = "a path with spaces/日本語.txt"; + const result = await runProcess(process.execPath, { + args: [ + "--eval", + "process.stdout.write(JSON.stringify({arg:process.argv[1],value:process.env.LIVESYNC_PROCESS_TEST,inherited:process.env.PATH||process.env.Path}));process.stderr.write('diagnostic');process.exitCode=7", + argument, + ], + env: { LIVESYNC_PROCESS_TEST: "override" }, + }); + assert.equal(result.code, 7); + assert.equal(result.success, false); + const output = JSON.parse(new TextDecoder().decode(result.stdout)); + assert.equal(output.arg, argument); + assert.equal(output.value, "override"); + assert.ok(output.inherited); + assert.equal(new TextDecoder().decode(result.stderr), "diagnostic"); +}); + +test("piped stdin closes and large stdout and stderr drain before completion", async () => { + const child = spawnProcess(process.execPath, { + args: [ + "--eval", + "let data='';process.stdin.setEncoding('utf8');process.stdin.on('data',v=>data+=v);process.stdin.on('end',()=>{process.stdout.write(data.repeat(100000));process.stderr.write('e'.repeat(100000));})", + ], + stdin: "piped", + }); + const stdout = new Response(child.stdout).text(); + const stderr = new Response(child.stderr).text(); + const writer = child.stdin.getWriter(); + await writer.write(new TextEncoder().encode("abc")); + await writer.close(); + const [status, out, err] = await Promise.all([child.status, stdout, stderr]); + assert.equal(status.success, true); + assert.equal(out, "abc".repeat(100000)); + assert.equal(err, "e".repeat(100000)); +}); + +test("a missing executable rejects with its OS error", async () => { + await assert.rejects(runProcess("livesync-missing-test-executable-847281"), { code: "ENOENT" }); +}); diff --git a/test/e2e-cli/helpers/process.ts b/test/e2e-cli/helpers/process.ts new file mode 100644 index 00000000..bc20dd57 --- /dev/null +++ b/test/e2e-cli/helpers/process.ts @@ -0,0 +1,76 @@ +import { spawn } from "node:child_process"; +import { constants } from "node:os"; +import { Readable, Writable } from "node:stream"; + +export type ProcessStatus = { code: number; success: boolean; signal: NodeJS.Signals | null }; +export type ProcessOutput = ProcessStatus & { stdout: Uint8Array; stderr: Uint8Array }; +export type ProcessOptions = { + args?: string[]; + cwd?: string | URL; + env?: NodeJS.ProcessEnv; + stdin?: "null" | "piped" | "inherit"; + stdout?: "piped" | "inherit"; + stderr?: "piped" | "inherit"; +}; +export type RunningProcess = { + readonly pid: number; + readonly stdin: WritableStream; + readonly stdout: ReadableStream; + readonly stderr: ReadableStream; + readonly status: Promise; + kill(signal?: NodeJS.Signals): void; +}; + +export function spawnProcess(command: string, options: ProcessOptions = {}): RunningProcess { + const child = spawn(command, options.args ?? [], { + cwd: options.cwd, + env: { ...process.env, ...options.env }, + stdio: [ + options.stdin === "inherit" ? "inherit" : options.stdin === "piped" ? "pipe" : "ignore", + options.stdout === "inherit" ? "inherit" : "pipe", + options.stderr === "inherit" ? "inherit" : "pipe", + ], + windowsHide: true, + }); + const status = new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", (code, signal) => + resolve({ + code: code ?? (signal ? 128 + (constants.signals[signal] ?? 0) : 1), + success: code === 0 && signal === null, + signal, + }) + ); + }); + return { + pid: child.pid ?? 0, + get stdin() { + if (!child.stdin) throw new Error("The child process stdin is not piped"); + return Writable.toWeb(child.stdin) as WritableStream; + }, + get stdout() { + if (!child.stdout) throw new Error("The child process stdout is not piped"); + return Readable.toWeb(child.stdout) as ReadableStream; + }, + get stderr() { + if (!child.stderr) throw new Error("The child process stderr is not piped"); + return Readable.toWeb(child.stderr) as ReadableStream; + }, + status, + kill(signal = "SIGTERM") { + child.kill(signal); + }, + }; +} + +export async function runProcess(command: string, options: ProcessOptions = {}): Promise { + const child = spawnProcess(command, options); + const collect = async (stream: ReadableStream): Promise => + new Uint8Array(await new Response(stream).arrayBuffer()); + const [status, stdout, stderr] = await Promise.all([ + child.status, + options.stdout === "inherit" ? new Uint8Array() : collect(child.stdout), + options.stderr === "inherit" ? new Uint8Array() : collect(child.stderr), + ]); + return { ...status, stdout, stderr }; +} diff --git a/src/apps/cli/testdeno/helpers/settings.ts b/test/e2e-cli/helpers/settings.ts similarity index 77% rename from src/apps/cli/testdeno/helpers/settings.ts rename to test/e2e-cli/helpers/settings.ts index bb999b13..3a3e5226 100644 --- a/src/apps/cli/testdeno/helpers/settings.ts +++ b/test/e2e-cli/helpers/settings.ts @@ -1,3 +1,5 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { runProcess } from "./process.ts"; import { CLI_DIR, runCliOrFail } from "./cli.ts"; // --------------------------------------------------------------------------- @@ -41,49 +43,27 @@ export async function generateSetupUriFromSettings( "})();", ].join("\n"); - const scriptPath = await Deno.makeTempFile({ - dir: CLI_DIR, - prefix: "livesync-setup-uri-", - suffix: ".mts", + const { code, stdout, stderr } = await runProcess(process.execPath, { + args: ["--input-type=module", "--eval", script], + cwd: CLI_DIR, + env: { SETTINGS_FILE: settingsFile, SETUP_PASSPHRASE: setupPassphrase }, }); - await Deno.writeTextFile(scriptPath, script); - - try { - const cmd = new Deno.Command("npx", { - args: ["tsx", scriptPath], - cwd: CLI_DIR, - env: { - SETTINGS_FILE: settingsFile, - SETUP_PASSPHRASE: setupPassphrase, - }, - stdin: "null", - stdout: "piped", - stderr: "piped", - }); - - const { code, stdout, stderr } = await cmd.output(); - const dec = new TextDecoder(); - if (code !== 0) { - throw new Error( - `Failed to generate setup URI (code ${code})\nstdout: ${dec.decode(stdout)}\nstderr: ${dec.decode(stderr)}` - ); - } - - const uri = dec.decode(stdout).trim(); - if (!uri) { - throw new Error("Failed to generate setup URI: output is empty"); - } - return uri; - } finally { - await Deno.remove(scriptPath).catch(() => {}); + const dec = new TextDecoder(); + if (code !== 0) { + throw new Error( + `Failed to generate setup URI (code ${code})\nstdout: ${dec.decode(stdout)}\nstderr: ${dec.decode(stderr)}` + ); } + const uri = dec.decode(stdout).trim(); + if (!uri) throw new Error("Failed to generate setup URI: output is empty"); + return uri; } /** Set isConfigured=true in a settings file (required for mirror / scan). */ export async function markSettingsConfigured(settingsFile: string): Promise { - const data = JSON.parse(await Deno.readTextFile(settingsFile)); + const data = JSON.parse(await readFile(settingsFile, "utf8")); data.isConfigured = true; - await Deno.writeTextFile(settingsFile, JSON.stringify(data, null, 2)); + await writeFile(settingsFile, JSON.stringify(data, null, 2)); } // --------------------------------------------------------------------------- @@ -102,7 +82,7 @@ export async function applyCouchdbSettings( couchdbDbname: string, liveSync = false ): Promise { - const data = JSON.parse(await Deno.readTextFile(settingsFile)); + const data = JSON.parse(await readFile(settingsFile, "utf8")); data.couchDB_URI = couchdbUri; data.couchDB_USER = couchdbUser; data.couchDB_PASSWORD = couchdbPassword; @@ -114,7 +94,7 @@ export async function applyCouchdbSettings( data.usePluginSync = false; } data.isConfigured = true; - await Deno.writeTextFile(settingsFile, JSON.stringify(data, null, 2)); + await writeFile(settingsFile, JSON.stringify(data, null, 2)); } export async function applyRemoteSyncSettings( @@ -135,7 +115,7 @@ export async function applyRemoteSyncSettings( usePathObfuscation?: boolean; } ): Promise { - const data = JSON.parse(await Deno.readTextFile(settingsFile)); + const data = JSON.parse(await readFile(settingsFile, "utf8")); if (options.remoteType === "COUCHDB") { data.remoteType = ""; @@ -166,7 +146,7 @@ export async function applyRemoteSyncSettings( data.usePathObfuscation = options.usePathObfuscation; } data.isConfigured = true; - await Deno.writeTextFile(settingsFile, JSON.stringify(data, null, 2)); + await writeFile(settingsFile, JSON.stringify(data, null, 2)); } // --------------------------------------------------------------------------- @@ -186,7 +166,7 @@ export async function applyP2pSettings( autoAccept = "~.*", turnServers = "turn:127.0.0.1:3478" ): Promise { - const data = JSON.parse(await Deno.readTextFile(settingsFile)); + const data = JSON.parse(await readFile(settingsFile, "utf8")); data.P2P_Enabled = true; data.P2P_AutoStart = false; data.P2P_AutoBroadcast = false; @@ -201,11 +181,11 @@ export async function applyP2pSettings( data.P2P_turnCredential = "testpass"; data.P2P_IsHeadless = true; data.isConfigured = true; - await Deno.writeTextFile(settingsFile, JSON.stringify(data, null, 2)); + await writeFile(settingsFile, JSON.stringify(data, null, 2)); } export async function applyP2pTestTweaks(settingsFile: string, deviceName: string, passphrase: string): Promise { - const data = JSON.parse(await Deno.readTextFile(settingsFile)); + const data = JSON.parse(await readFile(settingsFile, "utf8")); data.remoteType = "ONLY_P2P"; data.encrypt = true; data.passphrase = passphrase; @@ -215,5 +195,5 @@ export async function applyP2pTestTweaks(settingsFile: string, deviceName: strin data.usePluginSyncV2 = true; data.P2P_DevicePeerName = deviceName; data.isConfigured = true; - await Deno.writeTextFile(settingsFile, JSON.stringify(data, null, 2)); + await writeFile(settingsFile, JSON.stringify(data, null, 2)); } diff --git a/test/e2e-cli/helpers/temp.ts b/test/e2e-cli/helpers/temp.ts new file mode 100644 index 00000000..b558e8d9 --- /dev/null +++ b/test/e2e-cli/helpers/temp.ts @@ -0,0 +1,20 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** A temporary directory which is removed when its `await using` scope ends. */ +export class TempDir implements AsyncDisposable { + readonly path: string; + private constructor(path: string) { + this.path = path; + } + static async create(prefix = "livesync-node-test"): Promise { + return new TempDir(await mkdtemp(join(tmpdir(), `${prefix}.`))); + } + join(...parts: string[]): string { + return join(this.path, ...parts); + } + async [Symbol.asyncDispose](): Promise { + await rm(this.path, { recursive: true, force: true }).catch(() => {}); + } +} diff --git a/src/apps/cli/testdeno/relay-disconnect-probe.mjs b/test/e2e-cli/relay-disconnect-probe.mjs similarity index 100% rename from src/apps/cli/testdeno/relay-disconnect-probe.mjs rename to test/e2e-cli/relay-disconnect-probe.mjs diff --git a/src/apps/cli/testdeno/run-ci-suite.ts b/test/e2e-cli/run-ci-suite.ts similarity index 65% rename from src/apps/cli/testdeno/run-ci-suite.ts rename to test/e2e-cli/run-ci-suite.ts index 0eb1edfc..d82fc023 100644 --- a/src/apps/cli/testdeno/run-ci-suite.ts +++ b/test/e2e-cli/run-ci-suite.ts @@ -1,18 +1,20 @@ +import { spawnProcess } from "./helpers/process.ts"; +import { fileURLToPath } from "node:url"; import TASKS from "./ci-tasks.json" with { type: "json" }; for (const [index, task] of TASKS.entries()) { console.log(`\n[CLI E2E ${index + 1}/${TASKS.length}] ${task}`); - const child = new Deno.Command(Deno.execPath(), { - args: ["task", task], + const child = spawnProcess(process.execPath, { + args: [fileURLToPath(new URL("./run-task.ts", import.meta.url)), task], cwd: import.meta.dirname, stdin: "inherit", stdout: "inherit", stderr: "inherit", - }).spawn(); + }); const status = await child.status; if (!status.success) { console.error(`[CLI E2E] ${task} failed with exit code ${status.code}.`); - Deno.exit(status.code); + process.exit(status.code); } } diff --git a/src/apps/cli/testdeno/run-cli-e2e.sh b/test/e2e-cli/run-cli-e2e.sh similarity index 94% rename from src/apps/cli/testdeno/run-cli-e2e.sh rename to test/e2e-cli/run-cli-e2e.sh index 164a4e4a..3358547f 100644 --- a/src/apps/cli/testdeno/run-cli-e2e.sh +++ b/test/e2e-cli/run-cli-e2e.sh @@ -5,7 +5,7 @@ TASK="${CLI_E2E_TASK:-test:p2p:ci}" case "$TASK" in test:p2p-host|test:p2p-peers|test:p2p-sync|test:p2p-automatic-host|test:p2p-replacement|test:p2p-relay-disconnect|test:p2p:ci|test:p2p-three-nodes|test:p2p-upload-download) - exec deno task "$TASK" + exec node run-task.ts "$TASK" ;; *) echo "Unknown CLI_E2E_TASK: $TASK" >&2 diff --git a/test/e2e-cli/run-compose-p2p.ts b/test/e2e-cli/run-compose-p2p.ts new file mode 100644 index 00000000..0da9d824 --- /dev/null +++ b/test/e2e-cli/run-compose-p2p.ts @@ -0,0 +1,53 @@ +import { spawnProcess, type ProcessStatus } from "./helpers/process.ts"; +import { REPOSITORY_ROOT } from "./helpers/paths.ts"; +import { realpath } from "node:fs/promises"; +const repositoryRoot = await realpath(REPOSITORY_ROOT); +const composeArgs = ["compose", "-f", "test/bench-network/compose.yml"]; +const p2pEnvironment = { + CLI_E2E_TASK: process.env.CLI_E2E_TASK ?? "test:p2p:ci", + RELAY: process.env.RELAY ?? "ws://nostr-relay:7777/", + PEERS_TIMEOUT: process.env.PEERS_TIMEOUT ?? "20", + SYNC_TIMEOUT: process.env.SYNC_TIMEOUT ?? "60", + LIVESYNC_USE_COTURN: process.env.LIVESYNC_USE_COTURN ?? "0", + TURN_SERVERS: process.env.TURN_SERVERS ?? "none", + LIVESYNC_P2P_PEERS_RETRY: process.env.LIVESYNC_P2P_PEERS_RETRY ?? "1", + LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: process.env.LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS ?? "60000", + BENCH_LIVESYNC_TEST_TEE: process.env.BENCH_LIVESYNC_TEST_TEE ?? "0", + LIVESYNC_CLI_DEBUG: process.env.LIVESYNC_CLI_DEBUG ?? "0", + LIVESYNC_CLI_VERBOSE: process.env.LIVESYNC_CLI_VERBOSE ?? "0", +}; + +async function runDocker(args: string[], env?: Record): Promise { + return await spawnProcess("docker", { + args, + cwd: repositoryRoot, + env, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }).status; +} + +let testStatus: ProcessStatus | undefined; +try { + testStatus = await runDocker( + [...composeArgs, "run", "--build", "--rm", "bench-runner", "run-livesync-cli-e2e"], + p2pEnvironment + ); +} finally { + const cleanupStatus = await runDocker([...composeArgs, "down", "-v", "--remove-orphans"]); + if (!cleanupStatus.success) { + console.error(`[CLI E2E] Compose cleanup failed with exit code ${cleanupStatus.code}.`); + if (testStatus?.success) { + process.exit(cleanupStatus.code); + } + } +} + +if (!testStatus?.success) { + const code = testStatus?.code ?? 1; + console.error(`[CLI E2E] Compose P2P suite failed with exit code ${code}.`); + process.exit(code); +} + +console.log("\n[CLI E2E] Compose P2P suite passed."); diff --git a/test/e2e-cli/run-coverage.ts b/test/e2e-cli/run-coverage.ts new file mode 100644 index 00000000..c0ab4480 --- /dev/null +++ b/test/e2e-cli/run-coverage.ts @@ -0,0 +1,61 @@ +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnProcess } from "./helpers/process.ts"; +import { CLI_DIR, REPOSITORY_ROOT } from "./helpers/paths.ts"; + +const require = createRequire(import.meta.url); +const reportDirectory = join(REPOSITORY_ROOT, "coverage", "cli-e2e"); +const task = process.argv[2] ?? "test:local"; + +async function run(args: string[], cwd: string, env: NodeJS.ProcessEnv = {}): Promise { + const result = await spawnProcess(process.execPath, { + args, + cwd, + env, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }).status; + if (!result.success) throw new Error(`Coverage command failed with exit code ${result.code}`); +} + +if (task === "test:p2p:compose" || task === "test:e2e:all") { + throw new Error("CLI coverage collects host processes; select a host-direct task such as test:local or test:ci"); +} +const viteCli = join(dirname(require.resolve("vite/package.json")), "bin", "vite.js"); +await run([viteCli, "build"], CLI_DIR, { LIVESYNC_CLI_COVERAGE: "1" }); +const c8Cli = require.resolve("c8/bin/c8.js"); +await run( + [ + c8Cli, + "--all", + "--src=src/apps/cli", + // Match native paths and Node's file URL source-map cache. + "--include=**/src/**/*.ts", + "--exclude-after-remap", + "--exclude=**/test-support/**", + "--exclude=**/test/**", + "--exclude=**/*.unit.spec.ts", + "--exclude=**/*.test.ts", + "--reporter=text", + "--reporter=json", + "--reporter=html", + `--reports-dir=${reportDirectory}`, + `--temp-directory=${join(reportDirectory, "raw")}`, + process.execPath, + fileURLToPath(new URL("./run-task.ts", import.meta.url)), + task, + ...process.argv.slice(3), + ], + REPOSITORY_ROOT +); +await run( + ["--test", "--test-concurrency=1", fileURLToPath(new URL("./coverage-report.test.ts", import.meta.url))], + REPOSITORY_ROOT, + { + LIVESYNC_CLI_COVERAGE_REPORT: join(reportDirectory, "coverage-final.json"), + LIVESYNC_CLI_COVERAGE_TASK: task, + } +); +console.log(`CLI E2E coverage: ${reportDirectory}`); diff --git a/test/e2e-cli/run-task.ts b/test/e2e-cli/run-task.ts new file mode 100644 index 00000000..b92845ae --- /dev/null +++ b/test/e2e-cli/run-task.ts @@ -0,0 +1,59 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { join } from "node:path"; +import { CLI_DIR } from "./helpers/paths.ts"; +import { spawnProcess } from "./helpers/process.ts"; + +type Task = { test?: string[]; filter?: string; script?: string; tasks?: string[]; command?: string; args?: string[] }; +const taskDirectory = import.meta.dirname; + +export async function runTask(name: string, extraArgs: string[] = []): Promise { + if (Number(process.versions.node.split(".")[0]) < 26) throw new Error("CLI E2E tests require Node.js 26 or newer"); + const tasks = JSON.parse(await readFile(new URL("./tasks.json", import.meta.url), "utf8")) as Record; + const task = tasks[name]; + if (!task) throw new Error(`Unknown CLI task '${name}'. Available tasks: ${Object.keys(tasks).join(", ")}`); + if (task.tasks) { + for (const child of task.tasks) { + const code = await runTask(child, extraArgs); + if (code !== 0) return code; + } + return 0; + } + const envFile = join(CLI_DIR, ".test.env"); + const envArgs = existsSync(envFile) ? [`--env-file=${envFile}`] : []; + let command = process.execPath; + let args: string[]; + if (task.test) { + args = [ + ...envArgs, + "--test", + "--test-concurrency=1", + ...(task.filter ? [`--test-name-pattern=${task.filter}`] : []), + ...extraArgs, + ...task.test.map((file) => fileURLToPath(new URL(file, import.meta.url))), + ]; + } else if (task.script) { + args = [...envArgs, fileURLToPath(new URL(task.script, import.meta.url)), ...extraArgs]; + } else if (task.command) { + command = task.command; + args = [...(task.args ?? []), ...extraArgs]; + } else throw new Error(`CLI task '${name}' has no executable entry point`); + const child = spawnProcess(command, { + args, + cwd: taskDirectory, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }); + return (await child.status).code; +} + +if (import.meta.main) { + try { + process.exitCode = await runTask(process.argv[2] ?? "test", process.argv.slice(3)); + } catch (error) { + console.error(error); + process.exitCode = 1; + } +} diff --git a/test/e2e-cli/tasks.json b/test/e2e-cli/tasks.json new file mode 100644 index 00000000..97c5ba4a --- /dev/null +++ b/test/e2e-cli/tasks.json @@ -0,0 +1,198 @@ +{ + "test:helpers": { + "test": [ + "helpers/process.test.ts" + ] + }, + "test": { + "tasks": [ + "test:ci" + ] + }, + "test:ci": { + "script": "run-ci-suite.ts" + }, + "test:p2p:compose": { + "script": "run-compose-p2p.ts" + }, + "test:local": { + "tasks": [ + "test:setup-put-cat", + "test:mirror", + "test:daemon" + ] + }, + "test:daemon": { + "test": [ + "test-daemon.ts" + ] + }, + "test:daemon-startup": { + "test": [ + "test-daemon-startup.ts" + ] + }, + "test:decoupled-vault": { + "test": [ + "test-decoupled-vault.ts" + ] + }, + "test:remote-commands": { + "test": [ + "test-remote-commands.ts" + ] + }, + "test:settings-writeback": { + "test": [ + "test-settings-writeback.ts" + ] + }, + "test:remote-administration-exit-codes": { + "test": [ + "test-remote-administration-exit-codes.ts" + ] + }, + "test:push-pull": { + "test": [ + "test-push-pull.ts" + ] + }, + "test:setup-put-cat": { + "test": [ + "test-setup-put-cat.ts" + ] + }, + "test:mirror": { + "test": [ + "test-mirror.ts" + ] + }, + "test:sync-two-local": { + "test": [ + "test-sync-two-local-databases.ts" + ] + }, + "test:sync-locked-remote": { + "test": [ + "test-sync-locked-remote.ts" + ] + }, + "test:p2p-host": { + "test": [ + "test-p2p-host.ts" + ] + }, + "test:p2p-peers": { + "test": [ + "test-p2p-peers-local-relay.ts" + ] + }, + "test:p2p-sync": { + "test": [ + "test-p2p-sync.ts" + ] + }, + "test:p2p-automatic-host": { + "test": [ + "test-p2p-automatic-host.ts" + ] + }, + "test:p2p-replacement": { + "test": [ + "test-p2p-replicator-replacement.ts" + ] + }, + "test:p2p-relay-disconnect": { + "test": [ + "test-p2p-relay-disconnect.ts" + ] + }, + "test:p2p:ci": { + "test": [ + "test-p2p-sync.ts", + "test-p2p-automatic-host.ts", + "test-p2p-replicator-replacement.ts", + "test-p2p-relay-disconnect.ts" + ] + }, + "test:p2p-three-nodes": { + "test": [ + "test-p2p-three-nodes-conflict.ts" + ] + }, + "test:p2p-upload-download": { + "test": [ + "test-p2p-upload-download-repro.ts" + ] + }, + "test:benchmark-contract": { + "test": [ + "test-benchmark-contract.ts" + ] + }, + "bench:p2p": { + "script": "bench-p2p.ts" + }, + "bench:couchdb": { + "script": "bench-couchdb.ts" + }, + "bench:compression": { + "script": "bench-compression.ts" + }, + "bench:cases": { + "script": "bench-network-cases.ts" + }, + "bench:latency-sweep": { + "script": "bench-latency-sweep.ts" + }, + "bench:p2p-split-node": { + "script": "bench-p2p-split-node.ts" + }, + "bench:item1": { + "command": "bash", + "args": [ + "./bench-run-item1.sh" + ] + }, + "bench:item1:full": { + "command": "bash", + "args": [ + "-lc", + "BENCH_MD_FILE_COUNT=1500 BENCH_MD_MIN_SIZE_BYTES=1024 BENCH_MD_MAX_SIZE_BYTES=20480 BENCH_BIN_FILE_COUNT=500 BENCH_BIN_SIZE_BYTES=102400 BENCH_COUCHDB_RTT_MS=50 bash ./bench-run-item1.sh" + ] + }, + "test:e2e-couchdb": { + "test": [ + "test-e2e-two-vaults-couchdb.ts" + ] + }, + "test:e2e-matrix": { + "test": [ + "test-e2e-two-vaults-matrix.ts" + ] + }, + "test:e2e-matrix:couchdb-enc0": { + "test": [ + "test-e2e-two-vaults-matrix.ts" + ], + "filter": "e2e matrix: COUCHDB-enc0" + }, + "test:e2e-matrix:couchdb-enc1": { + "test": [ + "test-e2e-two-vaults-matrix.ts" + ], + "filter": "e2e matrix: COUCHDB-enc1" + }, + "test:e2e-matrix:minio-enc0": { + "test": [ + "test-e2e-two-vaults-matrix.ts" + ], + "filter": "e2e matrix: MINIO-enc0" + }, + "test:e2e-matrix:minio-enc1": { + "test": [ + "test-e2e-two-vaults-matrix.ts" + ], + "filter": "e2e matrix: MINIO-enc1" + } +} diff --git a/src/apps/cli/testdeno/test-benchmark-contract.ts b/test/e2e-cli/test-benchmark-contract.ts similarity index 69% rename from src/apps/cli/testdeno/test-benchmark-contract.ts rename to test/e2e-cli/test-benchmark-contract.ts index 5a201fec..40c0600d 100644 --- a/src/apps/cli/testdeno/test-benchmark-contract.ts +++ b/test/e2e-cli/test-benchmark-contract.ts @@ -1,4 +1,9 @@ -import { assert, assertEquals, assertStringIncludes } from "@std/assert"; +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { join } from "node:path"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { type BenchmarkCase, buildCases } from "./bench-network-cases.ts"; import { startCouchdbProxy } from "./bench-couchdb.ts"; import { @@ -7,14 +12,10 @@ import { } from "./helpers/benchmarkVerification.ts"; import type { DatasetEntry } from "./helpers/dataset.ts"; import { createCompressionBenchmarkDataset } from "./helpers/compressionDataset.ts"; +import { getFreePort } from "./helpers/net.ts"; -function getFreePort(): number { - const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); - try { - return (listener.addr as Deno.NetAddr).port; - } finally { - listener.close(); - } +function assertStringIncludes(actual: string, expected: string, message?: string): void { + assert.ok(actual.includes(expected), message ?? `Expected ${JSON.stringify(actual)} to include ${JSON.stringify(expected)}`); } function getCase(cases: BenchmarkCase[], name: string): BenchmarkCase { @@ -42,7 +43,7 @@ function parsedLimitations(testCase: BenchmarkCase): string[] { return parsed; } -Deno.test("benchmark cases record scope and limitations for paper use", () => { +test("benchmark cases record scope and limitations for paper use", () => { const cases = buildCases(); assert(cases.length > 0); @@ -67,31 +68,34 @@ Deno.test("benchmark cases record scope and limitations for paper use", () => { testCase.limitations.length > 0, `${testCase.name} must list limitations`, ); - assertEquals( + assert.deepStrictEqual( testCase.env.BENCH_MEASUREMENT_SCOPE, testCase.measurementScope, ); - assertEquals(parsedLimitations(testCase), testCase.limitations); - assertEquals( + assert.deepStrictEqual(parsedLimitations(testCase), testCase.limitations); + assert.deepStrictEqual( testCase.env.BENCH_VERIFY_MODE, "all", `${testCase.name} must verify the complete dataset`, ); } }); -Deno.test("CouchDB latency proxy applies half the requested RTT in each direction", async () => { - const backendPort = getFreePort(); - const proxyPort = getFreePort(); +test("CouchDB latency proxy applies half the requested RTT in each direction", async () => { + const backend = createServer((_request, response) => response.end("ok")); + const backendPort = await new Promise((resolve, reject) => { + backend.once("error", reject); + backend.listen(0, "127.0.0.1", () => { + const address = backend.address(); + if (!address || typeof address === "string") { + reject(new Error("Could not read the benchmark backend address")); + return; + } + resolve(address.port); + }); + }); + const proxyPort = await getFreePort(); const delays: number[] = []; - const backend = Deno.serve( - { - hostname: "127.0.0.1", - port: backendPort, - onListen() {}, - }, - () => new Response("ok"), - ); - const proxy = startCouchdbProxy({ + const proxy = await startCouchdbProxy({ backendUri: `http://127.0.0.1:${backendPort}`, proxyUri: `http://127.0.0.1:${proxyPort}`, requestedRttMs: 20, @@ -103,10 +107,10 @@ Deno.test("CouchDB latency proxy applies half the requested RTT in each directio try { const response = await fetch(`http://127.0.0.1:${proxyPort}/probe`); - assertEquals(await response.text(), "ok"); - assertEquals(proxy.directionalDelayMs, 10); - assertEquals(delays, [10, 10]); - assertEquals(proxy.snapshotCounters(), { + assert.deepStrictEqual(await response.text(), "ok"); + assert.deepStrictEqual(proxy.directionalDelayMs, 10); + assert.deepStrictEqual(delays, [10, 10]); + assert.deepStrictEqual(proxy.snapshotCounters(), { requestCount: 1, requestBodyBytes: 0, responseBodyBytes: 2, @@ -116,34 +120,34 @@ Deno.test("CouchDB latency proxy applies half the requested RTT in each directio method: "POST", body: "abc", }); - assertEquals(await posted.text(), "ok"); - assertEquals(proxy.snapshotCounters(), { + assert.deepStrictEqual(await posted.text(), "ok"); + assert.deepStrictEqual(proxy.snapshotCounters(), { requestCount: 1, requestBodyBytes: 3, responseBodyBytes: 2, }); } finally { await proxy.stop(); - await backend.shutdown(); + await new Promise((resolve, reject) => { + backend.close((error) => error ? reject(error) : resolve()); + }); } - const halfMillisecondProxy = startCouchdbProxy({ + const halfMillisecondProxy = await startCouchdbProxy({ backendUri: "http://127.0.0.1:1", - proxyUri: `http://127.0.0.1:${getFreePort()}`, + proxyUri: `http://127.0.0.1:${await getFreePort()}`, requestedRttMs: 1, delay: () => Promise.resolve(), }); try { - assertEquals(halfMillisecondProxy.directionalDelayMs, 0.5); + assert.deepStrictEqual(halfMillisecondProxy.directionalDelayMs, 0.5); } finally { await halfMillisecondProxy.stop(); } }); -Deno.test("compression benchmark dataset covers representative file kinds deterministically", async () => { - const fixtureRoot = await Deno.makeTempDir({ - prefix: "livesync-compression-contract-", - }); +test("compression benchmark dataset covers representative file kinds deterministically", async () => { + const fixtureRoot = await mkdtemp(join(tmpdir(), "livesync-compression-contract-")); const repositoryRoot = `${fixtureRoot}/repository`; const vaultA = `${fixtureRoot}/vault-a`; const vaultB = `${fixtureRoot}/vault-b`; @@ -161,21 +165,21 @@ Deno.test("compression benchmark dataset covers representative file kinds determ try { for (const [index, relativePath] of repositoryFiles.entries()) { const absolutePath = `${repositoryRoot}/${relativePath}`; - await Deno.mkdir( + await mkdir( absolutePath.slice(0, absolutePath.lastIndexOf("/")), { recursive: true }, ); - await Deno.writeFile( + await writeFile( absolutePath, new TextEncoder().encode( `fixture-${index}-${relativePath}\n`.repeat(20), ), ); } - await Deno.mkdir(vaultA, { recursive: true }); - await Deno.mkdir(vaultB, { recursive: true }); + await mkdir(vaultA, { recursive: true }); + await mkdir(vaultB, { recursive: true }); const jpegEncoder = async (_input: string, output: string) => { - await Deno.writeFile( + await writeFile( output, new Uint8Array([ 0xff, @@ -205,7 +209,7 @@ Deno.test("compression benchmark dataset covers representative file kinds determ jpegEncoder, }); - assertEquals(first.filesByKind, { + assert.deepStrictEqual(first.filesByKind, { md: 3, jpg: 2, png: 2, @@ -214,9 +218,9 @@ Deno.test("compression benchmark dataset covers representative file kinds determ gz: 1, bin: 1, }); - assertEquals(first.totalFiles, 13); - assertEquals(first.jpegGenerator, "contract JPEG encoder"); - assertEquals( + assert.deepStrictEqual(first.totalFiles, 13); + assert.deepStrictEqual(first.jpegGenerator, "contract JPEG encoder"); + assert.deepStrictEqual( first.entries.map((entry) => [ entry.kind, entry.relativePath, @@ -230,11 +234,11 @@ Deno.test("compression benchmark dataset covers representative file kinds determ ); assert(first.entries.every((entry) => entry.size > 0)); } finally { - await Deno.remove(fixtureRoot, { recursive: true }).catch(() => {}); + await rm(fixtureRoot, { recursive: true }).catch(() => {}); } }); -Deno.test("benchmark verification mode selects either all files or a labelled sample", () => { +test("benchmark verification mode selects either all files or a labelled sample", () => { const entries: DatasetEntry[] = [ { kind: "md", relativePath: "a.md", absolutePath: "/a", size: 1 }, { kind: "md", relativePath: "b.md", absolutePath: "/b", size: 1 }, @@ -243,15 +247,15 @@ Deno.test("benchmark verification mode selects either all files or a labelled sa { kind: "bin", relativePath: "e.bin", absolutePath: "/e", size: 1 }, ]; - assertEquals(parseBenchmarkVerificationMode("ALL"), "all"); - assertEquals(selectVerificationEntries(entries, "all").length, entries.length); + assert.deepStrictEqual(parseBenchmarkVerificationMode("ALL"), "all"); + assert.deepStrictEqual(selectVerificationEntries(entries, "all").length, entries.length); const sample = selectVerificationEntries(entries, "sample"); assert(sample.length > 0 && sample.length < entries.length); assert(sample.some((entry) => entry.kind === "md")); assert(sample.some((entry) => entry.kind === "bin")); }); -Deno.test("P2P signalling-shim cases do not claim to shape the note-data path", () => { +test("P2P signalling-shim cases do not claim to shape the note-data path", () => { const cases = buildCases(); for ( const name of [ @@ -260,10 +264,10 @@ Deno.test("P2P signalling-shim cases do not claim to shape the note-data path", ] ) { const testCase = getCase(cases, name); - assertEquals(testCase.runner, "p2p"); - assertEquals(testCase.env.BENCH_TURN_SERVERS, ""); - assertEquals(testCase.env.BENCH_SIMULATION_TIER, "2"); - assertEquals( + assert.deepStrictEqual(testCase.runner, "p2p"); + assert.deepStrictEqual(testCase.env.BENCH_TURN_SERVERS, ""); + assert.deepStrictEqual(testCase.env.BENCH_SIMULATION_TIER, "2"); + assert.deepStrictEqual( testCase.env.BENCH_NETWORK_MODEL, "compose-netem-signalling-shim", ); @@ -285,12 +289,12 @@ Deno.test("P2P signalling-shim cases do not claim to shape the note-data path", } }); -Deno.test("placeholder and TURN cases are clearly non-evidence for broad P2P performance", () => { +test("placeholder and TURN cases are clearly non-evidence for broad P2P performance", () => { const cases = buildCases(); const smartphone = getCase(cases, "p2p-smartphone-vpn-direct"); - assertEquals(smartphone.env.BENCH_SIMULATION_TIER, "unmeasured"); - assertEquals(smartphone.env.BENCH_NETWORK_MODEL, "local-runner-no-netem"); + assert.deepStrictEqual(smartphone.env.BENCH_SIMULATION_TIER, "unmeasured"); + assert.deepStrictEqual(smartphone.env.BENCH_NETWORK_MODEL, "local-runner-no-netem"); assert( smartphone.limitations.some((limitation) => limitation.includes("must not be reported as smartphone") @@ -310,15 +314,15 @@ Deno.test("placeholder and TURN cases are clearly non-evidence for broad P2P per ); }); -Deno.test("CouchDB netem cases are marked as remote-store baselines", () => { +test("CouchDB netem cases are marked as remote-store baselines", () => { const cases = buildCases(); for ( const name of ["couchdb-netem-home-wifi", "couchdb-netem-tethering-vpn"] ) { const testCase = getCase(cases, name); - assertEquals(testCase.runner, "couchdb"); - assertEquals(testCase.env.BENCH_SIMULATION_TIER, "2"); - assertEquals( + assert.deepStrictEqual(testCase.runner, "couchdb"); + assert.deepStrictEqual(testCase.env.BENCH_SIMULATION_TIER, "2"); + assert.deepStrictEqual( testCase.env.BENCH_NETWORK_MODEL, "compose-netem-tcp-shim", ); diff --git a/src/apps/cli/testdeno/test-daemon-startup.ts b/test/e2e-cli/test-daemon-startup.ts similarity index 78% rename from src/apps/cli/testdeno/test-daemon-startup.ts rename to test/e2e-cli/test-daemon-startup.ts index 8810faa1..e811e6ba 100644 --- a/src/apps/cli/testdeno/test-daemon-startup.ts +++ b/test/e2e-cli/test-daemon-startup.ts @@ -1,5 +1,7 @@ -import { assertEquals } from "@std/assert"; -import { join } from "@std/path"; +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, readFile, stat, unlink, utimes, writeFile } from "node:fs/promises"; +import { join } from "node:path"; import { TempDir } from "./helpers/temp.ts"; import { runCliOrFail, runCliWithInputOrFail } from "./helpers/cli.ts"; import { applyCouchdbSettings, initSettingsFile } from "./helpers/settings.ts"; @@ -8,7 +10,7 @@ import { startCouchdb, stopCouchdb } from "./helpers/docker.ts"; function envOrDefault(keys: string[], fallback: string): string { for (const key of keys) { - const value = Deno.env.get(key)?.trim(); + const value = process.env[key]?.trim(); if (value) return value; } return fallback; @@ -23,10 +25,10 @@ async function waitForText(filePath: string, expected: string, timeoutMs = 45_00 let actual = ""; while (Date.now() < deadline) { try { - actual = await Deno.readTextFile(filePath); + actual = await readFile(filePath, "utf8"); if (actual === expected) return; } catch (error) { - if (!(error instanceof Deno.errors.NotFound)) throw error; + if ((error as { code?: string }).code !== "ENOENT") throw error; } await waitForTick(); } @@ -39,9 +41,9 @@ async function waitForMissing(filePath: string, timeoutMs = 45_000): Promise {}); } -Deno.test("daemon: startup scan uploads, reconciles, and reflects CouchDB files", async () => { +test("daemon: startup scan uploads, reconciles, and reflects CouchDB files", async () => { await using workDir = await TempDir.create("livesync-cli-daemon-startup"); const couchdbUri = envOrDefault(["COUCHDB_URI", "hostname"], "http://127.0.0.1:5989").replace(/\/$/, ""); @@ -74,12 +76,12 @@ Deno.test("daemon: startup scan uploads, reconciles, and reflects CouchDB files" const settingsC = workDir.join("settings-c.json"); await Promise.all([ - Deno.mkdir(databaseA, { recursive: true }), - Deno.mkdir(databaseB, { recursive: true }), - Deno.mkdir(databaseC, { recursive: true }), - Deno.mkdir(vaultA, { recursive: true }), - Deno.mkdir(vaultB, { recursive: true }), - Deno.mkdir(vaultC, { recursive: true }), + mkdir(databaseA, { recursive: true }), + mkdir(databaseB, { recursive: true }), + mkdir(databaseC, { recursive: true }), + mkdir(vaultA, { recursive: true }), + mkdir(vaultB, { recursive: true }), + mkdir(vaultC, { recursive: true }), ]); const startupPath = "notes/present-before-start.md"; @@ -91,11 +93,11 @@ Deno.test("daemon: startup scan uploads, reconciles, and reflects CouchDB files" const deleteFileB = join(vaultB, deletePath); const remoteOnlyFileB = join(vaultB, remoteOnlyPath); - await Deno.mkdir(join(vaultA, "notes"), { recursive: true }); - await Deno.writeTextFile(startupFileA, "created before daemon startup\n"); + await mkdir(join(vaultA, "notes"), { recursive: true }); + await writeFile(startupFileA, "created before daemon startup\n"); const initialTime = new Date(Date.now() - 10_000); - await Deno.utime(startupFileA, initialTime, initialTime); - await Deno.writeTextFile(deleteFileA, "delete this after the first run\n"); + await utimes(startupFileA, initialTime, initialTime); + await writeFile(deleteFileA, "delete this after the first run\n"); let daemonA: BackgroundCliProcess | undefined; let daemonB: BackgroundCliProcess | undefined; @@ -118,10 +120,10 @@ Deno.test("daemon: startup scan uploads, reconciles, and reflects CouchDB files" await waitForText(deleteFileB, "delete this after the first run\n"); // Changes made while A is stopped must be found by its next startup scan. - assertEquals(await daemonA.stop(), 0, daemonA.combined); + assert.deepStrictEqual(await daemonA.stop(), 0, daemonA.combined); daemonA = undefined; - await Deno.writeTextFile(startupFileA, "edited while daemon was stopped\n"); - await Deno.remove(deleteFileA); + await writeFile(startupFileA, "edited while daemon was stopped\n"); + await unlink(deleteFileA); daemonA = startCliInBackground(databaseA, "--vault", vaultA, "--settings", settingsA, "daemon"); await daemonA.waitUntilContains("[Daemon] Initial replication complete", 45_000); @@ -142,8 +144,8 @@ Deno.test("daemon: startup scan uploads, reconciles, and reflects CouchDB files" ); await runCliOrFail(databaseC, "--vault", vaultC, "--settings", settingsC, "sync"); await waitForText(remoteOnlyFileB, "created in a different local database\n"); - assertEquals(await Deno.readTextFile(startupFileB), "edited while daemon was stopped\n"); - assertEquals((await Deno.stat(remoteOnlyFileB)).isFile, true); + assert.deepStrictEqual(await readFile(startupFileB, "utf8"), "edited while daemon was stopped\n"); + assert.deepStrictEqual(((await stat(remoteOnlyFileB))).isFile(), true); } finally { await stopDaemon(daemonB); await stopDaemon(daemonA); diff --git a/src/apps/cli/testdeno/test-daemon.ts b/test/e2e-cli/test-daemon.ts similarity index 68% rename from src/apps/cli/testdeno/test-daemon.ts rename to test/e2e-cli/test-daemon.ts index d5f16ffa..618a4341 100644 --- a/src/apps/cli/testdeno/test-daemon.ts +++ b/test/e2e-cli/test-daemon.ts @@ -1,5 +1,5 @@ /** - * Deno port of test-daemon-linux.sh + * Node.js port of test-daemon-linux.sh * * Tests daemon-related ignore rules behaviour. * @@ -13,33 +13,36 @@ * 3. import: .gitignore directive → patterns from .gitignore are merged * * Run: - * deno test -A test-daemon.ts + * node --test test-daemon.ts */ -import { join } from "@std/path"; -import { assertEquals } from "@std/assert"; +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; + +import { join } from "node:path"; import { TempDir } from "./helpers/temp.ts"; import { runCliOrFail, runCli, assertContains, assertNotContains } from "./helpers/cli.ts"; import { initSettingsFile, markSettingsConfigured } from "./helpers/settings.ts"; -Deno.test("daemon: ignore rules behaviour", async (t) => { +test("daemon: ignore rules behaviour", async (t) => { // ------------------------------------------------------------------------- // Case 1: .livesync/ignore with *.tmp → ignored file not synced to database // ------------------------------------------------------------------------- - await t.step("case 1: .livesync/ignore *.tmp prevents sync", async () => { + await t.test("case 1: .livesync/ignore *.tmp prevents sync", async () => { await using workDir = await TempDir.create("livesync-cli-daemon-c1"); const settingsFile = workDir.join("data.json"); const vaultDir = workDir.join("vault"); - await Deno.mkdir(join(vaultDir, ".livesync"), { recursive: true }); - await Deno.mkdir(join(vaultDir, "notes"), { recursive: true }); + await mkdir(join(vaultDir, ".livesync"), { recursive: true }); + await mkdir(join(vaultDir, "notes"), { recursive: true }); await initSettingsFile(settingsFile); await markSettingsConfigured(settingsFile); - await Deno.writeTextFile(join(vaultDir, ".livesync", "ignore"), "*.tmp\n"); - await Deno.writeTextFile(join(vaultDir, "notes", "normal.md"), "normal content\n"); - await Deno.writeTextFile(join(vaultDir, "notes", "scratch.tmp"), "tmp content\n"); + await writeFile(join(vaultDir, ".livesync", "ignore"), "*.tmp\n"); + await writeFile(join(vaultDir, "notes", "normal.md"), "normal content\n"); + await writeFile(join(vaultDir, "notes", "scratch.tmp"), "tmp content\n"); console.log("[INFO] Running mirror for Case 1..."); await runCliOrFail(vaultDir, "--settings", settingsFile, "mirror"); @@ -47,8 +50,8 @@ Deno.test("daemon: ignore rules behaviour", async (t) => { // The normal file should be in the database. const resultNormal = workDir.join("case1-normal.txt"); await runCliOrFail(vaultDir, "--settings", settingsFile, "pull", "notes/normal.md", resultNormal); - const normalContent = await Deno.readTextFile(resultNormal); - assertEquals(normalContent, "normal content\n", "normal.md content mismatch after mirror"); + const normalContent = await readFile(resultNormal, "utf8"); + assert.deepStrictEqual(normalContent, "normal content\n", "normal.md content mismatch after mirror"); // The .tmp file should NOT be in the database. const dbList = await runCliOrFail(vaultDir, "--settings", settingsFile, "ls"); @@ -60,50 +63,50 @@ Deno.test("daemon: ignore rules behaviour", async (t) => { // ------------------------------------------------------------------------- // Case 2: .livesync/ignore absent → no error, and normal synchronisation continues // ------------------------------------------------------------------------- - await t.step("case 2: .livesync/ignore absent does not cause failure", async () => { + await t.test("case 2: .livesync/ignore absent does not cause failure", async () => { await using workDir = await TempDir.create("livesync-cli-daemon-c2"); const settingsFile = workDir.join("data2.json"); const vaultDir = workDir.join("vault2"); - await Deno.mkdir(join(vaultDir, "notes"), { recursive: true }); + await mkdir(join(vaultDir, "notes"), { recursive: true }); await initSettingsFile(settingsFile); await markSettingsConfigured(settingsFile); // No .livesync directory at all. - await Deno.writeTextFile(join(vaultDir, "notes", "hello.md"), "hello\n"); + await writeFile(join(vaultDir, "notes", "hello.md"), "hello\n"); console.log("[INFO] Running mirror for Case 2..."); const result = await runCli(vaultDir, "--settings", settingsFile, "mirror"); - assertEquals(result.code, 0, "mirror exited non-zero when .livesync/ignore is absent"); + assert.deepStrictEqual(result.code, 0, "mirror exited non-zero when .livesync/ignore is absent"); // The normal file should have been synced. const resultHello = workDir.join("case2-hello.txt"); await runCliOrFail(vaultDir, "--settings", settingsFile, "pull", "notes/hello.md", resultHello); - const helloContent = await Deno.readTextFile(resultHello); - assertEquals(helloContent, "hello\n", "file content mismatch when .livesync/ignore is absent"); + const helloContent = await readFile(resultHello, "utf8"); + assert.deepStrictEqual(helloContent, "hello\n", "file content mismatch when .livesync/ignore is absent"); console.log("[PASS] Case 2 verified successfully"); }); // ------------------------------------------------------------------------- // Case 3: import: .gitignore merges patterns // ------------------------------------------------------------------------- - await t.step("case 3: import: .gitignore directive merges patterns", async () => { + await t.test("case 3: import: .gitignore directive merges patterns", async () => { await using workDir = await TempDir.create("livesync-cli-daemon-c3"); const settingsFile = workDir.join("data3.json"); const vaultDir = workDir.join("vault3"); - await Deno.mkdir(join(vaultDir, ".livesync"), { recursive: true }); - await Deno.mkdir(join(vaultDir, "notes"), { recursive: true }); + await mkdir(join(vaultDir, ".livesync"), { recursive: true }); + await mkdir(join(vaultDir, "notes"), { recursive: true }); await initSettingsFile(settingsFile); await markSettingsConfigured(settingsFile); - await Deno.writeTextFile(join(vaultDir, ".livesync", "ignore"), "import: .gitignore\n"); - await Deno.writeTextFile(join(vaultDir, ".gitignore"), "# gitignore comment\n*.log\nbuild/\n"); + await writeFile(join(vaultDir, ".livesync", "ignore"), "import: .gitignore\n"); + await writeFile(join(vaultDir, ".gitignore"), "# gitignore comment\n*.log\nbuild/\n"); - await Deno.writeTextFile(join(vaultDir, "notes", "regular.md"), "regular note\n"); - await Deno.writeTextFile(join(vaultDir, "notes", "debug.log"), "log content\n"); + await writeFile(join(vaultDir, "notes", "regular.md"), "regular note\n"); + await writeFile(join(vaultDir, "notes", "debug.log"), "log content\n"); console.log("[INFO] Running mirror for Case 3..."); await runCliOrFail(vaultDir, "--settings", settingsFile, "mirror"); diff --git a/src/apps/cli/testdeno/test-decoupled-vault.ts b/test/e2e-cli/test-decoupled-vault.ts similarity index 63% rename from src/apps/cli/testdeno/test-decoupled-vault.ts rename to test/e2e-cli/test-decoupled-vault.ts index f1a017f5..c4f4114e 100644 --- a/src/apps/cli/testdeno/test-decoupled-vault.ts +++ b/test/e2e-cli/test-decoupled-vault.ts @@ -1,39 +1,42 @@ /** - * Deno port of test-decoupled-vault-linux.sh + * Node.js port of test-decoupled-vault-linux.sh * * Tests push, pull, and mirror command behaviour when the vault directory is * decoupled (separated) from the database directory. * * Run: - * deno test -A test-decoupled-vault.ts + * node --test test-decoupled-vault.ts */ -import { join } from "@std/path"; -import { assertEquals } from "@std/assert"; +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, readFile, rm, unlink, writeFile } from "node:fs/promises"; + +import { join } from "node:path"; import { TempDir } from "./helpers/temp.ts"; import { runCliOrFail } from "./helpers/cli.ts"; import { applyCouchdbSettings, initSettingsFile, markSettingsConfigured } from "./helpers/settings.ts"; import { startCouchdb, stopCouchdb } from "./helpers/docker.ts"; -const REMOTE_PATH = Deno.env.get("REMOTE_PATH") ?? "test/push-pull-decoupled.txt"; +const REMOTE_PATH = process.env["REMOTE_PATH"] ?? "test/push-pull-decoupled.txt"; -Deno.test("decoupled database and vault", async () => { +test("decoupled database and vault", async () => { await using workDir = await TempDir.create("livesync-cli-decoupled"); const settingsFile = workDir.join("data.json"); const vaultDir = workDir.join("vault"); const dbDir = workDir.join("db"); - await Deno.mkdir(join(vaultDir, "test"), { recursive: true }); - await Deno.mkdir(dbDir, { recursive: true }); + await mkdir(join(vaultDir, "test"), { recursive: true }); + await mkdir(dbDir, { recursive: true }); - const uri = Deno.env.get("COUCHDB_URI") ?? "http://127.0.0.1:5989/"; - const user = Deno.env.get("COUCHDB_USER") ?? "admin"; - const password = Deno.env.get("COUCHDB_PASSWORD") ?? "testpassword"; - const dbname = Deno.env.get("COUCHDB_DBNAME") ?? `decoupled-${Date.now()}`; + const uri = process.env["COUCHDB_URI"] ?? "http://127.0.0.1:5989/"; + const user = process.env["COUCHDB_USER"] ?? "admin"; + const password = process.env["COUCHDB_PASSWORD"] ?? "testpassword"; + const dbname = process.env["COUCHDB_DBNAME"] ?? `decoupled-${Date.now()}`; - const shouldStartDocker = Deno.env.get("LIVESYNC_START_DOCKER") !== "0"; - const keepDocker = Deno.env.get("LIVESYNC_DEBUG_KEEP_DOCKER") === "1"; + const shouldStartDocker = process.env["LIVESYNC_START_DOCKER"] !== "0"; + const keepDocker = process.env["LIVESYNC_DEBUG_KEEP_DOCKER"] === "1"; if (shouldStartDocker) { await startCouchdb(uri, user, password, dbname); @@ -53,7 +56,7 @@ Deno.test("decoupled database and vault", async () => { const srcFile = workDir.join("push-source.txt"); const pulledFile = workDir.join("pull-result.txt"); const content = `push-pull-decoupled-test ${new Date().toISOString()}\n`; - await Deno.writeTextFile(srcFile, content); + await writeFile(srcFile, content); // 1. Test push command with decoupled vault directory console.log(`[INFO] push with decoupled vault -> ${REMOTE_PATH}`); @@ -63,21 +66,21 @@ Deno.test("decoupled database and vault", async () => { console.log(`[INFO] pull with decoupled vault <- ${REMOTE_PATH}`); await runCliOrFail(dbDir, "--vault", vaultDir, "--settings", settingsFile, "pull", REMOTE_PATH, pulledFile); - const pulled = await Deno.readTextFile(pulledFile); - assertEquals(pulled, content, "push/pull roundtrip with decoupled vault content mismatch"); + const pulled = await readFile(pulledFile, "utf8"); + assert.deepStrictEqual(pulled, content, "push/pull roundtrip with decoupled vault content mismatch"); console.log("[PASS] push/pull roundtrip with decoupled vault matched"); // 3. Clean up pulled file and vault test directory to verify mirror - await Deno.remove(pulledFile).catch(() => {}); - await Deno.remove(join(vaultDir, "test"), { recursive: true }).catch(() => {}); + await unlink(pulledFile).catch(() => {}); + await rm(join(vaultDir, "test"), { recursive: true }).catch(() => {}); // 4. Test mirror command with decoupled vault directory console.log("[INFO] mirror with decoupled vault"); await runCliOrFail(dbDir, "--vault", vaultDir, "--settings", settingsFile, "mirror"); const restoredFile = join(vaultDir, REMOTE_PATH); - const restored = await Deno.readTextFile(restoredFile); - assertEquals(restored, content, "mirror with decoupled vault content mismatch"); + const restored = await readFile(restoredFile, "utf8"); + assert.deepStrictEqual(restored, content, "mirror with decoupled vault content mismatch"); console.log("[PASS] mirror with decoupled vault matched"); } finally { if (shouldStartDocker && !keepDocker) { diff --git a/src/apps/cli/testdeno/test-e2e-two-vaults-couchdb.ts b/test/e2e-cli/test-e2e-two-vaults-couchdb.ts similarity index 95% rename from src/apps/cli/testdeno/test-e2e-two-vaults-couchdb.ts rename to test/e2e-cli/test-e2e-two-vaults-couchdb.ts index c112b702..ba3e9206 100644 --- a/src/apps/cli/testdeno/test-e2e-two-vaults-couchdb.ts +++ b/test/e2e-cli/test-e2e-two-vaults-couchdb.ts @@ -1,4 +1,6 @@ -import { assert } from "@std/assert"; +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, writeFile } from "node:fs/promises"; import { TempDir } from "./helpers/temp.ts"; import { runCli, @@ -14,7 +16,7 @@ type RemoteType = "COUCHDB" | "MINIO"; function requireEnv(...keys: string[]): string { for (const key of keys) { - const value = Deno.env.get(key)?.trim(); + const value = process.env[key]?.trim(); if (value) return value; } throw new Error(`Required env var is missing: ${keys.join(" or ")}`); @@ -49,10 +51,10 @@ export async function runScenario(remoteType: RemoteType, encrypt: boolean): Pro const pullDst = workDir.join("pull-destination.txt"); const pushBinarySrc = workDir.join("push-source.bin"); const pullBinaryDst = workDir.join("pull-destination.bin"); - await Deno.mkdir(vaultA, { recursive: true }); - await Deno.mkdir(vaultB, { recursive: true }); + await mkdir(vaultA, { recursive: true }); + await mkdir(vaultB, { recursive: true }); - const keepDocker = Deno.env.get("LIVESYNC_DEBUG_KEEP_DOCKER") === "1"; + const keepDocker = process.env["LIVESYNC_DEBUG_KEEP_DOCKER"] === "1"; if (remoteType === "COUCHDB") { await startCouchdb(couchdbUri, couchdbUser, couchdbPassword, dbname); } else { @@ -168,7 +170,7 @@ export async function runScenario(remoteType: RemoteType, encrypt: boolean): Pro ).trimEnd(); assert(secondVisible === `second-sync-round-${dbSuffix}`); - await Deno.writeTextFile(pushSrc, `pushed-content-${dbSuffix}\n`); + await writeFile(pushSrc, `pushed-content-${dbSuffix}\n`); await runCliOrFail(vaultA, "--settings", settingsA, "push", pushSrc, targetPush); await runCliWithInputOrFail(`put-content-${dbSuffix}\n`, vaultA, "--settings", settingsA, "put", targetPut); await syncBoth(); @@ -181,7 +183,7 @@ export async function runScenario(remoteType: RemoteType, encrypt: boolean): Pro const binary = new Uint8Array(4096); binary.fill(0x61); - await Deno.writeFile(pushBinarySrc, binary); + await writeFile(pushBinarySrc, binary); await runCliOrFail(vaultA, "--settings", settingsA, "push", pushBinarySrc, targetPushBinary); await syncBoth(); await runCliOrFail(vaultB, "--settings", settingsB, "pull", targetPushBinary, pullBinaryDst); @@ -294,10 +296,10 @@ export async function runScenario(remoteType: RemoteType, encrypt: boolean): Pro } } -Deno.test("e2e: two vaults over CouchDB without encryption", async () => { +test("e2e: two vaults over CouchDB without encryption", async () => { await runScenario("COUCHDB", false); }); -Deno.test("e2e: two vaults over CouchDB with encryption", async () => { +test("e2e: two vaults over CouchDB with encryption", async () => { await runScenario("COUCHDB", true); }); diff --git a/src/apps/cli/testdeno/test-e2e-two-vaults-matrix.ts b/test/e2e-cli/test-e2e-two-vaults-matrix.ts similarity index 87% rename from src/apps/cli/testdeno/test-e2e-two-vaults-matrix.ts rename to test/e2e-cli/test-e2e-two-vaults-matrix.ts index 757d6b57..eb99a1c0 100644 --- a/src/apps/cli/testdeno/test-e2e-two-vaults-matrix.ts +++ b/test/e2e-cli/test-e2e-two-vaults-matrix.ts @@ -1,3 +1,4 @@ +import { test } from "node:test"; import { runScenario } from "./test-e2e-two-vaults-couchdb.ts"; type MatrixCase = { @@ -14,7 +15,7 @@ const matrixCases: MatrixCase[] = [ ]; for (const tc of matrixCases) { - Deno.test(`e2e matrix: ${tc.label}`, async () => { + test(`e2e matrix: ${tc.label}`, async () => { await runScenario(tc.remoteType, tc.encrypt); }); } diff --git a/src/apps/cli/testdeno/test-mirror.ts b/test/e2e-cli/test-mirror.ts similarity index 76% rename from src/apps/cli/testdeno/test-mirror.ts rename to test/e2e-cli/test-mirror.ts index 1b2bcc22..1dd19622 100644 --- a/src/apps/cli/testdeno/test-mirror.ts +++ b/test/e2e-cli/test-mirror.ts @@ -1,5 +1,5 @@ /** - * Deno port of test-mirror-linux.sh + * Node.js port of test-mirror-linux.sh * * Tests the `mirror` command — bidirectional synchronisation between a local * storage directory (vault) and an in-process database. @@ -16,15 +16,18 @@ * No external services are required. * * Run: - * deno test -A test-mirror.ts + * node --test test-mirror.ts */ -import { assert, assertEquals } from "@std/assert"; +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { copyFile, mkdir, readFile, stat, utimes, writeFile } from "node:fs/promises"; + import { TempDir } from "./helpers/temp.ts"; import { runCliOrFail, runCliWithInputOrFail } from "./helpers/cli.ts"; import { initSettingsFile, markSettingsConfigured } from "./helpers/settings.ts"; -Deno.test("mirror: storage <-> DB synchronisation", async (t) => { +test("mirror: storage <-> DB synchronisation", async (t) => { await using workDir = await TempDir.create("livesync-cli-mirror"); // ------------------------------------------------------------------- @@ -33,20 +36,20 @@ Deno.test("mirror: storage <-> DB synchronisation", async (t) => { const settingsFile = workDir.join("data.json"); const vaultDir = workDir.join("vault"); const dbDir = workDir.join("db"); - await Deno.mkdir(workDir.join("vault", "test"), { recursive: true }); - await Deno.mkdir(dbDir, { recursive: true }); + await mkdir(workDir.join("vault", "test"), { recursive: true }); + await mkdir(dbDir, { recursive: true }); await initSettingsFile(settingsFile); // isConfigured=true is required for canProceedScan in the mirror command. await markSettingsConfigured(settingsFile); - const data = JSON.parse(await Deno.readTextFile(settingsFile)); + const data = JSON.parse(await readFile(settingsFile, "utf8")); data.writeDocumentsIfConflicted = true; - await Deno.writeTextFile(settingsFile, JSON.stringify(data, null, 2)); + await writeFile(settingsFile, JSON.stringify(data, null, 2)); // Copy settings to the DB directory (separated-path mode) const dbSettings = workDir.join("db", "settings.json"); - await Deno.copyFile(settingsFile, dbSettings); + await copyFile(settingsFile, dbSettings); /** Run mirror in separated-path mode: DB dir ≠ vault dir. */ const runMirror = () => runCliOrFail(dbDir, "--settings", dbSettings, "mirror", vaultDir); @@ -61,17 +64,17 @@ Deno.test("mirror: storage <-> DB synchronisation", async (t) => { // ------------------------------------------------------------------- // Case 1: storage-only -> DB (UPDATE DATABASE) // ------------------------------------------------------------------- - await t.step("case 1: storage-only file is synced into DB", async () => { + await t.test("case 1: storage-only file is synced into DB", async () => { const storageFile = workDir.join("vault", "test", "storage-only.md"); - await Deno.writeTextFile(storageFile, "storage-only content\n"); + await writeFile(storageFile, "storage-only content\n"); await runMirror(); const resultFile = workDir.join("case1-pull.txt"); await dbRun("pull", "test/storage-only.md", resultFile); - const storageContent = await Deno.readTextFile(storageFile); - const pulledContent = await Deno.readTextFile(resultFile); + const storageContent = await readFile(storageFile, "utf8"); + const pulledContent = await readFile(resultFile, "utf8"); assert( storageContent === pulledContent, `storage-only file NOT synced into DB\nexpected: ${storageContent}\ngot: ${pulledContent}` @@ -82,14 +85,14 @@ Deno.test("mirror: storage <-> DB synchronisation", async (t) => { // ------------------------------------------------------------------- // Case 2: DB-only -> storage (UPDATE STORAGE) // ------------------------------------------------------------------- - await t.step("case 2: DB-only file is restored to storage", async () => { + await t.test("case 2: DB-only file is restored to storage", async () => { await dbRun( "push", // write inline via push (pipe not needed — push takes a file path) // create a temp file with content and push it await (async () => { const tmp = workDir.join("db-only-src.txt"); - await Deno.writeTextFile(tmp, "db-only content\n"); + await writeFile(tmp, "db-only content\n"); return tmp; })(), "test/db-only.md" @@ -101,7 +104,7 @@ Deno.test("mirror: storage <-> DB synchronisation", async (t) => { await runMirror(); assert(await exists(storagePath), "DB-only file NOT restored to storage after mirror"); - const content = await Deno.readTextFile(storagePath); + const content = await readFile(storagePath, "utf8"); assert(content === "db-only content\n", `DB-only file restored but content mismatch: '${content}'`); console.log("[PASS] case 2: DB-only file was restored to storage"); }); @@ -109,9 +112,9 @@ Deno.test("mirror: storage <-> DB synchronisation", async (t) => { // ------------------------------------------------------------------- // Case 3: DB-deleted -> storage untouched // ------------------------------------------------------------------- - await t.step("case 3: DB-deleted entry is NOT restored to storage", async () => { + await t.test("case 3: DB-deleted entry is NOT restored to storage", async () => { const deletedSrc = workDir.join("deleted-src.txt"); - await Deno.writeTextFile(deletedSrc, "to-be-deleted\n"); + await writeFile(deletedSrc, "to-be-deleted\n"); await dbRun("push", deletedSrc, "test/deleted.md"); await dbRun("rm", "test/deleted.md"); @@ -125,29 +128,29 @@ Deno.test("mirror: storage <-> DB synchronisation", async (t) => { // ------------------------------------------------------------------- // Case 4: storage newer -> DB updated (SYNC: STORAGE -> DB) // ------------------------------------------------------------------- - await t.step("case 4: storage newer than DB -> DB is updated", async () => { + await t.test("case 4: storage newer than DB -> DB is updated", async () => { // Seed DB with old content (mtime ~ now) const seedFile = workDir.join("case4-seed.txt"); - await Deno.writeTextFile(seedFile, "old content\n"); + await writeFile(seedFile, "old content\n"); await dbRun("push", seedFile, "test/sync-storage-newer.md"); // Reflect the shared base into the actual Vault before editing it. await runMirror(); - assertEquals(await Deno.readTextFile(workDir.join("vault", "test", "sync-storage-newer.md")), "old content\n"); + assert.deepStrictEqual(await readFile(workDir.join("vault", "test", "sync-storage-newer.md"), "utf8"), "old content\n"); // Write new content to storage with a timestamp 1 hour in the future const storageFile = workDir.join("vault", "test", "sync-storage-newer.md"); - await Deno.writeTextFile(storageFile, "new content\n"); - await Deno.utime(storageFile, new Date(), new Date(Date.now() + 3600_000)); + await writeFile(storageFile, "new content\n"); + await utimes(storageFile, new Date(), new Date(Date.now() + 3600_000)); await runMirror(); const resultFile = workDir.join("case4-pull.txt"); const info = JSON.parse(await dbRun("info", "test/sync-storage-newer.md")); - assertEquals(info.conflicts, "N/A", "An ordinary local edit must not create a conflict"); + assert.deepStrictEqual(info.conflicts, "N/A", "An ordinary local edit must not create a conflict"); await dbRun("pull", "test/sync-storage-newer.md", resultFile); - const storageContent = await Deno.readTextFile(storageFile); - const pulledContent = await Deno.readTextFile(resultFile); + const storageContent = await readFile(storageFile, "utf8"); + const pulledContent = await readFile(resultFile, "utf8"); assert( storageContent === pulledContent, `DB NOT updated to match newer storage file\nexpected: ${storageContent}\ngot: ${pulledContent}` @@ -158,20 +161,20 @@ Deno.test("mirror: storage <-> DB synchronisation", async (t) => { // ------------------------------------------------------------------- // Case 5: DB newer -> storage updated (SYNC: DB -> STORAGE) // ------------------------------------------------------------------- - await t.step("case 5: DB newer than storage -> storage is updated", async () => { + await t.test("case 5: DB newer than storage -> storage is updated", async () => { // Write old content to storage with a timestamp 1 hour in the past const storageFile = workDir.join("vault", "test", "sync-db-newer.md"); - await Deno.writeTextFile(storageFile, "old storage content\n"); - await Deno.utime(storageFile, new Date(), new Date(Date.now() - 3600_000)); + await writeFile(storageFile, "old storage content\n"); + await utimes(storageFile, new Date(), new Date(Date.now() - 3600_000)); // Write new content to DB only (mtime ~ now, newer than the storage file) const dbNewFile = workDir.join("case5-db-new.txt"); - await Deno.writeTextFile(dbNewFile, "new db content\n"); + await writeFile(dbNewFile, "new db content\n"); await dbRun("push", dbNewFile, "test/sync-db-newer.md"); await runMirror(); - const content = await Deno.readTextFile(storageFile); + const content = await readFile(storageFile, "utf8"); assert(content === "new db content\n", `storage NOT updated to match newer DB entry (got: '${content}')`); console.log("[PASS] case 5: storage updated to match newer DB entry"); }); @@ -179,15 +182,15 @@ Deno.test("mirror: storage <-> DB synchronisation", async (t) => { // ------------------------------------------------------------------- // Case 6: compatibility mode (vault path = DB path) // ------------------------------------------------------------------- - await t.step("case 6: compatibility mode (omitted vault-path)", async () => { + await t.test("case 6: compatibility mode (omitted vault-path)", async () => { const compatFile = workDir.join("vault", "compat.md"); - await Deno.writeTextFile(compatFile, "compat-content\n"); + await writeFile(compatFile, "compat-content\n"); await runMirrorCompat(); const resultFile = workDir.join("case6-pull.txt"); await compatRun("pull", "compat.md", resultFile); - const pulled = await Deno.readTextFile(resultFile); + const pulled = await readFile(resultFile, "utf8"); assert(pulled === "compat-content\n", `Compatibility mode failed to sync file into DB (got: '${pulled}')`); console.log("[PASS] case 6: compatibility mode works"); }); @@ -196,13 +199,13 @@ Deno.test("mirror: storage <-> DB synchronisation", async (t) => { // Case 7: unknown local origin must preserve both contents regardless of mtime. // This deliberately uses put: push would record a file provenance entry. // ------------------------------------------------------------------- - await t.step("case 7: unknown local content is preserved, deduplicated, and resolved", async () => { + await t.test("case 7: unknown local content is preserved, deduplicated, and resolved", async () => { const path = "test/unknown-origin.md"; const storageFile = workDir.join("vault", "test", "unknown-origin.md"); await runCliWithInputOrFail("original DB content\n", dbDir, "--settings", dbSettings, "put", path); const writeUnknownFile = async () => { - await Deno.writeTextFile(storageFile, "unrelated local content\n"); - await Deno.utime(storageFile, new Date(), new Date(Date.now() + 3600_000)); + await writeFile(storageFile, "unrelated local content\n"); + await utimes(storageFile, new Date(), new Date(Date.now() + 3600_000)); }; await writeUnknownFile(); await runMirror(); @@ -215,22 +218,22 @@ Deno.test("mirror: storage <-> DB synchronisation", async (t) => { for (const revision of [info.revision, info.conflicts]) { contents.set(await dbRun("cat-rev", path, revision), revision); } - assertEquals([...contents.keys()].sort(), ["original DB content\n", "unrelated local content\n"]); + assert.deepStrictEqual([...contents.keys()].sort(), ["original DB content\n", "unrelated local content\n"]); // Re-submit identical local bytes even if incoming reflection replaced // the file under writeDocumentsIfConflicted; no third branch is needed. await writeUnknownFile(); await runMirror(); const repeated = JSON.parse(await dbRun("info", path)); - assertEquals(repeated.revision, info.revision); - assertEquals(repeated.conflicts, info.conflicts); + assert.deepStrictEqual(repeated.revision, info.revision); + assert.deepStrictEqual(repeated.conflicts, info.conflicts); const localRevision = contents.get("unrelated local content\n")!; await runCliOrFail(dbDir, "--vault", vaultDir, "--settings", dbSettings, "resolve", path, localRevision); const resolved = JSON.parse(await dbRun("info", path)); - assertEquals(resolved.conflicts, "N/A"); - assertEquals(resolved.revision, localRevision); - assertEquals(await Deno.readTextFile(storageFile), "unrelated local content\n"); + assert.deepStrictEqual(resolved.conflicts, "N/A"); + assert.deepStrictEqual(resolved.revision, localRevision); + assert.deepStrictEqual(await readFile(storageFile, "utf8"), "unrelated local content\n"); }); }); @@ -240,7 +243,7 @@ Deno.test("mirror: storage <-> DB synchronisation", async (t) => { async function exists(path: string): Promise { try { - await Deno.stat(path); + await stat(path); return true; } catch { return false; diff --git a/src/apps/cli/testdeno/test-p2p-automatic-host.ts b/test/e2e-cli/test-p2p-automatic-host.ts similarity index 90% rename from src/apps/cli/testdeno/test-p2p-automatic-host.ts rename to test/e2e-cli/test-p2p-automatic-host.ts index f9b9c2a0..54221796 100644 --- a/src/apps/cli/testdeno/test-p2p-automatic-host.ts +++ b/test/e2e-cli/test-p2p-automatic-host.ts @@ -1,5 +1,9 @@ -import { assert, assertEquals } from "@std/assert"; -import { join } from "@std/path"; +import { test } from "node:test"; +import process from "node:process"; +import assert from "node:assert/strict"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { spawnProcess, type ProcessStatus, type RunningProcess } from "./helpers/process.ts"; +import { join } from "node:path"; import { TempDir } from "./helpers/temp.ts"; import { applyP2pSettings, applyP2pTestTweaks, initSettingsFile } from "./helpers/settings.ts"; import { CLI_DIR } from "./helpers/cli.ts"; @@ -86,10 +90,13 @@ class AutomaticP2PHostProcess { #stdinWriter: WritableStreamDefaultWriter; #stdoutTask: Promise; #stderrTask: Promise; - #status: Promise; + #status: Promise; #stopped = false; - private constructor(readonly child: Deno.ChildProcess) { + readonly child: RunningProcess; + + private constructor(child: RunningProcess) { + this.child = child; this.#stdinWriter = child.stdin.getWriter(); this.#stdoutTask = this.#pump(child.stdout, (text) => this.#handleStdout(text)); this.#stderrTask = this.#pump(child.stderr, (text) => (this.#stderr += text)); @@ -119,14 +126,14 @@ class AutomaticP2PHostProcess { env[SEED_PATH_ENV] = seed.path; env[SEED_CONTENT_ENV] = seed.content; } - const child = new Deno.Command("node", { + const child = spawnProcess(process.execPath, { args: [join(CLI_DIR, "dist", "p2p-lifecycle-test.cjs"), vaultPath, "--settings", settingsPath, "p2p-host"], cwd: CLI_DIR, env, stdin: "piped", stdout: "piped", stderr: "piped", - }).spawn(); + }); const fixture = new AutomaticP2PHostProcess(child); try { await withTimeout(fixture.#ready, readyTimeoutMs, "waiting for the P2P automatic-host fixture to connect"); @@ -282,7 +289,7 @@ async function configureAutomaticSettings( turnServers ); await applyP2pTestTweaks(settingsPath, peerName, passphrase); - const settings = JSON.parse(await Deno.readTextFile(settingsPath)); + const settings = JSON.parse(await readFile(settingsPath, "utf8")); settings.P2P_AutoAccepting = 0; settings.P2P_AutoStart = false; settings.P2P_AutoBroadcast = true; @@ -290,21 +297,21 @@ async function configureAutomaticSettings( settings.P2P_AutoWatchPeers = autoWatchPeers; settings.P2P_AutoAcceptingPeers = autoAcceptingPeers; settings.P2P_AutoDenyingPeers = ""; - await Deno.writeTextFile(settingsPath, JSON.stringify(settings, null, 2)); + await writeFile(settingsPath, JSON.stringify(settings, null, 2)); } -Deno.test("p2p-host: restored automatic policy syncs clients through a persistent middle peer", async () => { - const relay = Deno.env.get("RELAY") ?? "ws://localhost:4000/"; - const peersTimeoutMs = Number(Deno.env.get("PEERS_TIMEOUT") ?? "20") * 1000; - const syncTimeoutMs = Number(Deno.env.get("SYNC_TIMEOUT") ?? "60") * 1000; +test("p2p-host: restored automatic policy syncs clients through a persistent middle peer", async () => { + const relay = process.env["RELAY"] ?? "ws://localhost:4000/"; + const peersTimeoutMs = Number(process.env["PEERS_TIMEOUT"] ?? "20") * 1000; + const syncTimeoutMs = Number(process.env["SYNC_TIMEOUT"] ?? "60") * 1000; const nonce = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`; const roomId = `automatic-host-room-${nonce}`; const passphrase = `automatic-host-pass-${nonce}`; const hostPeerName = `p2p-auto-host-${nonce}`; const clientAPeerName = `p2p-auto-client-a-${nonce}`; const clientBPeerName = `p2p-auto-client-b-${nonce}`; - const useCoturn = Deno.env.get("LIVESYNC_USE_COTURN") !== "0"; - const turnServers = Deno.env.get("TURN_SERVERS") ?? (useCoturn ? "turn:127.0.0.1:3478" : "none"); + const useCoturn = process.env["LIVESYNC_USE_COTURN"] !== "0"; + const turnServers = process.env["TURN_SERVERS"] ?? (useCoturn ? "turn:127.0.0.1:3478" : "none"); await using workDir = await TempDir.create("livesync-cli-p2p-automatic-host"); const hostVault = workDir.join("vault-host"); @@ -314,9 +321,9 @@ Deno.test("p2p-host: restored automatic policy syncs clients through a persisten const clientASettings = workDir.join("settings-client-a.json"); const clientBSettings = workDir.join("settings-client-b.json"); await Promise.all([ - Deno.mkdir(hostVault, { recursive: true }), - Deno.mkdir(clientAVault, { recursive: true }), - Deno.mkdir(clientBVault, { recursive: true }), + mkdir(hostVault, { recursive: true }), + mkdir(clientAVault, { recursive: true }), + mkdir(clientBVault, { recursive: true }), ]); const relayStarted = await maybeStartLocalRelay(relay); @@ -357,7 +364,7 @@ Deno.test("p2p-host: restored automatic policy syncs clients through a persisten host = await AutomaticP2PHostProcess.start(hostVault, hostSettings, HOST_BASELINE, 20000); const initialHostState = await host.state(); assert(initialHostState.connected, "p2p-host did not explicitly connect while P2P_AutoStart was false"); - assertEquals( + assert.deepStrictEqual( initialHostState.policy, { P2P_Enabled: true, @@ -382,7 +389,7 @@ Deno.test("p2p-host: restored automatic policy syncs clients through a persisten await host.write(HOST_UPDATE.path, HOST_UPDATE.content); await clientA.waitForContent(HOST_UPDATE, syncTimeoutMs); - assertEquals(await clientA.stop(), 0, "Client A did not stop cleanly before going offline"); + assert.deepStrictEqual(await clientA.stop(), 0, "Client A did not stop cleanly before going offline"); clientA = undefined; clientB = await AutomaticP2PHostProcess.start(clientBVault, clientBSettings, undefined, 20000); @@ -396,14 +403,14 @@ Deno.test("p2p-host: restored automatic policy syncs clients through a persisten await clientB.write(CLIENT_B_UPDATE.path, CLIENT_B_UPDATE.content); await host.waitForContent(CLIENT_B_UPDATE, syncTimeoutMs); - assertEquals(await host.stop(), 0, "The persistent host did not stop cleanly for its restart check"); + assert.deepStrictEqual(await host.stop(), 0, "The persistent host did not stop cleanly for its restart check"); host = undefined; await clientB.write(CLIENT_B_DURING_OUTAGE.path, CLIENT_B_DURING_OUTAGE.content); host = await AutomaticP2PHostProcess.start(hostVault, hostSettings, undefined, 20000); const restartedHostState = await host.state(); assert(restartedHostState.connected, "The restarted p2p-host did not connect"); - assertEquals( + assert.deepStrictEqual( restartedHostState.policy, initialHostState.policy, "The restarted host did not restore its original P2P policy" diff --git a/src/apps/cli/testdeno/test-p2p-host.ts b/test/e2e-cli/test-p2p-host.ts similarity index 67% rename from src/apps/cli/testdeno/test-p2p-host.ts rename to test/e2e-cli/test-p2p-host.ts index 7c67c5ff..abd494ab 100644 --- a/src/apps/cli/testdeno/test-p2p-host.ts +++ b/test/e2e-cli/test-p2p-host.ts @@ -1,20 +1,22 @@ -import { assert } from "@std/assert"; +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdir } from "node:fs/promises"; import { TempDir } from "./helpers/temp.ts"; import { initSettingsFile, applyP2pSettings } from "./helpers/settings.ts"; import { startP2pRelay, stopP2pRelay, isLocalP2pRelay } from "./helpers/docker.ts"; import { startCliInBackground } from "./helpers/backgroundCli.ts"; -Deno.test("p2p-host: starts and becomes ready", async () => { - const relay = Deno.env.get("RELAY") ?? "ws://localhost:4000/"; - const roomId = Deno.env.get("ROOM_ID") ?? `room-${Date.now()}`; - const passphrase = Deno.env.get("PASSPHRASE") ?? "test"; - const appId = Deno.env.get("APP_ID") ?? "self-hosted-livesync-cli-tests"; - const useInternalRelay = Deno.env.get("USE_INTERNAL_RELAY") !== "0"; +test("p2p-host: starts and becomes ready", async () => { + const relay = process.env["RELAY"] ?? "ws://localhost:4000/"; + const roomId = process.env["ROOM_ID"] ?? `room-${Date.now()}`; + const passphrase = process.env["PASSPHRASE"] ?? "test"; + const appId = process.env["APP_ID"] ?? "self-hosted-livesync-cli-tests"; + const useInternalRelay = process.env["USE_INTERNAL_RELAY"] !== "0"; await using workDir = await TempDir.create("livesync-cli-p2p-host"); const vaultDir = workDir.join("vault-host"); const settingsFile = workDir.join("settings-host.json"); - await Deno.mkdir(vaultDir, { recursive: true }); + await mkdir(vaultDir, { recursive: true }); let relayStarted = false; if (useInternalRelay && isLocalP2pRelay(relay)) { diff --git a/src/apps/cli/testdeno/test-p2p-peers-local-relay.ts b/test/e2e-cli/test-p2p-peers-local-relay.ts similarity index 72% rename from src/apps/cli/testdeno/test-p2p-peers-local-relay.ts rename to test/e2e-cli/test-p2p-peers-local-relay.ts index e8b162e0..5862c442 100644 --- a/src/apps/cli/testdeno/test-p2p-peers-local-relay.ts +++ b/test/e2e-cli/test-p2p-peers-local-relay.ts @@ -1,4 +1,6 @@ -import { assert } from "@std/assert"; +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdir } from "node:fs/promises"; import { TempDir } from "./helpers/temp.ts"; import { initSettingsFile, applyP2pSettings, applyP2pTestTweaks } from "./helpers/settings.ts"; import { startCliInBackground } from "./helpers/backgroundCli.ts"; @@ -11,27 +13,27 @@ import { } from "./helpers/p2p.ts"; import { getOptimalLoopbackIp } from "./helpers/net.ts"; -Deno.test("p2p-peers: discovers host through local relay", async () => { +test("p2p-peers: discovers host through local relay", async () => { const loopbackIp = await getOptimalLoopbackIp(); const loopbackHost = loopbackIp === "::1" ? "[::1]" : loopbackIp; - const relay = Deno.env.get("RELAY") ?? `ws://${loopbackHost}:4000/`; - const roomId = Deno.env.get("ROOM_ID") ?? `room-${Date.now()}`; - const passphrase = Deno.env.get("PASSPHRASE") ?? "test"; - const timeoutSeconds = Number(Deno.env.get("TIMEOUT_SECONDS") ?? "8"); + const relay = process.env["RELAY"] ?? `ws://${loopbackHost}:4000/`; + const roomId = process.env["ROOM_ID"] ?? `room-${Date.now()}`; + const passphrase = process.env["PASSPHRASE"] ?? "test"; + const timeoutSeconds = Number(process.env["TIMEOUT_SECONDS"] ?? "8"); const nonce = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`; - const hostPeerName = Deno.env.get("HOST_PEER_NAME") ?? `p2p-host-${nonce}`; - const clientPeerName = Deno.env.get("CLIENT_PEER_NAME") ?? `p2p-client-${nonce}`; - const useCoturn = Deno.env.get("LIVESYNC_USE_COTURN") !== "0"; - const turnServers = Deno.env.get("TURN_SERVERS") ?? (useCoturn ? `turn:${loopbackHost}:3478` : "none"); + const hostPeerName = process.env["HOST_PEER_NAME"] ?? `p2p-host-${nonce}`; + const clientPeerName = process.env["CLIENT_PEER_NAME"] ?? `p2p-client-${nonce}`; + const useCoturn = process.env["LIVESYNC_USE_COTURN"] !== "0"; + const turnServers = process.env["TURN_SERVERS"] ?? (useCoturn ? `turn:${loopbackHost}:3478` : "none"); await using workDir = await TempDir.create("livesync-cli-p2p-peers-local-relay"); const hostVault = workDir.join("vault-host"); const hostSettings = workDir.join("settings-host.json"); const clientVault = workDir.join("vault"); const clientSettings = workDir.join("settings.json"); - await Deno.mkdir(hostVault, { recursive: true }); - await Deno.mkdir(clientVault, { recursive: true }); + await mkdir(hostVault, { recursive: true }); + await mkdir(clientVault, { recursive: true }); const relayStarted = await maybeStartLocalRelay(relay); const coturnStarted = await maybeStartCoturn(turnServers); diff --git a/test/e2e-cli/test-p2p-relay-disconnect.ts b/test/e2e-cli/test-p2p-relay-disconnect.ts new file mode 100644 index 00000000..074aaec8 --- /dev/null +++ b/test/e2e-cli/test-p2p-relay-disconnect.ts @@ -0,0 +1,29 @@ +import { test } from "node:test"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { runProcess } from "./helpers/process.ts"; +import assert from "node:assert/strict"; + +test("p2p lifecycle: explicit disconnect closes and pauses relay WebSockets", async () => { + const result = await runProcess(process.execPath, { + args: [fileURLToPath(new URL("./relay-disconnect-probe.mjs", import.meta.url))], + env: { + RELAY: process.env["RELAY"] ?? "ws://nostr-relay:7777/", + RELAY_TIMEOUT_MS: process.env["LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS"] ?? "15000", + }, + stdout: "piped", + stderr: "piped", + }); + const stdout = new TextDecoder().decode(result.stdout).trim(); + const stderr = new TextDecoder().decode(result.stderr).trim(); + + assert(result.success, `Relay disconnect probe failed\nstdout: ${stdout}\nstderr: ${stderr}`); + const report = JSON.parse(stdout.split("\n").at(-1) ?? "{}") as { + socketsClosed?: number; + stayedDisconnectedWhilePaused?: boolean; + reconnectedAfterResume?: boolean; + }; + assert((report.socketsClosed ?? 0) > 0, "The probe did not observe an open relay WebSocket"); + assert.deepStrictEqual(report.stayedDisconnectedWhilePaused, true); + assert.deepStrictEqual(report.reconnectedAfterResume, true); +}); diff --git a/src/apps/cli/testdeno/test-p2p-replicator-replacement.ts b/test/e2e-cli/test-p2p-replicator-replacement.ts similarity index 74% rename from src/apps/cli/testdeno/test-p2p-replicator-replacement.ts rename to test/e2e-cli/test-p2p-replicator-replacement.ts index 231337e5..28cc483c 100644 --- a/src/apps/cli/testdeno/test-p2p-replicator-replacement.ts +++ b/test/e2e-cli/test-p2p-replicator-replacement.ts @@ -1,11 +1,19 @@ -import { assert, assertEquals, assertStringIncludes } from "@std/assert"; -import { join } from "@std/path"; +import { test } from "node:test"; +import process from "node:process"; +import assert from "node:assert/strict"; +import { mkdir } from "node:fs/promises"; +import { runProcess } from "./helpers/process.ts"; +import { join } from "node:path"; import { TempDir } from "./helpers/temp.ts"; import { initSettingsFile, applyP2pSettings, applyP2pTestTweaks } from "./helpers/settings.ts"; import { startCliInBackground } from "./helpers/backgroundCli.ts"; import { maybeStartLocalRelay, stopLocalRelayIfStarted, maybeStartCoturn, stopCoturnIfStarted } from "./helpers/p2p.ts"; import { CLI_DIR, runCli, sanitiseCatStdout } from "./helpers/cli.ts"; +function assertStringIncludes(actual: string, expected: string, message?: string): void { + assert.ok(actual.includes(expected), message ?? `Expected ${JSON.stringify(actual)} to include ${JSON.stringify(expected)}`); +} + const NOTE_PATH = "p2p-replicator-replacement.md"; const NOTE_CONTENT = "Replicated after replacing the active P2P replicator."; @@ -15,7 +23,7 @@ async function runReplacementProbe( targetPeer: string, timeoutMs: number ): Promise<{ code: number; stdout: string; stderr: string }> { - const command = new Deno.Command("node", { + const result = await runProcess(process.execPath, { args: [ join(CLI_DIR, "dist", "p2p-lifecycle-test.cjs"), vaultPath, @@ -31,7 +39,6 @@ async function runReplacementProbe( stdout: "piped", stderr: "piped", }); - const result = await command.output(); return { code: result.code, stdout: new TextDecoder().decode(result.stdout), @@ -39,20 +46,20 @@ async function runReplacementProbe( }; } -Deno.test("p2p lifecycle: active-adapter replacement keeps real CLI communication on the stable service", async () => { - const relay = Deno.env.get("RELAY") ?? "ws://localhost:4000/"; - const peersTimeout = Number(Deno.env.get("PEERS_TIMEOUT") ?? "20"); - const syncTimeout = Number(Deno.env.get("SYNC_TIMEOUT") ?? "60"); +test("p2p lifecycle: active-adapter replacement keeps real CLI communication on the stable service", async () => { + const relay = process.env["RELAY"] ?? "ws://localhost:4000/"; + const peersTimeout = Number(process.env["PEERS_TIMEOUT"] ?? "20"); + const syncTimeout = Number(process.env["SYNC_TIMEOUT"] ?? "60"); const probeTimeoutMs = Math.max(peersTimeout, syncTimeout) * 1000; const nonce = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`; - const roomId = Deno.env.get("ROOM_ID") ?? `replacement-room-${nonce}`; - const passphrase = Deno.env.get("PASSPHRASE") ?? `replacement-pass-${nonce}`; + const roomId = process.env["ROOM_ID"] ?? `replacement-room-${nonce}`; + const passphrase = process.env["PASSPHRASE"] ?? `replacement-pass-${nonce}`; const appId = "self-hosted-livesync-cli-replacement-test"; const hostPeerName = `p2p-replacement-host-${nonce}`; const probePeerName = `p2p-replacement-probe-${nonce}`; const verifierPeerName = `p2p-replacement-verifier-${nonce}`; - const useCoturn = Deno.env.get("LIVESYNC_USE_COTURN") !== "0"; - const turnServers = Deno.env.get("TURN_SERVERS") ?? (useCoturn ? "turn:127.0.0.1:3478" : "none"); + const useCoturn = process.env["LIVESYNC_USE_COTURN"] !== "0"; + const turnServers = process.env["TURN_SERVERS"] ?? (useCoturn ? "turn:127.0.0.1:3478" : "none"); await using workDir = await TempDir.create("livesync-cli-p2p-replacement"); const hostVault = workDir.join("vault-host"); @@ -62,9 +69,9 @@ Deno.test("p2p lifecycle: active-adapter replacement keeps real CLI communicatio const probeSettings = workDir.join("settings-probe.json"); const verifierSettings = workDir.join("settings-verifier.json"); await Promise.all([ - Deno.mkdir(hostVault, { recursive: true }), - Deno.mkdir(probeVault, { recursive: true }), - Deno.mkdir(verifierVault, { recursive: true }), + mkdir(hostVault, { recursive: true }), + mkdir(probeVault, { recursive: true }), + mkdir(verifierVault, { recursive: true }), ]); const relayStarted = await maybeStartLocalRelay(relay); @@ -103,7 +110,7 @@ Deno.test("p2p lifecycle: active-adapter replacement keeps real CLI communicatio catResult.code === 0, `Verifier could not read ${NOTE_PATH}\nstdout: ${catResult.stdout}\nstderr: ${catResult.stderr}` ); - assertEquals(sanitiseCatStdout(catResult.stdout).trim(), NOTE_CONTENT); + assert.deepStrictEqual(sanitiseCatStdout(catResult.stdout).trim(), NOTE_CONTENT); } finally { await host.stop(); } diff --git a/src/apps/cli/testdeno/test-p2p-sync.ts b/test/e2e-cli/test-p2p-sync.ts similarity index 82% rename from src/apps/cli/testdeno/test-p2p-sync.ts rename to test/e2e-cli/test-p2p-sync.ts index 32f3d1ad..0392ef6f 100644 --- a/src/apps/cli/testdeno/test-p2p-sync.ts +++ b/test/e2e-cli/test-p2p-sync.ts @@ -1,4 +1,6 @@ -import { assert } from "@std/assert"; +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import { TempDir } from "./helpers/temp.ts"; import { initSettingsFile, @@ -17,20 +19,20 @@ import { import { runCli, runCliOrFail, runCliWithInputOrFail, sanitiseCatStdout } from "./helpers/cli.ts"; import { getOptimalLoopbackIp } from "./helpers/net.ts"; -Deno.test("p2p-sync: transfers with the same ID key and rejects a different document ID key", async () => { +test("p2p-sync: transfers with the same ID key and rejects a different document ID key", async () => { const loopbackIp = await getOptimalLoopbackIp(); const loopbackHost = loopbackIp === "::1" ? "[::1]" : loopbackIp; - const relay = Deno.env.get("RELAY") ?? `ws://${loopbackHost}:4000/`; - const roomId = Deno.env.get("ROOM_ID") ?? `room-${Date.now()}`; - const passphrase = Deno.env.get("PASSPHRASE") ?? "test"; - const peersTimeout = Number(Deno.env.get("PEERS_TIMEOUT") ?? "12"); - const syncTimeout = Number(Deno.env.get("SYNC_TIMEOUT") ?? "15"); + const relay = process.env["RELAY"] ?? `ws://${loopbackHost}:4000/`; + const roomId = process.env["ROOM_ID"] ?? `room-${Date.now()}`; + const passphrase = process.env["PASSPHRASE"] ?? "test"; + const peersTimeout = Number(process.env["PEERS_TIMEOUT"] ?? "12"); + const syncTimeout = Number(process.env["SYNC_TIMEOUT"] ?? "15"); const nonce = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`; - const hostPeerName = Deno.env.get("HOST_PEER_NAME") ?? `p2p-host-${nonce}`; - const clientPeerName = Deno.env.get("CLIENT_PEER_NAME") ?? `p2p-client-${nonce}`; - const useCoturn = Deno.env.get("LIVESYNC_USE_COTURN") !== "0"; - const turnServers = Deno.env.get("TURN_SERVERS") ?? (useCoturn ? `turn:${loopbackHost}:3478` : "none"); + const hostPeerName = process.env["HOST_PEER_NAME"] ?? `p2p-host-${nonce}`; + const clientPeerName = process.env["CLIENT_PEER_NAME"] ?? `p2p-client-${nonce}`; + const useCoturn = process.env["LIVESYNC_USE_COTURN"] !== "0"; + const turnServers = process.env["TURN_SERVERS"] ?? (useCoturn ? `turn:${loopbackHost}:3478` : "none"); await using workDir = await TempDir.create("livesync-cli-p2p-sync"); const hostVault = workDir.join("vault-host"); @@ -39,9 +41,9 @@ Deno.test("p2p-sync: transfers with the same ID key and rejects a different docu const clientSettings = workDir.join("settings-sync.json"); const rejectedVault = workDir.join("vault-rejected"); const rejectedSettings = workDir.join("settings-rejected.json"); - await Deno.mkdir(hostVault, { recursive: true }); - await Deno.mkdir(clientVault, { recursive: true }); - await Deno.mkdir(rejectedVault, { recursive: true }); + await mkdir(hostVault, { recursive: true }); + await mkdir(clientVault, { recursive: true }); + await mkdir(rejectedVault, { recursive: true }); const relayStarted = await maybeStartLocalRelay(relay); const coturnStarted = await maybeStartCoturn(turnServers); @@ -84,15 +86,15 @@ Deno.test("p2p-sync: transfers with the same ID key and rejects a different docu [clientVault, clientSettings, "ab".repeat(32), "client"], [rejectedVault, rejectedSettings, "cd".repeat(32), "rejected"], ]) { - const settings = JSON.parse(await Deno.readTextFile(path)); + const settings = JSON.parse(await readFile(path, "utf8")); settings.idDerivationVersion = 1; settings.idDerivationKey = key; const sourcePath = workDir.join("setup-source-" + label + ".json"); - await Deno.writeTextFile(sourcePath, JSON.stringify(settings)); + await writeFile(sourcePath, JSON.stringify(settings)); const setupPassphrase = "independent-id-setup-passphrase"; const setupUri = await generateSetupUriFromSettings(sourcePath, setupPassphrase, true); await runCliWithInputOrFail(setupPassphrase + "\n", vault, "--settings", path, "setup", setupUri); - const persisted = JSON.parse(await Deno.readTextFile(path)); + const persisted = JSON.parse(await readFile(path, "utf8")); assert(persisted.idDerivationVersion === 1, "The Setup URI lost the ID derivation version."); assert(persisted.idDerivationKey === "", "The CLI stored the ID key in plain text."); assert( @@ -121,7 +123,7 @@ Deno.test("p2p-sync: transfers with the same ID key and rejects a different docu clientVault, clientSettings, peersTimeout, - Deno.env.get("TARGET_PEER") ?? hostPeerName + process.env["TARGET_PEER"] ?? hostPeerName ); const syncResult = await runCli( clientVault, diff --git a/src/apps/cli/testdeno/test-p2p-three-nodes-conflict.ts b/test/e2e-cli/test-p2p-three-nodes-conflict.ts similarity index 86% rename from src/apps/cli/testdeno/test-p2p-three-nodes-conflict.ts rename to test/e2e-cli/test-p2p-three-nodes-conflict.ts index 45eab2c6..21ce9097 100644 --- a/src/apps/cli/testdeno/test-p2p-three-nodes-conflict.ts +++ b/test/e2e-cli/test-p2p-three-nodes-conflict.ts @@ -1,4 +1,6 @@ -import { assert } from "@std/assert"; +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdir } from "node:fs/promises"; import { TempDir } from "./helpers/temp.ts"; import { applyP2pSettings, applyP2pTestTweaks, initSettingsFile } from "./helpers/settings.ts"; import { startCliInBackground } from "./helpers/backgroundCli.ts"; @@ -12,22 +14,22 @@ import { import { jsonStringField, runCliOrFail, runCliWithInputOrFail, sanitiseCatStdout } from "./helpers/cli.ts"; import { getOptimalLoopbackIp } from "./helpers/net.ts"; -Deno.test("p2p: three nodes detect and resolve conflicts", async () => { +test("p2p: three nodes detect and resolve conflicts", async () => { const loopbackIp = await getOptimalLoopbackIp(); const loopbackHost = loopbackIp === "::1" ? "[::1]" : loopbackIp; - const relay = Deno.env.get("RELAY") ?? `ws://${loopbackHost}:4000/`; - const roomId = Deno.env.get("ROOM_ID") ?? `room-${Date.now()}`; - const passphrase = Deno.env.get("PASSPHRASE") ?? "test"; + const relay = process.env["RELAY"] ?? `ws://${loopbackHost}:4000/`; + const roomId = process.env["ROOM_ID"] ?? `room-${Date.now()}`; + const passphrase = process.env["PASSPHRASE"] ?? "test"; const appId = "self-hosted-livesync-cli-tests"; - const peersTimeout = Number(Deno.env.get("PEERS_TIMEOUT") ?? "10"); - const syncTimeout = Number(Deno.env.get("SYNC_TIMEOUT") ?? "15"); + const peersTimeout = Number(process.env["PEERS_TIMEOUT"] ?? "10"); + const syncTimeout = Number(process.env["SYNC_TIMEOUT"] ?? "15"); const nonce = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`; - const hostPeerName = Deno.env.get("HOST_PEER_NAME") ?? `p2p-host-${nonce}`; - const peerNameB = Deno.env.get("PEER_NAME_B") ?? `p2p-client-b-${nonce}`; - const peerNameC = Deno.env.get("PEER_NAME_C") ?? `p2p-client-c-${nonce}`; - const useCoturn = Deno.env.get("LIVESYNC_USE_COTURN") !== "0"; - const turnServers = Deno.env.get("TURN_SERVERS") ?? (useCoturn ? `turn:${loopbackHost}:3478` : "none"); + const hostPeerName = process.env["HOST_PEER_NAME"] ?? `p2p-host-${nonce}`; + const peerNameB = process.env["PEER_NAME_B"] ?? `p2p-client-b-${nonce}`; + const peerNameC = process.env["PEER_NAME_C"] ?? `p2p-client-c-${nonce}`; + const useCoturn = process.env["LIVESYNC_USE_COTURN"] !== "0"; + const turnServers = process.env["TURN_SERVERS"] ?? (useCoturn ? `turn:${loopbackHost}:3478` : "none"); await using workDir = await TempDir.create("livesync-cli-p2p-3nodes"); const vaultA = workDir.join("vault-a"); @@ -36,9 +38,9 @@ Deno.test("p2p: three nodes detect and resolve conflicts", async () => { const settingsA = workDir.join("settings-a.json"); const settingsB = workDir.join("settings-b.json"); const settingsC = workDir.join("settings-c.json"); - await Deno.mkdir(vaultA, { recursive: true }); - await Deno.mkdir(vaultB, { recursive: true }); - await Deno.mkdir(vaultC, { recursive: true }); + await mkdir(vaultA, { recursive: true }); + await mkdir(vaultB, { recursive: true }); + await mkdir(vaultC, { recursive: true }); const relayStarted = await maybeStartLocalRelay(relay); const coturnStarted = await maybeStartCoturn(turnServers); diff --git a/src/apps/cli/testdeno/test-p2p-upload-download-repro.ts b/test/e2e-cli/test-p2p-upload-download-repro.ts similarity index 88% rename from src/apps/cli/testdeno/test-p2p-upload-download-repro.ts rename to test/e2e-cli/test-p2p-upload-download-repro.ts index ba07fd5c..f9b754e4 100644 --- a/src/apps/cli/testdeno/test-p2p-upload-download-repro.ts +++ b/test/e2e-cli/test-p2p-upload-download-repro.ts @@ -1,3 +1,5 @@ +import { test } from "node:test"; +import { mkdir, writeFile } from "node:fs/promises"; import { TempDir } from "./helpers/temp.ts"; import { applyP2pSettings, applyP2pTestTweaks, initSettingsFile } from "./helpers/settings.ts"; import { startCliInBackground } from "./helpers/backgroundCli.ts"; @@ -7,14 +9,14 @@ import { assertFilesEqual, runCliOrFail } from "./helpers/cli.ts"; async function writeFilledFile(path: string, size: number, byte: number): Promise { const data = new Uint8Array(size); data.fill(byte); - await Deno.writeFile(path, data); + await writeFile(path, data); } -Deno.test("p2p: upload/download reproduction scenario", async () => { - const relay = Deno.env.get("RELAY") ?? "ws://localhost:4000/"; - const appId = Deno.env.get("APP_ID") ?? "self-hosted-livesync-cli-tests"; - const peersTimeout = Number(Deno.env.get("PEERS_TIMEOUT") ?? "20"); - const syncTimeout = Number(Deno.env.get("SYNC_TIMEOUT") ?? "240"); +test("p2p: upload/download reproduction scenario", async () => { + const relay = process.env["RELAY"] ?? "ws://localhost:4000/"; + const appId = process.env["APP_ID"] ?? "self-hosted-livesync-cli-tests"; + const peersTimeout = Number(process.env["PEERS_TIMEOUT"] ?? "20"); + const syncTimeout = Number(process.env["SYNC_TIMEOUT"] ?? "240"); const roomId = `p2p-room-${Date.now()}`; const passphrase = `p2p-pass-${Date.now()}`; @@ -26,7 +28,7 @@ Deno.test("p2p: upload/download reproduction scenario", async () => { const settingsUp = workDir.join("settings-up.json"); const settingsDown = workDir.join("settings-down.json"); for (const dir of [vaultHost, vaultUp, vaultDown]) { - await Deno.mkdir(dir, { recursive: true }); + await mkdir(dir, { recursive: true }); } const relayStarted = await maybeStartLocalRelay(relay); @@ -48,10 +50,10 @@ Deno.test("p2p: upload/download reproduction scenario", async () => { const diffA = workDir.join("test-diff-1.md"); const diffB = workDir.join("test-diff-2.md"); const diffC = workDir.join("test-diff-3.md"); - await Deno.writeTextFile(storeText, "Hello, World!\n"); - await Deno.writeTextFile(diffA, "Content A\n"); - await Deno.writeTextFile(diffB, "Content B\n"); - await Deno.writeTextFile(diffC, "Content C\n"); + await writeFile(storeText, "Hello, World!\n"); + await writeFile(diffA, "Content A\n"); + await writeFile(diffB, "Content B\n"); + await writeFile(diffC, "Content C\n"); await runCliOrFail(vaultUp, "--settings", settingsUp, "push", storeText, "p2p/store-file.md"); await runCliOrFail(vaultUp, "--settings", settingsUp, "push", diffA, "p2p/test-diff-1.md"); await runCliOrFail(vaultUp, "--settings", settingsUp, "push", diffB, "p2p/test-diff-2.md"); @@ -61,8 +63,8 @@ Deno.test("p2p: upload/download reproduction scenario", async () => { const large1m = workDir.join("large-1m.txt"); const binary100k = workDir.join("binary-100k.bin"); const binary5m = workDir.join("binary-5m.bin"); - await Deno.writeTextFile(large100k, "a".repeat(100000)); - await Deno.writeTextFile(large1m, "b".repeat(1000000)); + await writeFile(large100k, "a".repeat(100000)); + await writeFile(large1m, "b".repeat(1000000)); await writeFilledFile(binary100k, 100000, 0x5a); await writeFilledFile(binary5m, 5000000, 0x7c); await runCliOrFail(vaultUp, "--settings", settingsUp, "push", large100k, "p2p/large-100000.md"); diff --git a/src/apps/cli/testdeno/test-push-pull.ts b/test/e2e-cli/test-push-pull.ts similarity index 65% rename from src/apps/cli/testdeno/test-push-pull.ts rename to test/e2e-cli/test-push-pull.ts index b5dd61be..3cabe004 100644 --- a/src/apps/cli/testdeno/test-push-pull.ts +++ b/test/e2e-cli/test-push-pull.ts @@ -1,44 +1,47 @@ /** - * Deno port of test-push-pull-linux.sh + * Node.js port of test-push-pull-linux.sh * * Requires CouchDB connection details either via environment variables or a * .test.env file. If neither is present the test logs a warning and the * CLI will likely fail at the push step. * * Run: - * deno test -A test-push-pull.ts + * node --test test-push-pull.ts * * With explicit CouchDB: * COUCHDB_URI=http://127.0.0.1:5984 \ * COUCHDB_USER=admin \ * COUCHDB_PASSWORD=password \ * COUCHDB_DBNAME=livesync-test \ - * deno test -A test-push-pull.ts + * node --test test-push-pull.ts */ -import { join } from "@std/path"; -import { assertEquals } from "@std/assert"; +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; + +import { join } from "node:path"; import { TempDir } from "./helpers/temp.ts"; import { runCliOrFail } from "./helpers/cli.ts"; import { applyCouchdbSettings, initSettingsFile } from "./helpers/settings.ts"; import { startCouchdb, stopCouchdb } from "./helpers/docker.ts"; -const REMOTE_PATH = Deno.env.get("REMOTE_PATH") ?? "test/push-pull.txt"; +const REMOTE_PATH = process.env["REMOTE_PATH"] ?? "test/push-pull.txt"; -Deno.test("push/pull roundtrip", async () => { +test("push/pull roundtrip", async () => { await using workDir = await TempDir.create("livesync-cli-push-pull"); const settingsFile = workDir.join("data.json"); const vaultDir = workDir.join("vault"); - await Deno.mkdir(join(vaultDir, "test"), { recursive: true }); + await mkdir(join(vaultDir, "test"), { recursive: true }); - const uri = Deno.env.get("COUCHDB_URI") ?? "http://127.0.0.1:5989/"; - const user = Deno.env.get("COUCHDB_USER") ?? "admin"; - const password = Deno.env.get("COUCHDB_PASSWORD") ?? "testpassword"; - const dbname = Deno.env.get("COUCHDB_DBNAME") ?? `push-pull-${Date.now()}`; + const uri = process.env["COUCHDB_URI"] ?? "http://127.0.0.1:5989/"; + const user = process.env["COUCHDB_USER"] ?? "admin"; + const password = process.env["COUCHDB_PASSWORD"] ?? "testpassword"; + const dbname = process.env["COUCHDB_DBNAME"] ?? `push-pull-${Date.now()}`; - const shouldStartDocker = Deno.env.get("LIVESYNC_START_DOCKER") !== "0"; - const keepDocker = Deno.env.get("LIVESYNC_DEBUG_KEEP_DOCKER") === "1"; + const shouldStartDocker = process.env["LIVESYNC_START_DOCKER"] !== "0"; + const keepDocker = process.env["LIVESYNC_DEBUG_KEEP_DOCKER"] === "1"; if (shouldStartDocker) { await startCouchdb(uri, user, password, dbname); @@ -59,7 +62,7 @@ Deno.test("push/pull roundtrip", async () => { const srcFile = workDir.join("push-source.txt"); const pulledFile = workDir.join("pull-result.txt"); const content = `push-pull-test ${new Date().toISOString()}\n`; - await Deno.writeTextFile(srcFile, content); + await writeFile(srcFile, content); console.log(`[INFO] push -> ${REMOTE_PATH}`); await runCliOrFail(vaultDir, "--settings", settingsFile, "push", srcFile, REMOTE_PATH); @@ -67,8 +70,8 @@ Deno.test("push/pull roundtrip", async () => { console.log(`[INFO] pull <- ${REMOTE_PATH}`); await runCliOrFail(vaultDir, "--settings", settingsFile, "pull", REMOTE_PATH, pulledFile); - const pulled = await Deno.readTextFile(pulledFile); - assertEquals(content, pulled, "push/pull roundtrip content mismatch"); + const pulled = await readFile(pulledFile, "utf8"); + assert.deepStrictEqual(content, pulled, "push/pull roundtrip content mismatch"); console.log("[PASS] push/pull roundtrip matched"); } finally { if (shouldStartDocker && !keepDocker) { diff --git a/src/apps/cli/testdeno/test-remote-administration-exit-codes.ts b/test/e2e-cli/test-remote-administration-exit-codes.ts similarity index 74% rename from src/apps/cli/testdeno/test-remote-administration-exit-codes.ts rename to test/e2e-cli/test-remote-administration-exit-codes.ts index ccb20e6b..75f332f7 100644 --- a/src/apps/cli/testdeno/test-remote-administration-exit-codes.ts +++ b/test/e2e-cli/test-remote-administration-exit-codes.ts @@ -1,18 +1,24 @@ -import { assertEquals, assertStringIncludes } from "@std/assert"; +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdir } from "node:fs/promises"; import { TempDir } from "./helpers/temp.ts"; import { runCli } from "./helpers/cli.ts"; import { applyCouchdbSettings, applyP2pSettings, applyP2pTestTweaks, initSettingsFile } from "./helpers/settings.ts"; +function assertStringIncludes(actual: string, expected: string, message?: string): void { + assert.ok(actual.includes(expected), message ?? `Expected ${JSON.stringify(actual)} to include ${JSON.stringify(expected)}`); +} + async function prepareFixture(prefix: string) { const workDir = await TempDir.create(prefix); const settingsFile = workDir.join("settings.json"); const databaseDir = workDir.join("database"); - await Deno.mkdir(databaseDir, { recursive: true }); + await mkdir(databaseDir, { recursive: true }); await initSettingsFile(settingsFile); return { workDir, settingsFile, databaseDir }; } -Deno.test("remote administration process exit policy distinguishes returned verification failure", async () => { +test("remote administration process exit policy distinguishes returned verification failure", async () => { const fixture = await prepareFixture("livesync-cli-remote-admin-exit"); await using workDir = fixture.workDir; const { settingsFile, databaseDir } = fixture; @@ -29,7 +35,7 @@ Deno.test("remote administration process exit policy distinguishes returned veri await applyP2pTestTweaks(settingsFile, "remote-admin-exit-device", "remote-admin-exit-passphrase"); const defaultFailure = await runCli(databaseDir, "--settings", settingsFile, "mark-resolved"); - assertEquals(defaultFailure.code, 1, defaultFailure.combined); + assert.deepStrictEqual(defaultFailure.code, 1, defaultFailure.combined); assertStringIncludes( defaultFailure.combined, "[Verification] Remote administration is unavailable for this provider." @@ -43,7 +49,7 @@ Deno.test("remote administration process exit policy distinguishes returned veri "--compat-remote-admin-exit-zero", "mark-resolved" ); - assertEquals(compatibilitySuccess.code, 0, compatibilitySuccess.combined); + assert.deepStrictEqual(compatibilitySuccess.code, 0, compatibilitySuccess.combined); assertStringIncludes( compatibilitySuccess.combined, "[Verification] Remote administration is unavailable for this provider." @@ -51,7 +57,7 @@ Deno.test("remote administration process exit policy distinguishes returned veri assertStringIncludes(compatibilitySuccess.combined, "[Done] Command 'mark-resolved' completed"); }); -Deno.test("remote administration compatibility does not hide a thrown mutation failure", async () => { +test("remote administration compatibility does not hide a thrown mutation failure", async () => { const fixture = await prepareFixture("livesync-cli-remote-admin-mutation"); await using workDir = fixture.workDir; const { settingsFile, databaseDir } = fixture; @@ -71,7 +77,7 @@ Deno.test("remote administration compatibility does not hide a thrown mutation f "--compat-remote-admin-exit-zero", "mark-resolved" ); - assertEquals(mutationFailure.code, 1, mutationFailure.combined); + assert.deepStrictEqual(mutationFailure.code, 1, mutationFailure.combined); assertStringIncludes(mutationFailure.combined, "[Command] mark-resolved"); assertStringIncludes(mutationFailure.combined, "[Error] Failed to start:"); }); diff --git a/src/apps/cli/testdeno/test-remote-commands.ts b/test/e2e-cli/test-remote-commands.ts similarity index 87% rename from src/apps/cli/testdeno/test-remote-commands.ts rename to test/e2e-cli/test-remote-commands.ts index 6040ab11..64678470 100644 --- a/src/apps/cli/testdeno/test-remote-commands.ts +++ b/test/e2e-cli/test-remote-commands.ts @@ -1,5 +1,5 @@ /** - * Deno port of test-remote-commands-linux.sh + * Node.js port of test-remote-commands-linux.sh * * Tests remote management commands: remote-status, lock-remote, unlock-remote, * and mark-resolved. @@ -14,10 +14,13 @@ * current device is accepted. * * Run: - * deno test -A test-remote-commands.ts + * node --test test-remote-commands.ts */ -import { join } from "@std/path"; +import { test } from "node:test"; +import { mkdir } from "node:fs/promises"; + +import { join } from "node:path"; import { TempDir } from "./helpers/temp.ts"; import { runCli, assertContains } from "./helpers/cli.ts"; import { applyCouchdbSettings, initSettingsFile } from "./helpers/settings.ts"; @@ -31,21 +34,21 @@ async function runCliCombinedOrFail(...args: string[]): Promise { return res.combined; } -Deno.test("remote management commands", async () => { +test("remote management commands", async () => { await using workDir = await TempDir.create("livesync-cli-remote-cmds"); const settingsFile = workDir.join("settings.json"); const vaultDir = workDir.join("vault"); - await Deno.mkdir(vaultDir, { recursive: true }); + await mkdir(vaultDir, { recursive: true }); - const uri = Deno.env.get("COUCHDB_URI") ?? "http://127.0.0.1:5989/"; - const user = Deno.env.get("COUCHDB_USER") ?? "admin"; - const password = Deno.env.get("COUCHDB_PASSWORD") ?? "testpassword"; + const uri = process.env["COUCHDB_URI"] ?? "http://127.0.0.1:5989/"; + const user = process.env["COUCHDB_USER"] ?? "admin"; + const password = process.env["COUCHDB_PASSWORD"] ?? "testpassword"; const dbSuffix = `${Date.now()}-${Math.floor(Math.random() * 10000)}`; - const dbname = Deno.env.get("COUCHDB_DBNAME") ?? `remotes-${dbSuffix}`; + const dbname = process.env["COUCHDB_DBNAME"] ?? `remotes-${dbSuffix}`; - const shouldStartDocker = Deno.env.get("LIVESYNC_START_DOCKER") !== "0"; - const keepDocker = Deno.env.get("LIVESYNC_DEBUG_KEEP_DOCKER") === "1"; + const shouldStartDocker = process.env["LIVESYNC_START_DOCKER"] !== "0"; + const keepDocker = process.env["LIVESYNC_DEBUG_KEEP_DOCKER"] === "1"; if (shouldStartDocker) { await startCouchdb(uri, user, password, dbname); diff --git a/src/apps/cli/testdeno/test-settings-writeback.ts b/test/e2e-cli/test-settings-writeback.ts similarity index 54% rename from src/apps/cli/testdeno/test-settings-writeback.ts rename to test/e2e-cli/test-settings-writeback.ts index 4e3c4adf..ff37eca9 100644 --- a/src/apps/cli/testdeno/test-settings-writeback.ts +++ b/test/e2e-cli/test-settings-writeback.ts @@ -1,4 +1,6 @@ -import { assert, assertEquals } from "@std/assert"; +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import { TempDir } from "./helpers/temp.ts"; import { runCli } from "./helpers/cli.ts"; import { initSettingsFile } from "./helpers/settings.ts"; @@ -7,24 +9,24 @@ async function prepareSettingsFixture(prefix: string) { const workDir = await TempDir.create(prefix); const settingsFile = workDir.join("settings.json"); const databaseDir = workDir.join("database"); - await Deno.mkdir(databaseDir, { recursive: true }); + await mkdir(databaseDir, { recursive: true }); await initSettingsFile(settingsFile); return { workDir, settingsFile, databaseDir }; } -Deno.test("settings-changing commands persist durable settings without CLI runtime suspension", async () => { +test("settings-changing commands persist durable settings without CLI runtime suspension", async () => { const fixture = await prepareSettingsFixture("livesync-cli-settings-command"); await using workDir = fixture.workDir; const { settingsFile, databaseDir } = fixture; - const settings = JSON.parse(await Deno.readTextFile(settingsFile)); + const settings = JSON.parse(await readFile(settingsFile, "utf8")); settings.liveSync = true; settings.syncOnStart = true; settings.periodicReplication = true; settings.P2P_Enabled = true; settings.P2P_AutoStart = true; settings.P2P_AutoBroadcast = true; - await Deno.writeTextFile(settingsFile, JSON.stringify(settings, null, 2)); + await writeFile(settingsFile, JSON.stringify(settings, null, 2)); const result = await runCli( databaseDir, @@ -34,22 +36,22 @@ Deno.test("settings-changing commands persist durable settings without CLI runti "test-remote", "sls+https://user:pass@example.com/database" ); - assertEquals(result.code, 0, result.combined); + assert.deepStrictEqual(result.code, 0, result.combined); const firstRemoteId = result.stdout.trim().split("\t")[0]; assert(firstRemoteId, `remote-add did not return an ID: ${result.combined}`); - let persisted = JSON.parse(await Deno.readTextFile(settingsFile)); - let remotes = Object.values(persisted.remoteConfigurations ?? {}) as Array<{ name?: string }>; + let persisted = JSON.parse(await readFile(settingsFile, "utf8")); + const remotesByName = Object.values(persisted.remoteConfigurations ?? {}) as Array<{ name?: string }>; assert( - remotes.some((remote) => remote.name === "test-remote"), + remotesByName.some((remote) => remote.name === "test-remote"), "remote-add did not persist the new profile" ); - assertEquals(persisted.liveSync, true); - assertEquals(persisted.syncOnStart, true); - assertEquals(persisted.periodicReplication, true); - assertEquals(persisted.P2P_Enabled, true); - assertEquals(persisted.P2P_AutoStart, true); - assertEquals(persisted.P2P_AutoBroadcast, true); + assert.deepStrictEqual(persisted.liveSync, true); + assert.deepStrictEqual(persisted.syncOnStart, true); + assert.deepStrictEqual(persisted.periodicReplication, true); + assert.deepStrictEqual(persisted.P2P_Enabled, true); + assert.deepStrictEqual(persisted.P2P_AutoStart, true); + assert.deepStrictEqual(persisted.P2P_AutoBroadcast, true); const secondAdd = await runCli( databaseDir, @@ -59,14 +61,14 @@ Deno.test("settings-changing commands persist durable settings without CLI runti "second-remote", "sls+https://other:secret@example.net/second" ); - assertEquals(secondAdd.code, 0, secondAdd.combined); + assert.deepStrictEqual(secondAdd.code, 0, secondAdd.combined); const secondRemoteId = secondAdd.stdout.trim().split("\t")[0]; assert(secondRemoteId, `second remote-add did not return an ID: ${secondAdd.combined}`); const activate = await runCli(databaseDir, "--settings", settingsFile, "remote-activate", secondRemoteId); - assertEquals(activate.code, 0, activate.combined); - persisted = JSON.parse(await Deno.readTextFile(settingsFile)); - assertEquals(persisted.activeConfigurationId, secondRemoteId); + assert.deepStrictEqual(activate.code, 0, activate.combined); + persisted = JSON.parse(await readFile(settingsFile, "utf8")); + assert.deepStrictEqual(persisted.activeConfigurationId, secondRemoteId); const set = await runCli( databaseDir, @@ -76,59 +78,59 @@ Deno.test("settings-changing commands persist durable settings without CLI runti secondRemoteId, "sls+https://replacement:secret@example.org/replaced" ); - assertEquals(set.code, 0, set.combined); + assert.deepStrictEqual(set.code, 0, set.combined); const exported = await runCli(databaseDir, "--settings", settingsFile, "remote-export", secondRemoteId); - assertEquals(exported.code, 0, exported.combined); + assert.deepStrictEqual(exported.code, 0, exported.combined); assert(exported.stdout.includes("replacement"), "remote-set did not persist the replacement URI"); const remove = await runCli(databaseDir, "--settings", settingsFile, "remote-rm", secondRemoteId); - assertEquals(remove.code, 0, remove.combined); - persisted = JSON.parse(await Deno.readTextFile(settingsFile)); - remotes = Object.values(persisted.remoteConfigurations ?? {}) as Array<{ id?: string }>; - assert(!remotes.some((remote) => remote.id === secondRemoteId), "remote-rm did not persist the removal"); - assertEquals(persisted.activeConfigurationId, firstRemoteId); + assert.deepStrictEqual(remove.code, 0, remove.combined); + persisted = JSON.parse(await readFile(settingsFile, "utf8")); + const remotesById = Object.values(persisted.remoteConfigurations ?? {}) as Array<{ id?: string }>; + assert(!remotesById.some((remote) => remote.id === secondRemoteId), "remote-rm did not persist the removal"); + assert.deepStrictEqual(persisted.activeConfigurationId, firstRemoteId); }); -Deno.test("ordinary commands keep the settings file unchanged by default", async () => { +test("ordinary commands keep the settings file unchanged by default", async () => { const fixture = await prepareSettingsFixture("livesync-cli-settings-readonly"); await using workDir = fixture.workDir; const { settingsFile, databaseDir } = fixture; - const settings = JSON.parse(await Deno.readTextFile(settingsFile)); + const settings = JSON.parse(await readFile(settingsFile, "utf8")); settings.settingVersion = 9; const original = JSON.stringify(settings, null, 2); - await Deno.writeTextFile(settingsFile, original); + await writeFile(settingsFile, original); const result = await runCli(databaseDir, "--settings", settingsFile, "ls"); - assertEquals(result.code, 0, result.combined); - assertEquals(await Deno.readTextFile(settingsFile), original); + assert.deepStrictEqual(result.code, 0, result.combined); + assert.deepStrictEqual(await readFile(settingsFile, "utf8"), original); }); -Deno.test("--write-settings persists durable start-up setting changes", async () => { +test("--write-settings persists durable start-up setting changes", async () => { const fixture = await prepareSettingsFixture("livesync-cli-settings-explicit"); await using workDir = fixture.workDir; const { settingsFile, databaseDir } = fixture; - const settings = JSON.parse(await Deno.readTextFile(settingsFile)); + const settings = JSON.parse(await readFile(settingsFile, "utf8")); settings.settingVersion = 9; delete settings.useIndexedDBAdapter; - await Deno.writeTextFile(settingsFile, JSON.stringify(settings, null, 2)); + await writeFile(settingsFile, JSON.stringify(settings, null, 2)); const result = await runCli(databaseDir, "--settings", settingsFile, "--write-settings", "ls"); - assertEquals(result.code, 0, result.combined); + assert.deepStrictEqual(result.code, 0, result.combined); - const persisted = JSON.parse(await Deno.readTextFile(settingsFile)); - assertEquals(persisted.settingVersion, 10); + const persisted = JSON.parse(await readFile(settingsFile, "utf8")); + assert.deepStrictEqual(persisted.settingVersion, 10); assert(!("useIndexedDBAdapter" in persisted), "the CLI-only adapter override was written to the settings file"); }); -Deno.test("failed settings-changing commands leave the settings file unchanged", async () => { +test("failed settings-changing commands leave the settings file unchanged", async () => { const fixture = await prepareSettingsFixture("livesync-cli-settings-failure"); await using workDir = fixture.workDir; const { settingsFile, databaseDir } = fixture; - const original = await Deno.readTextFile(settingsFile); + const original = await readFile(settingsFile, "utf8"); const result = await runCli(databaseDir, "--settings", settingsFile, "remote-rm", "missing-remote"); assert(result.code !== 0, "remote-rm unexpectedly succeeded"); - assertEquals(await Deno.readTextFile(settingsFile), original); + assert.deepStrictEqual(await readFile(settingsFile, "utf8"), original); }); diff --git a/src/apps/cli/testdeno/test-setup-put-cat.ts b/test/e2e-cli/test-setup-put-cat.ts similarity index 82% rename from src/apps/cli/testdeno/test-setup-put-cat.ts rename to test/e2e-cli/test-setup-put-cat.ts index 1df861e4..36af0aae 100644 --- a/src/apps/cli/testdeno/test-setup-put-cat.ts +++ b/test/e2e-cli/test-setup-put-cat.ts @@ -1,29 +1,32 @@ /** - * Deno port of test-setup-put-cat-linux.sh + * Node.js port of test-setup-put-cat-linux.sh * * Tests all local-DB file operations that require no external remote: * setup / * push / cat / ls / info / rm / resolve / cat-rev / pull-rev * * Run (no external services needed): - * deno test -A test-setup-put-cat.ts + * node --test test-setup-put-cat.ts */ -import { join } from "@std/path"; -import { assertEquals, assert } from "@std/assert"; +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; + +import { join } from "node:path"; import { TempDir } from "./helpers/temp.ts"; import { runCli, runCliOrFail, runCliWithInput, sanitiseCatStdout } from "./helpers/cli.ts"; import { generateSetupUriFromSettings, initSettingsFile } from "./helpers/settings.ts"; -const REMOTE_PATH = Deno.env.get("REMOTE_PATH") ?? "test/setup-put-cat.txt"; -const SETUP_PASSPHRASE = Deno.env.get("SETUP_PASSPHRASE") ?? "setup-passphrase"; +const REMOTE_PATH = process.env["REMOTE_PATH"] ?? "test/setup-put-cat.txt"; +const SETUP_PASSPHRASE = process.env["SETUP_PASSPHRASE"] ?? "setup-passphrase"; -Deno.test("CLI file operations: push / cat / ls / info / rm / resolve / cat-rev / pull-rev", async (t) => { +test("CLI file operations: push / cat / ls / info / rm / resolve / cat-rev / pull-rev", async (t) => { await using workDir = await TempDir.create("livesync-cli-setup-put-cat"); const settingsFile = workDir.join("data.json"); const vaultDir = workDir.join("vault"); - await Deno.mkdir(join(vaultDir, "test"), { recursive: true }); + await mkdir(join(vaultDir, "test"), { recursive: true }); await initSettingsFile(settingsFile); @@ -41,8 +44,8 @@ Deno.test("CLI file operations: push / cat / ls / info / rm / resolve / cat-rev setupResult.combined.includes("[Command] setup ->"), `setup command did not execute expected code path\n${setupResult.combined}` ); - const persistedSetup = JSON.parse(await Deno.readTextFile(settingsFile)); - assertEquals(persistedSetup.isConfigured, true, "setup did not persist the configured state"); + const persistedSetup = JSON.parse(await readFile(settingsFile, "utf8")); + assert.deepStrictEqual(persistedSetup.isConfigured, true, "setup did not persist the configured state"); assert( typeof persistedSetup.encryptedCouchDBConnection === "string" && persistedSetup.encryptedCouchDBConnection.length > 0, @@ -54,10 +57,10 @@ Deno.test("CLI file operations: push / cat / ls / info / rm / resolve / cat-rev // ------------------------------------------------------------------ // push / cat roundtrip // ------------------------------------------------------------------ - await t.step("push/cat roundtrip", async () => { + await t.test("push/cat roundtrip", async () => { const srcFile = workDir.join("put-source.txt"); const content = `setup-put-cat-test ${new Date().toISOString()}\nline-2\n`; - await Deno.writeTextFile(srcFile, content); + await writeFile(srcFile, content); console.log(`[INFO] push -> ${REMOTE_PATH}`); await runCliWithInput(content, vaultDir, "--settings", settingsFile, "put", REMOTE_PATH); @@ -66,14 +69,14 @@ Deno.test("CLI file operations: push / cat / ls / info / rm / resolve / cat-rev const rawOutput = await run("cat", REMOTE_PATH); const catOutput = sanitiseCatStdout(rawOutput); - assertEquals(content, catOutput, "push/cat roundtrip content mismatch"); + assert.deepStrictEqual(content, catOutput, "push/cat roundtrip content mismatch"); console.log("[PASS] push/cat roundtrip matched"); }); // ------------------------------------------------------------------ // ls: single file // ------------------------------------------------------------------ - await t.step("ls output format (single file)", async () => { + await t.test("ls output format (single file)", async () => { const lsOutput = await run("ls", REMOTE_PATH); const line = lsOutput .trim() @@ -82,7 +85,7 @@ Deno.test("CLI file operations: push / cat / ls / info / rm / resolve / cat-rev assert(line, `ls output did not include ${REMOTE_PATH}`); const [lsPath, lsSize, lsMtime, lsRev] = line.split("\t"); - assertEquals(lsPath, REMOTE_PATH, "ls path column mismatch"); + assert.deepStrictEqual(lsPath, REMOTE_PATH, "ls path column mismatch"); assert(/^\d+$/.test(lsSize), `ls size not numeric: ${lsSize}`); assert(/^\d+$/.test(lsMtime), `ls mtime not numeric: ${lsMtime}`); assert(lsRev?.length > 0, "ls revision column is empty"); @@ -92,7 +95,7 @@ Deno.test("CLI file operations: push / cat / ls / info / rm / resolve / cat-rev // ------------------------------------------------------------------ // ls: prefix filter and sort order // ------------------------------------------------------------------ - await t.step("ls prefix filter and sort order", async () => { + await t.test("ls prefix filter and sort order", async () => { await runCliWithInput("file-a\n", vaultDir, "--settings", settingsFile, "put", "test/a-first.txt"); await runCliWithInput("file-z\n", vaultDir, "--settings", settingsFile, "put", "test/z-last.txt"); @@ -119,16 +122,16 @@ Deno.test("CLI file operations: push / cat / ls / info / rm / resolve / cat-rev // ------------------------------------------------------------------ // ls: no-match prefix returns empty output // ------------------------------------------------------------------ - await t.step("ls no-match prefix returns empty", async () => { + await t.test("ls no-match prefix returns empty", async () => { const lsOut = await run("ls", "no-such-prefix/"); - assertEquals(lsOut.trim(), "", "ls no-match prefix should produce empty output"); + assert.deepStrictEqual(lsOut.trim(), "", "ls no-match prefix should produce empty output"); console.log("[PASS] ls no-match prefix matched"); }); // ------------------------------------------------------------------ // info: JSON output format // ------------------------------------------------------------------ - await t.step("info output JSON format", async () => { + await t.test("info output JSON format", async () => { const infoOut = await run("info", REMOTE_PATH); let data: Record; try { @@ -136,18 +139,18 @@ Deno.test("CLI file operations: push / cat / ls / info / rm / resolve / cat-rev } catch { throw new Error(`info output is not valid JSON:\n${infoOut}`); } - assertEquals(data.path, REMOTE_PATH, "info .path mismatch"); - assertEquals(data.filename, REMOTE_PATH.split("/").at(-1), "info .filename mismatch"); + assert.deepStrictEqual(data.path, REMOTE_PATH, "info .path mismatch"); + assert.deepStrictEqual(data.filename, REMOTE_PATH.split("/").at(-1), "info .filename mismatch"); assert(typeof data.size === "number" && data.size >= 0, `info .size invalid: ${data.size}`); assert(typeof data.chunks === "number" && data.chunks >= 1, `info .chunks invalid: ${data.chunks}`); - assertEquals(data.conflicts, "N/A", "info .conflicts should be N/A"); + assert.deepStrictEqual(data.conflicts, "N/A", "info .conflicts should be N/A"); console.log("[PASS] info output format matched"); }); // ------------------------------------------------------------------ // info: non-existent path exits non-zero // ------------------------------------------------------------------ - await t.step("info non-existent path returns non-zero", async () => { + await t.test("info non-existent path returns non-zero", async () => { const r = await runCli(vaultDir, "--settings", settingsFile, "info", "no-such-file.md"); assert(r.code !== 0, "info on non-existent file should exit non-zero"); console.log("[PASS] info non-existent path returns non-zero"); @@ -156,7 +159,7 @@ Deno.test("CLI file operations: push / cat / ls / info / rm / resolve / cat-rev // ------------------------------------------------------------------ // rm: removes file from ls and makes cat fail // ------------------------------------------------------------------ - await t.step("rm removes target from ls and cat", async () => { + await t.test("rm removes target from ls and cat", async () => { await run("rm", "test/z-last.txt"); const catResult = await runCli(vaultDir, "--settings", settingsFile, "cat", "test/z-last.txt"); @@ -170,7 +173,7 @@ Deno.test("CLI file operations: push / cat / ls / info / rm / resolve / cat-rev // ------------------------------------------------------------------ // resolve: accepts current revision, rejects invalid revision // ------------------------------------------------------------------ - await t.step("resolve: valid and invalid revisions", async () => { + await t.test("resolve: valid and invalid revisions", async () => { const lsLine = (await run("ls", "test/a-first.txt")).trim().split("\n")[0]; assert(lsLine, "could not fetch revision for resolve test"); const rev = lsLine.split("\t")[3]; @@ -187,7 +190,7 @@ Deno.test("CLI file operations: push / cat / ls / info / rm / resolve / cat-rev // ------------------------------------------------------------------ // cat-rev / pull-rev: retrieve a past revision // ------------------------------------------------------------------ - await t.step("cat-rev / pull-rev: retrieve past revision", async () => { + await t.test("cat-rev / pull-rev: retrieve past revision", async () => { const revPath = "test/revision-history.txt"; await runCliWithInput("revision-v1\n", vaultDir, "--settings", settingsFile, "put", revPath); await runCliWithInput("revision-v2\n", vaultDir, "--settings", settingsFile, "put", revPath); @@ -211,7 +214,7 @@ Deno.test("CLI file operations: push / cat / ls / info / rm / resolve / cat-rev const pullRevFile = workDir.join("rev-pull-output.txt"); await run("pull-rev", revPath, pullRevFile, pastRev); - const pullRevContent = await Deno.readTextFile(pullRevFile); + const pullRevContent = await readFile(pullRevFile, "utf8"); assert( pullRevContent === "revision-v1\n" || pullRevContent === "revision-v2\n", `pull-rev output did not match expected past revision:\n${pullRevContent}` diff --git a/src/apps/cli/testdeno/test-sync-locked-remote.ts b/test/e2e-cli/test-sync-locked-remote.ts similarity index 84% rename from src/apps/cli/testdeno/test-sync-locked-remote.ts rename to test/e2e-cli/test-sync-locked-remote.ts index d8b2e3d8..099b85e8 100644 --- a/src/apps/cli/testdeno/test-sync-locked-remote.ts +++ b/test/e2e-cli/test-sync-locked-remote.ts @@ -1,27 +1,34 @@ /** - * Deno port of test-sync-locked-remote-linux.sh + * Node.js port of test-sync-locked-remote-linux.sh * * Verifies CLI sync behaviour when the remote milestone document is unlocked * versus locked. */ -import { assert, assertStringIncludes } from "@std/assert"; +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdir } from "node:fs/promises"; + import { TempDir } from "./helpers/temp.ts"; import { runCli } from "./helpers/cli.ts"; import { applyCouchdbSettings, initSettingsFile } from "./helpers/settings.ts"; import { createCouchdbDatabase, startCouchdb, stopCouchdb, updateCouchdbDoc } from "./helpers/docker.ts"; +function assertStringIncludes(actual: string, expected: string, message?: string): void { + assert.ok(actual.includes(expected), message ?? `Expected ${JSON.stringify(actual)} to include ${JSON.stringify(expected)}`); +} + const MILESTONE_DOC = "_local/obsydian_livesync_milestone"; function requireEnv(...keys: string[]): string { for (const key of keys) { - const value = Deno.env.get(key)?.trim(); + const value = process.env[key]?.trim(); if (value) return value; } throw new Error(`Required env var is missing: ${keys.join(" or ")}`); } -Deno.test("sync: actionable error against locked remote DB", async () => { +test("sync: actionable error against locked remote DB", async () => { const couchdbUri = requireEnv("COUCHDB_URI", "hostname").replace(/\/$/, ""); const couchdbUser = requireEnv("COUCHDB_USER", "username"); const couchdbPassword = requireEnv("COUCHDB_PASSWORD", "password"); @@ -31,10 +38,10 @@ Deno.test("sync: actionable error against locked remote DB", async () => { await using workDir = await TempDir.create("livesync-cli-locked-test"); const vaultDir = workDir.join("vault"); const settingsFile = workDir.join("settings.json"); - await Deno.mkdir(vaultDir, { recursive: true }); + await mkdir(vaultDir, { recursive: true }); - const shouldStartDocker = Deno.env.get("LIVESYNC_START_DOCKER") !== "0"; - const keepDocker = Deno.env.get("LIVESYNC_DEBUG_KEEP_DOCKER") === "1"; + const shouldStartDocker = process.env["LIVESYNC_START_DOCKER"] !== "0"; + const keepDocker = process.env["LIVESYNC_DEBUG_KEEP_DOCKER"] === "1"; if (shouldStartDocker) { console.log(`[INFO] starting CouchDB and creating test database: ${dbname}`); diff --git a/src/apps/cli/testdeno/test-sync-two-local-databases.ts b/test/e2e-cli/test-sync-two-local-databases.ts similarity index 81% rename from src/apps/cli/testdeno/test-sync-two-local-databases.ts rename to test/e2e-cli/test-sync-two-local-databases.ts index 5717d405..7bd90dbd 100644 --- a/src/apps/cli/testdeno/test-sync-two-local-databases.ts +++ b/test/e2e-cli/test-sync-two-local-databases.ts @@ -1,5 +1,5 @@ /** - * Deno port of test-sync-two-local-databases-linux.sh + * Node.js port of test-sync-two-local-databases-linux.sh * * Tests two-vault synchronisation via CouchDB including conflict detection * and resolution. @@ -12,7 +12,7 @@ * CouchDB instance instead. * * Run: - * deno test -A test-sync-two-local-databases.ts + * node --test test-sync-two-local-databases.ts * * With an existing CouchDB: * COUCHDB_URI=http://127.0.0.1:5984 \ @@ -20,10 +20,13 @@ * COUCHDB_PASSWORD=password \ * COUCHDB_DBNAME=livesync-test \ * LIVESYNC_START_DOCKER=0 \ - * deno test -A test-sync-two-local-databases.ts + * node --test test-sync-two-local-databases.ts */ -import { assertEquals, assert } from "@std/assert"; +import { test, type TestContext } from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; + import { TempDir } from "./helpers/temp.ts"; import { runCliOrFail, jsonFieldIsNa } from "./helpers/cli.ts"; import { applyCouchdbSettings, initSettingsFile } from "./helpers/settings.ts"; @@ -39,7 +42,7 @@ async function resolveConfig(): Promise<{ password: string; baseDbname: string; } | null> { - const env = Deno.env.toObject(); + const env = { ...process.env }; const uri = (env["COUCHDB_URI"] ?? env["hostname"] ?? "").replace(/\/$/, ""); const user = env["COUCHDB_USER"] ?? env["username"] ?? ""; @@ -51,25 +54,23 @@ async function resolveConfig(): Promise<{ } const config = await resolveConfig(); -const START_DOCKER = Deno.env.get("LIVESYNC_START_DOCKER") !== "0"; -const KEEP_DOCKER = Deno.env.get("LIVESYNC_DEBUG_KEEP_DOCKER") === "1"; -const SYNC_RETRY = Number(Deno.env.get("LIVESYNC_SYNC_RETRY") ?? "8"); +const START_DOCKER = process.env["LIVESYNC_START_DOCKER"] !== "0"; +const KEEP_DOCKER = process.env["LIVESYNC_DEBUG_KEEP_DOCKER"] === "1"; +const SYNC_RETRY = Number(process.env["LIVESYNC_SYNC_RETRY"] ?? "8"); // Provide a sane default for flaky remote connectivity in Docker-on-WSL // environments. Users can override explicitly if needed. -if (!Deno.env.has("LIVESYNC_CLI_RETRY")) { - Deno.env.set("LIVESYNC_CLI_RETRY", "2"); +if (!Object.hasOwn(process.env, "LIVESYNC_CLI_RETRY")) { + (process.env["LIVESYNC_CLI_RETRY"] = "2"); } // --------------------------------------------------------------------------- // Test suite // --------------------------------------------------------------------------- -Deno.test( - { - name: "sync two local databases: sync + conflict detection + resolution", - ignore: config === null, - }, +test( + "sync two local databases: sync + conflict detection + resolution", + { skip: config === null }, async (t) => { if (!config) return; // narrowing for TypeScript @@ -104,7 +105,7 @@ Deno.test( // --------------------------------------------------------------------------- async function runSuite( - t: Deno.TestContext, + t: TestContext, workDir: TempDir, config: { uri: string; user: string; password: string }, dbname: string @@ -130,8 +131,8 @@ async function runSuite( const vaultB = workDir.join("vault-b"); const settingsA = workDir.join("a-settings.json"); const settingsB = workDir.join("b-settings.json"); - await Deno.mkdir(vaultA, { recursive: true }); - await Deno.mkdir(vaultB, { recursive: true }); + await mkdir(vaultA, { recursive: true }); + await mkdir(vaultB, { recursive: true }); await initSettingsFile(settingsA); await initSettingsFile(settingsB); @@ -152,45 +153,45 @@ async function runSuite( // ------------------------------------------------------------------ // Case 1: A creates file, B reads after sync // ------------------------------------------------------------------ - await t.step("case 1: A creates file -> B can read after sync", async () => { + await t.test("case 1: A creates file -> B can read after sync", async () => { const srcA = workDir.join("from-a-src.txt"); - await Deno.writeTextFile(srcA, "from-a\n"); + await writeFile(srcA, "from-a\n"); await runA("push", srcA, "shared/from-a.txt"); await syncA(); await syncB(); const value = (await catB("shared/from-a.txt")).replace(/\r\n/g, "\n").trimEnd(); - assertEquals(value, "from-a", "B could not read file created on A"); + assert.deepStrictEqual(value, "from-a", "B could not read file created on A"); console.log("[PASS] case 1 passed"); }); // ------------------------------------------------------------------ // Case 2: B creates file, A reads after sync // ------------------------------------------------------------------ - await t.step("case 2: B creates file -> A can read after sync", async () => { + await t.test("case 2: B creates file -> A can read after sync", async () => { const srcB = workDir.join("from-b-src.txt"); - await Deno.writeTextFile(srcB, "from-b\n"); + await writeFile(srcB, "from-b\n"); await runB("push", srcB, "shared/from-b.txt"); await syncB(); await syncA(); const value = (await catA("shared/from-b.txt")).replace(/\r\n/g, "\n").trimEnd(); - assertEquals(value, "from-b", "A could not read file created on B"); + assert.deepStrictEqual(value, "from-b", "A could not read file created on B"); console.log("[PASS] case 2 passed"); }); // ------------------------------------------------------------------ // Case 3: concurrent edits create a conflict // ------------------------------------------------------------------ - await t.step("case 3: concurrent edits create conflict", async () => { + await t.test("case 3: concurrent edits create conflict", async () => { const baseSrc = workDir.join("base-src.txt"); - await Deno.writeTextFile(baseSrc, "base\n"); + await writeFile(baseSrc, "base\n"); await runA("push", baseSrc, "shared/conflicted.txt"); await syncA(); await syncB(); const aEdit = workDir.join("edit-a.txt"); const bEdit = workDir.join("edit-b.txt"); - await Deno.writeTextFile(aEdit, "edit-from-a\n"); - await Deno.writeTextFile(bEdit, "edit-from-b\n"); + await writeFile(aEdit, "edit-from-a\n"); + await writeFile(bEdit, "edit-from-b\n"); await runA("push", aEdit, "shared/conflicted.txt"); await runB("push", bEdit, "shared/conflicted.txt"); @@ -201,10 +202,10 @@ async function runSuite( for (const side of ["a", "b"] as const) { if (side === "a") await syncA(); else await syncB(); - await Deno.writeTextFile(infoFileA, await runA("info", "shared/conflicted.txt")); - await Deno.writeTextFile(infoFileB, await runB("info", "shared/conflicted.txt")); - const da = JSON.parse(await Deno.readTextFile(infoFileA)) as Record; - const db = JSON.parse(await Deno.readTextFile(infoFileB)) as Record; + await writeFile(infoFileA, await runA("info", "shared/conflicted.txt")); + await writeFile(infoFileB, await runB("info", "shared/conflicted.txt")); + const da = JSON.parse(await readFile(infoFileA, "utf8")) as Record; + const db = JSON.parse(await readFile(infoFileB, "utf8")) as Record; if (!jsonFieldIsNa(da, "conflicts") || !jsonFieldIsNa(db, "conflicts")) { conflictDetected = true; break; @@ -217,14 +218,14 @@ async function runSuite( // ------------------------------------------------------------------ // Case 4: resolve on A, verify B has no conflict after sync // ------------------------------------------------------------------ - await t.step("case 4: resolve on A propagates to B", async () => { + await t.test("case 4: resolve on A propagates to B", async () => { const infoFileA = workDir.join("info-a-resolve.json"); const infoFileB = workDir.join("info-b-resolve.json"); // Ensure A sees the conflict for (let i = 0; i < 5; i++) { const raw = await runA("info", "shared/conflicted.txt"); - await Deno.writeTextFile(infoFileA, raw); + await writeFile(infoFileA, raw); const da = JSON.parse(raw) as Record; if (!jsonFieldIsNa(da, "conflicts")) break; await syncB(); @@ -232,7 +233,7 @@ async function runSuite( } const rawA = await runA("info", "shared/conflicted.txt"); - await Deno.writeTextFile(infoFileA, rawA); + await writeFile(infoFileA, rawA); const dataA = JSON.parse(rawA) as Record; assert(!jsonFieldIsNa(dataA, "conflicts"), "A does not see conflict, cannot resolve from A only"); @@ -247,8 +248,8 @@ async function runSuite( await syncB(); const rawA2 = await runA("info", "shared/conflicted.txt"); const rawB2 = await runB("info", "shared/conflicted.txt"); - await Deno.writeTextFile(infoFileA, rawA2); - await Deno.writeTextFile(infoFileB, rawB2); + await writeFile(infoFileA, rawA2); + await writeFile(infoFileB, rawB2); const da2 = JSON.parse(rawA2) as Record; const db2 = JSON.parse(rawB2) as Record; if (jsonFieldIsNa(da2, "conflicts") && jsonFieldIsNa(db2, "conflicts")) { @@ -265,7 +266,7 @@ async function runSuite( const contentA = (await catA("shared/conflicted.txt")).replace(/\r\n/g, "\n"); const contentB = (await catB("shared/conflicted.txt")).replace(/\r\n/g, "\n"); - assertEquals(contentA, contentB, "resolved content mismatch between A and B"); + assert.deepStrictEqual(contentA, contentB, "resolved content mismatch between A and B"); console.log("[PASS] case 4 passed"); console.log("[PASS] all sync/resolve scenarios passed"); }); diff --git a/test/e2e-cli/test_dev_node.md b/test/e2e-cli/test_dev_node.md new file mode 100644 index 00000000..2f006878 --- /dev/null +++ b/test/e2e-cli/test_dev_node.md @@ -0,0 +1,121 @@ +# CLI Node.js Test Development Notes + +The CLI test suite under `test/e2e-cli/` uses Node.js 26 native TypeScript execution and the built-in `node:test` runner. The standard service-backed suite is selected by `ci-tasks.json`. P2P scenarios use the repository Compose entry point so that networking, signalling, and the runner environment are reproducible. Existing Bash tests under `src/apps/cli/test/` remain available as compatibility references. + +## Goals and structure + +- Run CLI subprocess and filesystem scenarios on Windows, macOS, and Linux. +- Use Node.js standard modules and shared helpers in place of a separate test runtime. +- Keep the default service-backed suite and canonical Compose P2P suite distinct. + +```text +test/e2e-cli/ + tasks.json + ci-tasks.json + run-task.ts + run-ci-suite.ts + run-compose-p2p.ts + run-coverage.ts + helpers/ + test-*.ts +``` + +Node executes erasable TypeScript syntax directly. The scoped `test/e2e-cli/tsconfig.json` checks that test files stay within that runtime boundary. The task runner uses one test worker at a time and loads the CLI package `.test.env` file when present. + +## Task manifest + +`tasks.json` preserves named test and benchmark entry points as structured records. A record can name test files and an optional test-name filter, a TypeScript script, an ordered list of other tasks, or a command with arguments for the retained benchmark shell scripts. Shell commands are restricted to those legacy benchmark tasks. + +`ci-tasks.json` is the source of truth for the default service-backed CI task list. Keep it aligned with the `test:ci` task runner. `test:local` groups setup, mirror, and daemon smoke tasks which do not require Docker. + +## Test coverage + +- `test-setup-put-cat.ts` covers setup URI generation and core CLI commands without a remote service. +- `test-mirror.ts` covers seven mirror scenarios, including unknown revision provenance, conflict preservation, deduplication, and resolution. +- `test-daemon.ts` and `test-daemon-startup.ts` cover daemon ignore rules and startup behaviour. +- `test-push-pull.ts`, `test-sync-two-local-databases.ts`, and `test-sync-locked-remote.ts` cover remote synchronisation and locking. +- `test-decoupled-vault.ts` covers a vault directory which is separate from the database directory. +- `test-remote-commands.ts`, `test-remote-administration-exit-codes.ts`, and `test-settings-writeback.ts` cover remote management and settings behaviour. +- P2P tests cover host startup, peer discovery, synchronisation, automatic host policy, replacement, relay disconnection, multi-node conflicts, and upload/download. The Compose suite runs the canonical synchronisation, replacement, and relay-disconnection scenarios. +- `test-e2e-two-vaults-couchdb.ts` and `test-e2e-two-vaults-matrix.ts` cover two-vault CouchDB and CouchDB/Object Storage cases. + +## Running tests + +Run the canonical workflows from the repository root. These npm scripts build the CLI before the suite where needed: + +```bash +npm run test:e2e:cli +npm run test:e2e:cli:p2p +npm run test:e2e:cli:all +``` + +Run an individual named task after building the CLI: + +```bash +npm run test:e2e:task --workspace self-hosted-livesync-cli -- test:mirror +``` + +The local smoke set is also available as `test:local`. It runs setup, mirror, and daemon tests without starting Docker services. To run the mirror file directly from its directory: + +```bash +cd test/e2e-cli +node --env-file-if-exists=../../src/apps/cli/.test.env --test --test-concurrency=1 test-mirror.ts +``` + +Collect CLI body coverage with the local smoke suite, or pass `test:ci` to cover the standard service-backed suite: + +```bash +npm run test:e2e:coverage --workspace self-hosted-livesync-cli +npm run test:e2e:coverage --workspace self-hosted-livesync-cli -- test:ci +``` + +The runner defaults to `test:local`, which does not require services. It writes HTML, JSON, and text reports under `coverage/cli-e2e/`. The runner enables CLI bundle source maps only for the coverage build with `LIVESYNC_CLI_COVERAGE=1`; ordinary builds do not enable them. + +The CLI Node.js workflow runs this local smoke coverage on Linux and publishes the HTML and JSON reports as the `cli-e2e-local-coverage` artifact, retained for seven days. This artifact covers `test:local` only and is separate from unit-test and service-backed/P2P coverage. + +## Environment variables + +### CouchDB + +- `COUCHDB_URI` +- `COUCHDB_USER` +- `COUCHDB_PASSWORD` +- `COUCHDB_DBNAME` + +The equivalent keys in `.test.env` are `hostname`, `username`, `password`, and `dbname`. + +### Behaviour switches + +- `LIVESYNC_START_DOCKER=0`: use an existing CouchDB service. +- `REMOTE_PATH`: override the target path for selected tests. +- `LIVESYNC_TEST_TEE=1`: stream CLI stdout and stderr during execution. +- `LIVESYNC_DOCKER_TEE=1`: stream Docker stdout and stderr. +- `LIVESYNC_CLI_RETRY=`: retry transient network failures. +- `LIVESYNC_DEBUG_KEEP_DOCKER=1`: keep `couchdb-test` after a test completes. + +### Docker command selection + +`helpers/docker.ts` supports `LIVESYNC_DOCKER_MODE=auto` by default. On Windows it tries `wsl docker` first, then `docker`; on other platforms it tries `docker` first, then `wsl docker`. Set `LIVESYNC_DOCKER_MODE=native` or `LIVESYNC_DOCKER_MODE=wsl` to select one explicitly. `LIVESYNC_DOCKER_COMMAND` takes priority and can specify a custom command, such as `wsl docker`. + +### P2P + +- `RELAY` +- `ROOM_ID` +- `PASSPHRASE` +- `APP_ID` +- `PEERS_TIMEOUT` +- `SYNC_TIMEOUT` +- `USE_INTERNAL_RELAY=0|1` +- `TIMEOUT_SECONDS` + +## Continuous integration + +The `.github/workflows/cli-node-tests.yml` workflow runs for changes to the CLI or its shared runtime inputs. Its Linux `prepare` job builds the CLI once and distributes that bundle to the Linux service-backed test jobs. The `local-smoke` job runs automatically on pushes and non-draft pull requests, and also when `test:local` is selected manually; it builds the CLI once on each of Ubuntu, macOS, and Windows and starts no Docker services. + +The separate `.github/workflows/cli-p2p-tests.yml` workflow runs the canonical Compose P2P suite for CLI and `test/bench-network/` changes. A change confined to Compose fixtures does not start the non-P2P matrix. + +The `.github/workflows/cli-docker-validation.yml` workflow still builds the production CLI bundle with Node.js 22, then validates Docker images on native amd64 and arm64 runners. That release-image runtime remains independent of the Node.js version used by CLI E2E tests. + +## Maintenance policy + +Keep scenarios in Node test files and reuse helpers from `helpers/`. Preserve the authoritative CI task list in `ci-tasks.json`. Retain the Bash suites and benchmark wrappers until their corresponding migration has been separately agreed and completed. diff --git a/test/e2e-cli/tsconfig.json b/test/e2e-cli/tsconfig.json new file mode 100644 index 00000000..971b0dcd --- /dev/null +++ b/test/e2e-cli/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": ["node"], + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "erasableSyntaxOnly": true, + "resolveJsonModule": true + }, + "include": ["**/*.ts"] +} diff --git a/tsconfig.json b/tsconfig.json index 4e159699..e01d0f5e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,6 +30,7 @@ "src/**/*.test.ts", "**/_test/**", "test/browser-apps", + "test/e2e-cli", "test/styles/*.deno.ts", "utilsdeno" ] diff --git a/updates.md b/updates.md index e4441dd6..3d01a759 100644 --- a/updates.md +++ b/updates.md @@ -12,6 +12,8 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi ## Unreleased +- The CLI mirror now starts correctly on Windows when the Vault path is omitted and the database directory is also used as the Vault. It checks logical Vault paths using `/` separators, correctly excluding internal database files and nested hidden directories even when the host uses Windows path separators. + ## 1.0.43 30th October, 2026 diff --git a/utils/ci-pages.unit.spec.ts b/utils/ci-pages.unit.spec.ts index fbece795..38853f6c 100644 --- a/utils/ci-pages.unit.spec.ts +++ b/utils/ci-pages.unit.spec.ts @@ -57,7 +57,7 @@ describe("CI workflow path filters", () => { "utils/ci-pages.unit.spec.ts", "utils/cli-ci.unit.spec.ts", ".github/workflows/cli-docker.yml", - ".github/workflows/cli-deno-tests.yml", + ".github/workflows/cli-node-tests.yml", ".github/workflows/cli-p2p-tests.yml", ".github/workflows/cli-docker-validation.yml", ]; diff --git a/utils/cli-ci.unit.spec.ts b/utils/cli-ci.unit.spec.ts index 500de60e..dd60faf0 100644 --- a/utils/cli-ci.unit.spec.ts +++ b/utils/cli-ci.unit.spec.ts @@ -19,7 +19,8 @@ type Workflow = { if?: string; permissions?: Record; "runs-on"?: string; - strategy?: { matrix?: { include?: Array<{ arch: string; runner: string }> } }; + strategy?: { matrix?: { include?: Array<{ arch: string; runner: string }>; os?: string[] } }; + services?: Record; } >; }; @@ -35,12 +36,12 @@ function matches(paths: string[], file: string): boolean { return included; } function localTasks(): string[] { - const runner = readFileSync(new URL("src/apps/cli/testdeno/run-ci-suite.ts", root), "utf8"); + const runner = readFileSync(new URL("test/e2e-cli/run-ci-suite.ts", root), "utf8"); expect(runner).toContain('import TASKS from "./ci-tasks.json" with { type: "json" }'); - return JSON.parse(readFileSync(new URL("src/apps/cli/testdeno/ci-tasks.json", root), "utf8")) as string[]; + return JSON.parse(readFileSync(new URL("test/e2e-cli/ci-tasks.json", root), "utf8")) as string[]; } function selectedTasks(task: string): string[] { - const select = workflow("cli-deno-tests.yml").jobs.prepare.steps?.find((step) => step.id === "select"); + const select = workflow("cli-node-tests.yml").jobs.prepare.steps?.find((step) => step.id === "select"); expect(select?.run).toBeDefined(); const directory = mkdtempSync(join(tmpdir(), "livesync-ci-matrix-")); const output = join(directory, "output"); @@ -67,19 +68,20 @@ describe("CLI CI selection", () => { expect(selectedTasks("test:ci")).toEqual( expect.arrayContaining(["test:settings-writeback", "test:remote-administration-exit-codes"]) ); - const tasks = JSON.parse(readFileSync(new URL("src/apps/cli/testdeno/deno.json", root), "utf8")).tasks; - expect(localTasks().filter((task) => !(task in tasks))).toEqual([]); + const tasks = JSON.parse(readFileSync(new URL("test/e2e-cli/tasks.json", root), "utf8")); + expect(localTasks().filter((task) => !Object.hasOwn(tasks, task))).toEqual([]); + expect(tasks["test:local"]).toEqual({ tasks: ["test:setup-put-cat", "test:mirror", "test:daemon"] }); expect(new Set(localTasks()).size).toBe(localTasks().length); }); it("keeps the selected manual subsets", () => { - expect(selectedTasks("test:local")).toEqual(["test:setup-put-cat", "test:mirror", "test:daemon"]); + expect(selectedTasks("test:local")).toEqual(["test:local"]); expect(selectedTasks("test:e2e-matrix")).toEqual( localTasks().filter((task) => task.startsWith("test:e2e-matrix:")) ); }); it.each([ - ["cli-deno-tests.yml", "push"], - ["cli-deno-tests.yml", "pull_request"], + ["cli-node-tests.yml", "push"], + ["cli-node-tests.yml", "pull_request"], ["cli-p2p-tests.yml", "push"], ["cli-p2p-tests.yml", "pull_request"], ["cli-docker.yml", "push"], @@ -105,16 +107,72 @@ describe("CLI CI selection", () => { expect(inputs.filter((file) => !matches(paths, file))).toEqual([]); expect(matches(paths, "src/modules/features/ModuleObsidianSettingTab.ts")).toBe(false); expect(matches(paths, "docs/settings.md")).toBe(false); - expect(matches(paths, "src/apps/cli/testdeno/CONTRIBUTING_TESTS.md")).toBe(false); }); + it.each([ + ["cli-node-tests.yml", "push"], + ["cli-node-tests.yml", "pull_request"], + ["cli-p2p-tests.yml", "push"], + ["cli-p2p-tests.yml", "pull_request"], + ])("runs CLI E2E workflows for suite changes and skips suite documentation in %s on %s", (name, event) => { + const paths = workflow(name).on[event].paths ?? []; + expect(matches(paths, "test/e2e-cli/test-setup-put-cat.ts")).toBe(true); + expect(matches(paths, "test/e2e-cli/CONTRIBUTING_TESTS.md")).toBe(false); + }); + it("runs Compose alone for a change to its fixture", () => { const file = "test/bench-network/compose.yml"; expect(matches(workflow("cli-p2p-tests.yml").on.pull_request.paths ?? [], file)).toBe(true); - expect(matches(workflow("cli-deno-tests.yml").on.pull_request.paths ?? [], file)).toBe(false); + expect(matches(workflow("cli-node-tests.yml").on.pull_request.paths ?? [], file)).toBe(false); expect(matches(workflow("cli-docker-validation.yml").on.pull_request.paths ?? [], file)).toBe(false); }); + it("uses Node 26 for CLI tests and runs local smoke tests on three platforms without Docker", () => { + const { prepare, test, "local-smoke": localSmoke } = workflow("cli-node-tests.yml").jobs; + for (const job of [prepare, test, localSmoke]) { + const setupNode = job.steps?.find((step) => step.uses?.startsWith("actions/setup-node@")); + expect(setupNode?.with?.["node-version"]).toBe("26.x"); + expect(job.steps?.some((step) => step.uses?.startsWith("denoland/setup-deno@"))).toBe(false); + } + expect(localSmoke.if).toBe( + "${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name != 'workflow_dispatch' || inputs.test_task == 'test:local') }}" + ); + expect(localSmoke.strategy?.matrix?.os).toEqual(["ubuntu-latest", "macos-latest", "windows-latest"]); + expect(localSmoke["runs-on"]).toBe("${{ matrix.os }}"); + expect(localSmoke.services).toBeUndefined(); + expect(localSmoke.steps?.some((step) => step.run?.includes("docker"))).toBe(false); + expect(localSmoke.steps?.filter((step) => step.run?.includes("npm run build --workspace self-hosted-livesync-cli"))).toHaveLength(1); + expect(localSmoke.steps?.find((step) => step.run?.includes("test:local"))?.run).toContain("node ../../../test/e2e-cli/run-task.ts test:local"); + expect(test.steps?.find((step) => step.run?.includes("node run-task.ts"))?.run).toContain('node run-task.ts "$TASK"'); + }); + + it("collects local smoke coverage once on Linux and retains its reports for seven days", () => { + const steps = workflow("cli-node-tests.yml").jobs["local-smoke"].steps ?? []; + const coverage = steps.filter((step) => step.run?.includes("test:e2e:coverage")); + expect(coverage).toHaveLength(1); + expect(coverage[0].run).toBe("npm run test:e2e:coverage --workspace self-hosted-livesync-cli"); + expect(coverage[0].if).toBe("${{ matrix.os == 'ubuntu-latest' }}"); + for (const step of steps.filter( + (step) => step.run?.includes("npm run build --workspace") || step.run?.includes("run-task.ts test:local") + )) { + expect(step.if).toBe("${{ matrix.os != 'ubuntu-latest' }}"); + } + const uploads = steps.filter((step) => step.uses?.startsWith("actions/upload-artifact@")); + expect(uploads).toHaveLength(1); + const upload = uploads[0]; + expect(upload.if).toBe("${{ always() && matrix.os == 'ubuntu-latest' }}"); + expect(upload.with?.name).toBe("cli-e2e-local-coverage"); + expect(upload.with?.["retention-days"]).toBe(7); + expect(upload.with?.["if-no-files-found"]).toBe("warn"); + const paths = String(upload.with?.path).trim().split("\n"); + expect(matches(paths, "coverage/cli-e2e/index.html")).toBe(true); + expect(matches(paths, "coverage/cli-e2e/src/apps/cli/main.ts.html")).toBe(true); + expect(matches(paths, "coverage/cli-e2e/base.css")).toBe(true); + expect(matches(paths, "coverage/cli-e2e/coverage-final.json")).toBe(true); + expect(matches(paths, "coverage/cli-e2e/raw/coverage-123.json")).toBe(false); + expect(matches(paths, "coverage/unit/index.html")).toBe(false); + }); + it("passes a single CLI build to every standard test job", () => { - const { prepare, test } = workflow("cli-deno-tests.yml").jobs; + const { prepare, test } = workflow("cli-node-tests.yml").jobs; expect(test.needs).toBe("prepare"); expect(prepare.steps?.filter((step) => step.run === "npm run build")).toHaveLength(1); expect(test.steps?.some((step) => step.run?.includes("npm run build"))).toBe(false); diff --git a/utilsdeno/README.md b/utilsdeno/README.md index d0b2a76e..d7d358c7 100644 --- a/utilsdeno/README.md +++ b/utilsdeno/README.md @@ -95,6 +95,6 @@ Ensures that internal plug-in import statements are standardised to the `@/` ali ## Safety and Exclusions -* **Tests Excluded**: All scripts automatically skip files located in `_test/` or `testdeno/` folders, as well as files ending with `.spec.ts` or `.test.ts`. +* **Source Scope and Tests**: `refactor-styles.ts` and `refactor-globals.ts` process files only inside the project `src/` directory. They skip files in `_test/` folders and files ending with `.spec.ts` or `.test.ts`. The CLI E2E suite is under `test/e2e-cli/`, outside that source scope. * **Package Boundary**: These tools operate on this repository only. Changes to Commonlib belong in its own repository and must be validated with its package checks. * **Verification**: Always run `npm run check` and `npm run test:unit` after performing refactoring tasks to verify that type safety and tests remain intact. diff --git a/utilsdeno/refactor-globals.ts b/utilsdeno/refactor-globals.ts index ea54a1f4..870fc56b 100644 --- a/utilsdeno/refactor-globals.ts +++ b/utilsdeno/refactor-globals.ts @@ -67,8 +67,7 @@ for (const sourceFile of project.getSourceFiles()) { if ( posixFilePath.endsWith(".spec.ts") || posixFilePath.endsWith(".test.ts") || - posixFilePath.includes("/_test/") || - posixFilePath.includes("/testdeno/") + posixFilePath.includes("/_test/") ) { continue; } diff --git a/utilsdeno/refactor-styles.ts b/utilsdeno/refactor-styles.ts index 95f46d82..35d14685 100644 --- a/utilsdeno/refactor-styles.ts +++ b/utilsdeno/refactor-styles.ts @@ -102,8 +102,7 @@ for (const sourceFile of project.getSourceFiles()) { if ( posixFilePath.endsWith(".spec.ts") || posixFilePath.endsWith(".test.ts") || - posixFilePath.includes("/_test/") || - posixFilePath.includes("/testdeno/") + posixFilePath.includes("/_test/") ) { continue; } diff --git a/vitest.config.integration.ts b/vitest.config.integration.ts index 6ad9e109..f5c92ba7 100644 --- a/vitest.config.integration.ts +++ b/vitest.config.integration.ts @@ -19,7 +19,7 @@ export default mergeConfig( logHeapUsage: true, name: "integration-tests", include: ["**/*.integration.spec.ts", "**/*.integration.test.ts"], - exclude: ["test/**", "src/apps/**/testdeno/**"], + exclude: ["test/**"], }, }) ); diff --git a/vitest.config.unit.ts b/vitest.config.unit.ts index 09ba5865..aab252f6 100644 --- a/vitest.config.unit.ts +++ b/vitest.config.unit.ts @@ -24,7 +24,7 @@ export default mergeConfig( // maxConcurrency: 2, name: "unit-tests", include: ["**/*unit.test.ts", "**/*.unit.spec.ts"], - exclude: ["node_modules/**", ".devmemo.local/**", "src/apps/**/testdeno/**"], + exclude: ["node_modules/**", ".devmemo.local/**", "test/e2e-cli/**"], coverage: { include: ["src/**/*.ts"], exclude: [ @@ -33,7 +33,6 @@ export default mergeConfig( "**/*.unit.spec.ts", "test/**", "**/_*", - "src/apps/**/testdeno/**", "**/*_obsolete.ts", ...importOnlyFiles, ...obsidianRuntimeOnlyFiles,