mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-01 13:58:18 +00:00
Migrate CLI E2E tests to Node.js 26
Run the CLI subprocess suites and benchmark tools as native TypeScript with node:test, and move them under test/e2e-cli. Update CI and Compose runners, run local smoke tests on Linux, macOS, and Windows, and collect source-mapped CLI coverage with c8.
This commit is contained in:
1 parent
a0987016bb
commit
2433d117c2
91 files changed
+2219
-1615
No files matched your search
+1
-1
@@ -19,7 +19,7 @@ production/
|
|||||||
coverage/
|
coverage/
|
||||||
_testdata/
|
_testdata/
|
||||||
test/bench-network/bench-results/
|
test/bench-network/bench-results/
|
||||||
src/apps/cli/testdeno/bench-results/
|
test/e2e-cli/bench-results/
|
||||||
|
|
||||||
# Local environment / secrets
|
# Local environment / secrets
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: cli-deno-tests
|
name: cli-node-tests
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -6,9 +6,11 @@ on:
|
|||||||
- main
|
- main
|
||||||
- beta
|
- beta
|
||||||
paths:
|
paths:
|
||||||
- '.github/workflows/cli-deno-tests.yml'
|
- '.github/workflows/cli-node-tests.yml'
|
||||||
- 'src/apps/cli/**'
|
- 'src/apps/cli/**'
|
||||||
- '!src/apps/cli/**/*.md'
|
- '!src/apps/cli/**/*.md'
|
||||||
|
- 'test/e2e-cli/**'
|
||||||
|
- '!test/e2e-cli/**/*.md'
|
||||||
- 'src/LiveSyncBaseCore.ts'
|
- 'src/LiveSyncBaseCore.ts'
|
||||||
- 'src/common/**'
|
- 'src/common/**'
|
||||||
- 'src/serviceFeatures/**'
|
- 'src/serviceFeatures/**'
|
||||||
@@ -24,9 +26,11 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, synchronize, reopened, ready_for_review]
|
types: [opened, synchronize, reopened, ready_for_review]
|
||||||
paths:
|
paths:
|
||||||
- '.github/workflows/cli-deno-tests.yml'
|
- '.github/workflows/cli-node-tests.yml'
|
||||||
- 'src/apps/cli/**'
|
- 'src/apps/cli/**'
|
||||||
- '!src/apps/cli/**/*.md'
|
- '!src/apps/cli/**/*.md'
|
||||||
|
- 'test/e2e-cli/**'
|
||||||
|
- '!test/e2e-cli/**/*.md'
|
||||||
- 'src/LiveSyncBaseCore.ts'
|
- 'src/LiveSyncBaseCore.ts'
|
||||||
- 'src/common/**'
|
- 'src/common/**'
|
||||||
- 'src/serviceFeatures/**'
|
- 'src/serviceFeatures/**'
|
||||||
@@ -42,7 +46,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
test_task:
|
test_task:
|
||||||
description: 'Deno test task to run'
|
description: 'Node.js test task to run'
|
||||||
type: choice
|
type: choice
|
||||||
options:
|
options:
|
||||||
- test:ci
|
- test:ci
|
||||||
@@ -67,7 +71,7 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
prepare:
|
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
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
env:
|
env:
|
||||||
@@ -89,10 +93,10 @@ jobs:
|
|||||||
|
|
||||||
case "$SELECTED_TASK" in
|
case "$SELECTED_TASK" in
|
||||||
test:ci)
|
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)
|
test:local)
|
||||||
TASK_MATRIX='["test:setup-put-cat","test:mirror","test:daemon"]'
|
TASK_MATRIX='["test:local"]'
|
||||||
;;
|
;;
|
||||||
test:e2e-matrix)
|
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"]'
|
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
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: '24.x'
|
node-version: '26.x'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -127,12 +131,12 @@ jobs:
|
|||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
test:
|
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
|
needs: prepare
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
env:
|
env:
|
||||||
DENO_DIR: ~/.cache/deno
|
NODE_OPTIONS: --max-old-space-size=3072
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -144,23 +148,10 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: '24.x'
|
node-version: '26.x'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: package-lock.json
|
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
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
@@ -184,8 +175,8 @@ jobs:
|
|||||||
bucketName=livesync-test-bucket-ci
|
bucketName=livesync-test-bucket-ci
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
- name: Run Deno tests
|
- name: Run Node.js tests
|
||||||
working-directory: src/apps/cli/testdeno
|
working-directory: test/e2e-cli
|
||||||
env:
|
env:
|
||||||
LIVESYNC_DOCKER_MODE: native
|
LIVESYNC_DOCKER_MODE: native
|
||||||
LIVESYNC_CLI_RETRY: 3
|
LIVESYNC_CLI_RETRY: 3
|
||||||
@@ -194,11 +185,44 @@ jobs:
|
|||||||
LIVESYNC_USE_COTURN: ${{ inputs.use_coturn == true && '1' || '0' }}
|
LIVESYNC_USE_COTURN: ${{ inputs.use_coturn == true && '1' || '0' }}
|
||||||
run: |
|
run: |
|
||||||
TASK="${{ matrix.task }}"
|
TASK="${{ matrix.task }}"
|
||||||
echo "[INFO] Running Deno task: $TASK"
|
echo "[INFO] Running Node.js task: $TASK"
|
||||||
deno task "$TASK"
|
node run-task.ts "$TASK"
|
||||||
|
|
||||||
- name: Stop leftover containers
|
- name: Stop leftover containers
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
docker stop couchdb-test minio-test relay-test coturn-test >/dev/null 2>&1 || true
|
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
|
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
|
||||||
|
env:
|
||||||
|
LIVESYNC_CLI_TEST_SUPPORT: '1'
|
||||||
|
run: npm run build --workspace self-hosted-livesync-cli
|
||||||
|
|
||||||
|
- name: Run local smoke tests
|
||||||
|
working-directory: src/apps/cli
|
||||||
|
run: node ../../../test/e2e-cli/run-task.ts test:local
|
||||||
@@ -9,6 +9,8 @@ on:
|
|||||||
- '.github/workflows/cli-p2p-tests.yml'
|
- '.github/workflows/cli-p2p-tests.yml'
|
||||||
- 'src/apps/cli/**'
|
- 'src/apps/cli/**'
|
||||||
- '!src/apps/cli/**/*.md'
|
- '!src/apps/cli/**/*.md'
|
||||||
|
- 'test/e2e-cli/**'
|
||||||
|
- '!test/e2e-cli/**/*.md'
|
||||||
- 'src/LiveSyncBaseCore.ts'
|
- 'src/LiveSyncBaseCore.ts'
|
||||||
- 'src/common/**'
|
- 'src/common/**'
|
||||||
- 'src/serviceFeatures/**'
|
- 'src/serviceFeatures/**'
|
||||||
@@ -29,6 +31,8 @@ on:
|
|||||||
- '.github/workflows/cli-p2p-tests.yml'
|
- '.github/workflows/cli-p2p-tests.yml'
|
||||||
- 'src/apps/cli/**'
|
- 'src/apps/cli/**'
|
||||||
- '!src/apps/cli/**/*.md'
|
- '!src/apps/cli/**/*.md'
|
||||||
|
- 'test/e2e-cli/**'
|
||||||
|
- '!test/e2e-cli/**/*.md'
|
||||||
- 'src/LiveSyncBaseCore.ts'
|
- 'src/LiveSyncBaseCore.ts'
|
||||||
- 'src/common/**'
|
- 'src/common/**'
|
||||||
- 'src/serviceFeatures/**'
|
- 'src/serviceFeatures/**'
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ on:
|
|||||||
- '.github/workflows/prepare-release.yml'
|
- '.github/workflows/prepare-release.yml'
|
||||||
- '.github/workflows/finalise-release.yml'
|
- '.github/workflows/finalise-release.yml'
|
||||||
- '.github/workflows/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-p2p-tests.yml'
|
||||||
- '.github/workflows/cli-docker-validation.yml'
|
- '.github/workflows/cli-docker-validation.yml'
|
||||||
- '.github/workflows/cli-docker.yml'
|
- '.github/workflows/cli-docker.yml'
|
||||||
@@ -78,7 +78,7 @@ on:
|
|||||||
- '.github/workflows/prepare-release.yml'
|
- '.github/workflows/prepare-release.yml'
|
||||||
- '.github/workflows/finalise-release.yml'
|
- '.github/workflows/finalise-release.yml'
|
||||||
- '.github/workflows/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-p2p-tests.yml'
|
||||||
- '.github/workflows/cli-docker-validation.yml'
|
- '.github/workflows/cli-docker-validation.yml'
|
||||||
- '.github/workflows/cli-docker.yml'
|
- '.github/workflows/cli-docker.yml'
|
||||||
|
|||||||
@@ -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.
|
- 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**:
|
4. **Application Directories**:
|
||||||
- The directory [src/apps](src/apps) contains independent application modules:
|
- 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.
|
- `webapp`: A Web-based application.
|
||||||
- `webpeer`: A Web-based peer utility.
|
- `webpeer`: A Web-based peer utility.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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.
|
- **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.
|
- **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.
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ The current benchmark measures the Node CLI process. It does not establish Obsid
|
|||||||
|
|
||||||
## Reproducible benchmark
|
## 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default defineConfig(
|
|||||||
"**/*.unit.spec.ts",
|
"**/*.unit.spec.ts",
|
||||||
"**/test/**",
|
"**/test/**",
|
||||||
"src/apps/_test/**",
|
"src/apps/_test/**",
|
||||||
"src/apps/cli/testdeno/**",
|
"test/e2e-cli/**",
|
||||||
]),
|
]),
|
||||||
{
|
{
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
|
|||||||
+1
-1
@@ -48,7 +48,7 @@ export default defineConfig([
|
|||||||
"test/**",
|
"test/**",
|
||||||
"**/test/**",
|
"**/test/**",
|
||||||
"src/apps/_test/**",
|
"src/apps/_test/**",
|
||||||
"src/apps/cli/testdeno/**",
|
"test/e2e-cli/**",
|
||||||
"**/*.test.ts",
|
"**/*.test.ts",
|
||||||
"**/*.unit.spec.ts",
|
"**/*.unit.spec.ts",
|
||||||
"**/test.ts",
|
"**/test.ts",
|
||||||
|
|||||||
Generated
+383
@@ -57,6 +57,7 @@
|
|||||||
"@typescript-eslint/parser": "8.69.0",
|
"@typescript-eslint/parser": "8.69.0",
|
||||||
"@vitest/coverage-v8": "^4.1.12",
|
"@vitest/coverage-v8": "^4.1.12",
|
||||||
"@vrtmrz/obsidian-test-session": "0.2.6",
|
"@vrtmrz/obsidian-test-session": "0.2.6",
|
||||||
|
"c8": "^12.0.0",
|
||||||
"dotenv-cli": "^11.0.0",
|
"dotenv-cli": "^11.0.0",
|
||||||
"esbuild": "0.28.1",
|
"esbuild": "0.28.1",
|
||||||
"esbuild-plugin-inline-worker": "^0.1.1",
|
"esbuild-plugin-inline-worker": "^0.1.1",
|
||||||
@@ -1822,6 +1823,16 @@
|
|||||||
"url": "https://github.com/sponsors/nzakas"
|
"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": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
@@ -3427,6 +3438,13 @@
|
|||||||
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
@@ -4275,6 +4293,19 @@
|
|||||||
"url": "https://github.com/sponsors/epoberezkin"
|
"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": {
|
"node_modules/ansi-styles": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
@@ -4617,6 +4648,40 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/call-bind": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
||||||
@@ -4728,6 +4793,39 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"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": {
|
"node_modules/clsx": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
@@ -5093,6 +5191,13 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/empathic": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.1.tgz",
|
||||||
@@ -5429,6 +5534,16 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/escape-string-regexp": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
"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"
|
"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": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.2",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
@@ -6610,6 +6742,29 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/get-intrinsic": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"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"
|
"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": {
|
"node_modules/glob-parent": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||||
@@ -8206,6 +8379,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/ltgt": {
|
||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ltgt/-/ltgt-2.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ltgt/-/ltgt-2.2.1.tgz",
|
||||||
@@ -8371,6 +8554,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/module-replacements": {
|
||||||
"version": "2.11.0",
|
"version": "2.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/module-replacements/-/module-replacements-2.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/module-replacements/-/module-replacements-2.11.0.tgz",
|
||||||
@@ -8798,6 +8991,23 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/pathe": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||||
@@ -9892,6 +10102,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/source-map": {
|
||||||
"version": "0.6.1",
|
"version": "0.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||||
@@ -9986,6 +10209,23 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/string.prototype.matchall": {
|
||||||
"version": "4.0.12",
|
"version": "4.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
|
"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"
|
"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": {
|
"node_modules/strip-bom": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
|
||||||
@@ -10408,6 +10664,21 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/through2": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/through2/-/through2-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/through2/-/through2-3.0.2.tgz",
|
||||||
@@ -10844,6 +11115,21 @@
|
|||||||
"uuid": "dist/esm/bin/uuid"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "8.1.6",
|
"version": "8.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.6.tgz",
|
||||||
@@ -11280,6 +11566,55 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/write-stream": {
|
||||||
"version": "0.4.3",
|
"version": "0.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/write-stream/-/write-stream-0.4.3.tgz",
|
"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==",
|
"integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/yaml": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||||
@@ -11358,6 +11703,44 @@
|
|||||||
"url": "https://github.com/sponsors/ota-meshi"
|
"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": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|||||||
+3
-2
@@ -15,7 +15,7 @@
|
|||||||
"lint:community:tools": "eslint --config eslint.community.config.mjs --concurrency off --max-warnings 0 _tools",
|
"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",
|
"svelte-check": "svelte-check --tsconfig ./tsconfig.json --fail-on-warnings",
|
||||||
"tsc-check": "tsc --noEmit",
|
"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:importpath": "cd utilsdeno && deno run -A ./normalise-imports.ts",
|
||||||
"pretty:json": "prettier --config ./.prettierrc.mjs \"**/*.json\" --write --log-level error",
|
"pretty:json": "prettier --config ./.prettierrc.mjs \"**/*.json\" --write --log-level error",
|
||||||
"pretty": "npm run prettyNoWrite -- --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: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: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: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: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": "npm run test:e2e:ci --workspace self-hosted-livesync-cli",
|
||||||
"test:e2e:cli:p2p": "npm run test:e2e:p2p --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",
|
"@typescript-eslint/parser": "8.69.0",
|
||||||
"@vitest/coverage-v8": "^4.1.12",
|
"@vitest/coverage-v8": "^4.1.12",
|
||||||
"@vrtmrz/obsidian-test-session": "0.2.6",
|
"@vrtmrz/obsidian-test-session": "0.2.6",
|
||||||
|
"c8": "^12.0.0",
|
||||||
"dotenv-cli": "^11.0.0",
|
"dotenv-cli": "^11.0.0",
|
||||||
"esbuild": "0.28.1",
|
"esbuild": "0.28.1",
|
||||||
"esbuild-plugin-inline-worker": "^0.1.1",
|
"esbuild-plugin-inline-worker": "^0.1.1",
|
||||||
|
|||||||
@@ -20,19 +20,21 @@
|
|||||||
"test:e2e:setup-put-cat": "bash test/test-setup-put-cat-linux.sh",
|
"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",
|
"test:e2e:sync-two-local": "bash test/test-sync-two-local-databases-linux.sh",
|
||||||
"pretest:e2e:ci": "npm run build",
|
"pretest:e2e:ci": "npm run build",
|
||||||
"test:e2e:ci": "deno task --cwd testdeno test:ci",
|
"test:e2e:ci": "node ../../../test/e2e-cli/run-ci-suite.ts",
|
||||||
"test:e2e:p2p": "deno task --cwd testdeno test:p2p:compose",
|
"test:e2e:p2p": "node ../../../test/e2e-cli/run-compose-p2p.ts",
|
||||||
"test:e2e:mirror": "bash test/test-mirror-linux.sh",
|
"test:e2e:mirror": "bash test/test-mirror-linux.sh",
|
||||||
"test:e2e:remote-commands": "bash test/test-remote-commands-linux.sh",
|
"test:e2e:remote-commands": "bash test/test-remote-commands-linux.sh",
|
||||||
"pretest:e2e:all": "npm run build",
|
"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",
|
"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: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: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: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: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: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": {
|
"dependencies": {
|
||||||
"chokidar": "^4.0.0",
|
"chokidar": "^4.0.0",
|
||||||
|
|||||||
@@ -1,151 +1,3 @@
|
|||||||
# Writing CLI Tests on Deno
|
# Moved
|
||||||
|
|
||||||
This guide explains how to add or update tests under `src/apps/cli/testdeno/`.
|
The CLI test authoring guide is now [test/e2e-cli/CONTRIBUTING_TESTS.md](../../../../test/e2e-cli/CONTRIBUTING_TESTS.md).
|
||||||
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-<feature>.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.
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Generated
-31
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
type WaitForPortOptions = {
|
|
||||||
timeoutMs?: number;
|
|
||||||
intervalMs?: number;
|
|
||||||
connectTimeoutMs?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function connectWithTimeout(hostname: string, port: number, timeoutMs: number): Promise<void> {
|
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
try {
|
|
||||||
const connPromise = Deno.connect({ hostname, port });
|
|
||||||
const timeoutPromise = new Promise<never>((_, 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<void> {
|
|
||||||
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<string> {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<TempDir> {
|
|
||||||
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<void> {
|
|
||||||
await Deno.remove(this.path, { recursive: true }).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<string, string>): Promise<Deno.CommandStatus> {
|
|
||||||
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.");
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -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 CLI test development guide is now [test/e2e-cli/test_dev_node.md](../../../../test/e2e-cli/test_dev_node.md).
|
||||||
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=<n>`: 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/`.
|
|
||||||
@@ -27,5 +27,5 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["*.ts", "**/*.ts", "**/*.tsx"],
|
"include": ["*.ts", "**/*.ts", "**/*.tsx"],
|
||||||
"exclude": ["node_modules", "dist", "test", "testdeno"]
|
"exclude": ["node_modules", "dist", "test"]
|
||||||
}
|
}
|
||||||
+10
-21
@@ -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<string, string> = {
|
const buildInputs: Record<string, string> = {
|
||||||
index: resolve(__dirname, "entrypoint.ts"),
|
index: resolve(__dirname, "entrypoint.ts"),
|
||||||
};
|
};
|
||||||
@@ -77,7 +57,7 @@ if (process.env.LIVESYNC_CLI_TEST_SUPPORT === "1") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [svelte(), injectBanner()],
|
plugins: [svelte()],
|
||||||
resolve: {
|
resolve: {
|
||||||
// This bundle runs in Node. Vite's client defaults include the `browser`
|
// This bundle runs in Node. Vite's client defaults include the `browser`
|
||||||
// export condition, which would select Commonlib's inline Web Worker.
|
// export condition, which would select Commonlib's inline Web Worker.
|
||||||
@@ -98,7 +78,16 @@ export default defineConfig({
|
|||||||
outDir: "dist",
|
outDir: "dist",
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
minify: false,
|
minify: false,
|
||||||
|
sourcemap: process.env.LIVESYNC_CLI_COVERAGE === "1",
|
||||||
rollupOptions: {
|
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,
|
input: buildInputs,
|
||||||
external: (id) => {
|
external: (id) => {
|
||||||
if (isBuiltin(id)) return true;
|
if (isBuiltin(id)) return true;
|
||||||
|
|||||||
+4
-4
@@ -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) |
|
| 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) |
|
| 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) |
|
| WebApp, WebPeer, and browser interoperability | [Browser application tests](browser-apps/README.md) |
|
||||||
|
|
||||||
## Community Review checks and CI confirmation
|
## Community Review checks and CI confirmation
|
||||||
@@ -58,8 +58,8 @@ Run the native CLI subprocess suite after building the CLI:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
NODE_OPTIONS=--max-old-space-size=3072 npm run build --workspace self-hosted-livesync-cli
|
NODE_OPTIONS=--max-old-space-size=3072 npm run build --workspace self-hosted-livesync-cli
|
||||||
cd src/apps/cli/testdeno
|
cd test/e2e-cli
|
||||||
deno test -A --no-check test-mirror.ts
|
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.
|
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
|
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
|
## Multiple-device conflict regression tests
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
FROM node:24-slim
|
FROM node:26-slim
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates curl unzip python3 make g++ iproute2 libjpeg-turbo-progs time \
|
&& 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/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
ENV DENO_INSTALL=/usr/local
|
|
||||||
RUN curl -fsSL https://deno.land/install.sh | sh
|
|
||||||
|
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
@@ -20,22 +17,10 @@ RUN npm ci
|
|||||||
COPY . .
|
COPY . .
|
||||||
RUN NODE_OPTIONS=--max-old-space-size=3072 LIVESYNC_CLI_TEST_SUPPORT=1 npm run build -w self-hosted-livesync-cli
|
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
|
WORKDIR /workspace/test/e2e-cli
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
COPY test/bench-network/run-bench.sh /usr/local/bin/run-livesync-bench
|
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
|
RUN chmod +x /usr/local/bin/run-livesync-bench /usr/local/bin/run-livesync-cli-e2e
|
||||||
|
|
||||||
CMD ["run-livesync-bench"]
|
CMD ["run-livesync-bench"]
|
||||||
@@ -74,10 +74,10 @@ services:
|
|||||||
BENCH_COMMAND: ${BENCH_COMMAND:-cases}
|
BENCH_COMMAND: ${BENCH_COMMAND:-cases}
|
||||||
BENCH_CASES: ${BENCH_CASES:-couchdb-baseline,p2p-direct-local}
|
BENCH_CASES: ${BENCH_CASES:-couchdb-baseline,p2p-direct-local}
|
||||||
BENCH_REPEAT_COUNT: ${BENCH_REPEAT_COUNT:-1}
|
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_COMPRESSION_RESULT_ROOT: /workspace/src/apps/cli/testdeno/bench-results
|
BENCH_COMPRESSION_RESULT_ROOT: /workspace/test/e2e-cli/bench-results
|
||||||
BENCH_COMPRESSION_REPEAT_COUNT: ${BENCH_COMPRESSION_REPEAT_COUNT:-1}
|
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_RTT_MS: ${BENCH_SWEEP_RTT_MS:-20,50,100,150,300}
|
||||||
BENCH_SWEEP_INCLUDE_P2P: ${BENCH_SWEEP_INCLUDE_P2P:-true}
|
BENCH_SWEEP_INCLUDE_P2P: ${BENCH_SWEEP_INCLUDE_P2P:-true}
|
||||||
BENCH_COUCHDB_MANAGED: "false"
|
BENCH_COUCHDB_MANAGED: "false"
|
||||||
@@ -110,7 +110,7 @@ services:
|
|||||||
TURN_SERVERS: ${TURN_SERVERS:-none}
|
TURN_SERVERS: ${TURN_SERVERS:-none}
|
||||||
LIVESYNC_P2P_PEERS_RETRY: ${LIVESYNC_P2P_PEERS_RETRY:-1}
|
LIVESYNC_P2P_PEERS_RETRY: ${LIVESYNC_P2P_PEERS_RETRY:-1}
|
||||||
volumes:
|
volumes:
|
||||||
- ./bench-results:/workspace/src/apps/cli/testdeno/bench-results
|
- ./bench-results:/workspace/test/e2e-cli/bench-results
|
||||||
|
|
||||||
couchdb-shim:
|
couchdb-shim:
|
||||||
build:
|
build:
|
||||||
@@ -156,8 +156,8 @@ services:
|
|||||||
BENCH_COMMAND: ${BENCH_COMMAND:-cases}
|
BENCH_COMMAND: ${BENCH_COMMAND:-cases}
|
||||||
BENCH_CASES: ${BENCH_CASES:-couchdb-netem-home-wifi}
|
BENCH_CASES: ${BENCH_CASES:-couchdb-netem-home-wifi}
|
||||||
BENCH_REPEAT_COUNT: ${BENCH_REPEAT_COUNT:-1}
|
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_SWEEP_ROOT: /workspace/src/apps/cli/testdeno/bench-results
|
BENCH_SWEEP_ROOT: /workspace/test/e2e-cli/bench-results
|
||||||
BENCH_COUCHDB_MANAGED: "false"
|
BENCH_COUCHDB_MANAGED: "false"
|
||||||
BENCH_COUCHDB_BACKEND_URI: http://couchdb-shim:5984
|
BENCH_COUCHDB_BACKEND_URI: http://couchdb-shim:5984
|
||||||
BENCH_SHIM_COUCHDB_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_SYNC_TIMEOUT: ${BENCH_SYNC_TIMEOUT:-300}
|
||||||
BENCH_LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0}
|
BENCH_LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0}
|
||||||
volumes:
|
volumes:
|
||||||
- ./bench-results:/workspace/src/apps/cli/testdeno/bench-results
|
- ./bench-results:/workspace/test/e2e-cli/bench-results
|
||||||
|
|
||||||
p2p-signalling-shim:
|
p2p-signalling-shim:
|
||||||
build:
|
build:
|
||||||
@@ -219,7 +219,7 @@ services:
|
|||||||
BENCH_COMMAND: ${BENCH_COMMAND:-cases}
|
BENCH_COMMAND: ${BENCH_COMMAND:-cases}
|
||||||
BENCH_CASES: ${BENCH_CASES:-p2p-signalling-netem-home-wifi}
|
BENCH_CASES: ${BENCH_CASES:-p2p-signalling-netem-home-wifi}
|
||||||
BENCH_REPEAT_COUNT: ${BENCH_REPEAT_COUNT:-1}
|
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_SIGNAL_SHIM_RELAY: ws://p2p-signalling-shim:7777/
|
||||||
BENCH_MD_FILE_COUNT: ${BENCH_MD_FILE_COUNT:-20}
|
BENCH_MD_FILE_COUNT: ${BENCH_MD_FILE_COUNT:-20}
|
||||||
BENCH_MD_MIN_SIZE_BYTES: ${BENCH_MD_MIN_SIZE_BYTES:-512}
|
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}
|
LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: ${LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS:-60000}
|
||||||
BENCH_LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0}
|
BENCH_LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0}
|
||||||
volumes:
|
volumes:
|
||||||
- ./bench-results:/workspace/src/apps/cli/testdeno/bench-results
|
- ./bench-results:/workspace/test/e2e-cli/bench-results
|
||||||
|
|
||||||
p2p-split-host:
|
p2p-split-host:
|
||||||
build:
|
build:
|
||||||
@@ -250,7 +250,7 @@ services:
|
|||||||
BENCH_P2P_SPLIT_ROLE: host
|
BENCH_P2P_SPLIT_ROLE: host
|
||||||
BENCH_SPLIT_RUN_ID: ${BENCH_SPLIT_RUN_ID:-bench-split-run}
|
BENCH_SPLIT_RUN_ID: ${BENCH_SPLIT_RUN_ID:-bench-split-run}
|
||||||
BENCH_SPLIT_WORK_ROOT: /p2p-work
|
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_RELAY: ws://nostr-relay:7777/
|
||||||
BENCH_APP_ID: ${BENCH_APP_ID:-self-hosted-livesync-cli-benchmark}
|
BENCH_APP_ID: ${BENCH_APP_ID:-self-hosted-livesync-cli-benchmark}
|
||||||
BENCH_ROOM_ID: ${BENCH_ROOM_ID:-bench-split-room}
|
BENCH_ROOM_ID: ${BENCH_ROOM_ID:-bench-split-room}
|
||||||
@@ -276,7 +276,7 @@ services:
|
|||||||
LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0}
|
LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0}
|
||||||
volumes:
|
volumes:
|
||||||
- p2p-split-work:/p2p-work
|
- 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:
|
p2p-split-client:
|
||||||
build:
|
build:
|
||||||
@@ -296,7 +296,7 @@ services:
|
|||||||
BENCH_P2P_SPLIT_ROLE: client
|
BENCH_P2P_SPLIT_ROLE: client
|
||||||
BENCH_SPLIT_RUN_ID: ${BENCH_SPLIT_RUN_ID:-bench-split-run}
|
BENCH_SPLIT_RUN_ID: ${BENCH_SPLIT_RUN_ID:-bench-split-run}
|
||||||
BENCH_SPLIT_WORK_ROOT: /p2p-work
|
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_RELAY: ws://nostr-relay:7777/
|
||||||
BENCH_APP_ID: ${BENCH_APP_ID:-self-hosted-livesync-cli-benchmark}
|
BENCH_APP_ID: ${BENCH_APP_ID:-self-hosted-livesync-cli-benchmark}
|
||||||
BENCH_ROOM_ID: ${BENCH_ROOM_ID:-bench-split-room}
|
BENCH_ROOM_ID: ${BENCH_ROOM_ID:-bench-split-room}
|
||||||
@@ -317,7 +317,7 @@ services:
|
|||||||
LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0}
|
LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0}
|
||||||
volumes:
|
volumes:
|
||||||
- p2p-split-work:/p2p-work
|
- p2p-split-work:/p2p-work
|
||||||
- ./bench-results:/workspace/src/apps/cli/testdeno/bench-results
|
- ./bench-results:/workspace/test/e2e-cli/bench-results
|
||||||
|
|
||||||
netem-smoke:
|
netem-smoke:
|
||||||
build:
|
build:
|
||||||
|
|||||||
@@ -3,16 +3,16 @@ set -eu
|
|||||||
|
|
||||||
case "${BENCH_COMMAND:-cases}" in
|
case "${BENCH_COMMAND:-cases}" in
|
||||||
cases)
|
cases)
|
||||||
exec deno task bench:cases
|
exec node run-task.ts bench:cases
|
||||||
;;
|
;;
|
||||||
latency-sweep)
|
latency-sweep)
|
||||||
exec deno task bench:latency-sweep
|
exec node run-task.ts bench:latency-sweep
|
||||||
;;
|
;;
|
||||||
compression)
|
compression)
|
||||||
exec deno task bench:compression
|
exec node run-task.ts bench:compression
|
||||||
;;
|
;;
|
||||||
p2p-split-node)
|
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
|
echo "Unknown BENCH_COMMAND: ${BENCH_COMMAND}" >&2
|
||||||
|
|||||||
File renamed without changes.
@@ -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-<feature>.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 -- <task-name>` 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.
|
||||||
@@ -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 { TempDir } from "./helpers/temp.ts";
|
||||||
import { applyRemoteSyncSettings, initSettingsFile } from "./helpers/settings.ts";
|
import { applyRemoteSyncSettings, initSettingsFile } from "./helpers/settings.ts";
|
||||||
import { assertFilesEqual } from "./helpers/cli.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";
|
const COMPRESSED_MARKER = "\u000eLZ\u001d";
|
||||||
|
|
||||||
function readEnvString(name: string, fallback: string): string {
|
function readEnvString(name: string, fallback: string): string {
|
||||||
const value = Deno.env.get(name)?.trim();
|
const value = process.env[name]?.trim();
|
||||||
return value ? value : fallback;
|
return value ? value : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readEnvPositiveInteger(name: string, fallback: number): number {
|
function readEnvPositiveInteger(name: string, fallback: number): number {
|
||||||
const raw = Deno.env.get(name)?.trim();
|
const raw = process.env[name]?.trim();
|
||||||
if (!raw) return fallback;
|
if (!raw) return fallback;
|
||||||
const value = Number(raw);
|
const value = Number(raw);
|
||||||
if (!Number.isInteger(value) || value <= 0) {
|
if (!Number.isInteger(value) || value <= 0) {
|
||||||
@@ -97,7 +99,7 @@ function readEnvPositiveInteger(name: string, fallback: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readEnvPositiveNumber(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;
|
if (!raw) return fallback;
|
||||||
const value = Number(raw);
|
const value = Number(raw);
|
||||||
if (!Number.isFinite(value) || value <= 0) {
|
if (!Number.isFinite(value) || value <= 0) {
|
||||||
@@ -107,7 +109,7 @@ function readEnvPositiveNumber(name: string, fallback: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readEnvBoolean(name: string, fallback: boolean): boolean {
|
function readEnvBoolean(name: string, fallback: boolean): boolean {
|
||||||
const raw = Deno.env.get(name)?.trim();
|
const raw = process.env[name]?.trim();
|
||||||
if (!raw) return fallback;
|
if (!raw) return fallback;
|
||||||
return /^(1|true|yes|on)$/i.test(raw);
|
return /^(1|true|yes|on)$/i.test(raw);
|
||||||
}
|
}
|
||||||
@@ -429,14 +431,14 @@ async function main(): Promise<void> {
|
|||||||
const passphrase = readEnvString("BENCH_PASSPHRASE", "compression-benchmark-passphrase");
|
const passphrase = readEnvString("BENCH_PASSPHRASE", "compression-benchmark-passphrase");
|
||||||
const resultRoot = readEnvString("BENCH_COMPRESSION_RESULT_ROOT", "bench-results");
|
const resultRoot = readEnvString("BENCH_COMPRESSION_RESULT_ROOT", "bench-results");
|
||||||
const resultPath =
|
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`);
|
join(resultRoot, `compression-${new Date().toISOString().replaceAll(":", "-")}.json`);
|
||||||
const createdDatabases = new Set<string>();
|
const createdDatabases = new Set<string>();
|
||||||
const results: RunResult[] = [];
|
const results: RunResult[] = [];
|
||||||
let managedStarted = false;
|
let managedStarted = false;
|
||||||
|
|
||||||
await Deno.mkdir(resultRoot, { recursive: true });
|
await mkdir(resultRoot, { recursive: true });
|
||||||
const proxy = startCouchdbProxy({ backendUri, proxyUri, requestedRttMs });
|
const proxy = await startCouchdbProxy({ backendUri, proxyUri, requestedRttMs });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (let repeatIndex = 1; repeatIndex <= repeatCount; repeatIndex++) {
|
for (let repeatIndex = 1; repeatIndex <= repeatCount; repeatIndex++) {
|
||||||
@@ -457,8 +459,8 @@ async function main(): Promise<void> {
|
|||||||
const vaultB = workDir.join("vault-b");
|
const vaultB = workDir.join("vault-b");
|
||||||
const settingsA = workDir.join("settings-a.json");
|
const settingsA = workDir.join("settings-a.json");
|
||||||
const settingsB = workDir.join("settings-b.json");
|
const settingsB = workDir.join("settings-b.json");
|
||||||
await Deno.mkdir(vaultA, { recursive: true });
|
await mkdir(vaultA, { recursive: true });
|
||||||
await Deno.mkdir(vaultB, { recursive: true });
|
await mkdir(vaultB, { recursive: true });
|
||||||
await initSettingsFile(settingsA);
|
await initSettingsFile(settingsA);
|
||||||
await initSettingsFile(settingsB);
|
await initSettingsFile(settingsB);
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
@@ -502,7 +504,7 @@ async function main(): Promise<void> {
|
|||||||
databaseName,
|
databaseName,
|
||||||
dataset,
|
dataset,
|
||||||
});
|
});
|
||||||
const settings = JSON.parse(await Deno.readTextFile(settingsA)) as Record<string, unknown>;
|
const settings = JSON.parse(await readFile(settingsA, "utf8")) as Record<string, unknown>;
|
||||||
const effectiveSettings = Object.fromEntries(
|
const effectiveSettings = Object.fromEntries(
|
||||||
[
|
[
|
||||||
"encrypt",
|
"encrypt",
|
||||||
@@ -553,8 +555,9 @@ async function main(): Promise<void> {
|
|||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
commonlibVersion: (
|
commonlibVersion: (
|
||||||
JSON.parse(
|
JSON.parse(
|
||||||
await Deno.readTextFile(
|
await readFile(
|
||||||
join(import.meta.dirname!, "../../../../node_modules/@vrtmrz/livesync-commonlib/package.json")
|
join(REPOSITORY_ROOT, "node_modules/@vrtmrz/livesync-commonlib/package.json"),
|
||||||
|
"utf8"
|
||||||
)
|
)
|
||||||
) as { version: string }
|
) as { version: string }
|
||||||
).version,
|
).version,
|
||||||
@@ -581,7 +584,7 @@ async function main(): Promise<void> {
|
|||||||
summary: summariseResults(results),
|
summary: summariseResults(results),
|
||||||
runs: 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.log(JSON.stringify(output, null, 2));
|
||||||
console.error(`[Compression benchmark] wrote ${resultPath}`);
|
console.error(`[Compression benchmark] wrote ${resultPath}`);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -598,6 +601,6 @@ async function main(): Promise<void> {
|
|||||||
if (import.meta.main) {
|
if (import.meta.main) {
|
||||||
main().catch((error) => {
|
main().catch((error) => {
|
||||||
console.error("[Compression benchmark fatal]", error);
|
console.error("[Compression benchmark fatal]", error);
|
||||||
Deno.exit(1);
|
process.exitCode = 1;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import { createServer } from "node:http";
|
||||||
import { TempDir } from "./helpers/temp.ts";
|
import { TempDir } from "./helpers/temp.ts";
|
||||||
import {
|
import {
|
||||||
applyRemoteSyncSettings,
|
applyRemoteSyncSettings,
|
||||||
@@ -48,12 +50,12 @@ type BenchmarkConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function readEnvString(name: string, fallback: string): string {
|
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;
|
return value && value.length > 0 ? value : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readEnvNumber(name: string, fallback: number): number {
|
function readEnvNumber(name: string, fallback: number): number {
|
||||||
const raw = Deno.env.get(name);
|
const raw = process.env[name];
|
||||||
if (raw === undefined || raw.trim() === "") {
|
if (raw === undefined || raw.trim() === "") {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
@@ -66,7 +68,7 @@ function readEnvNumber(name: string, fallback: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readEnvBool(name: string, fallback: boolean): boolean {
|
function readEnvBool(name: string, fallback: boolean): boolean {
|
||||||
const raw = Deno.env.get(name);
|
const raw = process.env[name];
|
||||||
if (raw === undefined || raw.trim() === "") {
|
if (raw === undefined || raw.trim() === "") {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
@@ -74,7 +76,7 @@ function readEnvBool(name: string, fallback: boolean): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readEnvStringArray(name: string, fallback: string[]): string[] {
|
function readEnvStringArray(name: string, fallback: string[]): string[] {
|
||||||
const raw = Deno.env.get(name)?.trim();
|
const raw = process.env[name]?.trim();
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
@@ -171,7 +173,7 @@ function buildConfig(): BenchmarkConfig {
|
|||||||
"This benchmark result is scoped to the configured dataset, remote store, and network model.",
|
"This benchmark result is scoped to the configured dataset, remote store, and network model.",
|
||||||
]),
|
]),
|
||||||
verificationMode: parseBenchmarkVerificationMode(
|
verificationMode: parseBenchmarkVerificationMode(
|
||||||
Deno.env.get("BENCH_VERIFY_MODE"),
|
process.env.BENCH_VERIFY_MODE,
|
||||||
),
|
),
|
||||||
repeatIndex: Math.floor(readEnvNumber("BENCH_REPEAT_INDEX", 1)),
|
repeatIndex: Math.floor(readEnvNumber("BENCH_REPEAT_INDEX", 1)),
|
||||||
repeatCount: Math.floor(readEnvNumber("BENCH_REPEAT_COUNT", 1)),
|
repeatCount: Math.floor(readEnvNumber("BENCH_REPEAT_COUNT", 1)),
|
||||||
@@ -179,7 +181,7 @@ function buildConfig(): BenchmarkConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readOptionalResultPath(): string | undefined {
|
function readOptionalResultPath(): string | undefined {
|
||||||
const raw = Deno.env.get("BENCH_RESULT_JSON")?.trim();
|
const raw = process.env.BENCH_RESULT_JSON?.trim();
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -201,52 +203,51 @@ export type CouchdbProxyCounters = {
|
|||||||
responseBodyBytes: number;
|
responseBodyBytes: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function startCouchdbProxy(
|
export async function startCouchdbProxy(
|
||||||
options: {
|
options: {
|
||||||
backendUri: string;
|
backendUri: string;
|
||||||
proxyUri: string;
|
proxyUri: string;
|
||||||
requestedRttMs: number;
|
requestedRttMs: number;
|
||||||
delay?: (milliseconds: number) => Promise<void>;
|
delay?: (milliseconds: number) => Promise<void>;
|
||||||
},
|
},
|
||||||
): CouchdbProxyHandle {
|
): Promise<CouchdbProxyHandle> {
|
||||||
const backend = new URL(options.backendUri);
|
const backend = new URL(options.backendUri);
|
||||||
const proxy = new URL(options.proxyUri);
|
const proxy = new URL(options.proxyUri);
|
||||||
const halfDelayMs = options.requestedRttMs / 2;
|
const halfDelayMs = options.requestedRttMs / 2;
|
||||||
const delay = options.delay ??
|
const delay = options.delay ??
|
||||||
((milliseconds: number) =>
|
((milliseconds: number) =>
|
||||||
new Promise<void>((resolve) => setTimeout(resolve, milliseconds)));
|
new Promise<void>((resolve) => setTimeout(resolve, milliseconds)));
|
||||||
const controller = new AbortController();
|
|
||||||
const counters: CouchdbProxyCounters = {
|
const counters: CouchdbProxyCounters = {
|
||||||
requestCount: 0,
|
requestCount: 0,
|
||||||
requestBodyBytes: 0,
|
requestBodyBytes: 0,
|
||||||
responseBodyBytes: 0,
|
responseBodyBytes: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const listener = Deno.serve(
|
const server = createServer(async (request, response) => {
|
||||||
{
|
try {
|
||||||
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) => {
|
|
||||||
await delay(halfDelayMs);
|
await delay(halfDelayMs);
|
||||||
|
|
||||||
const targetUrl = new URL(request.url);
|
const targetUrl = new URL(request.url ?? "/", proxy);
|
||||||
targetUrl.protocol = backend.protocol;
|
targetUrl.protocol = backend.protocol;
|
||||||
targetUrl.host = backend.host;
|
targetUrl.host = backend.host;
|
||||||
|
|
||||||
const headers = new Headers(request.headers);
|
const headers = new Headers();
|
||||||
headers.delete("host");
|
for (const [name, value] of Object.entries(request.headers)) {
|
||||||
headers.delete("content-length");
|
if (value === undefined || name === "host" || name === "content-length") continue;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
let requestBody: ArrayBuffer | undefined;
|
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") {
|
if (request.method !== "GET" && request.method !== "HEAD") {
|
||||||
try {
|
try {
|
||||||
requestBody = await request.arrayBuffer();
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of request as AsyncIterable<Buffer | string>) {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||||
|
}
|
||||||
|
requestBody = Buffer.concat(chunks);
|
||||||
} catch {
|
} catch {
|
||||||
requestBody = undefined;
|
requestBody = undefined;
|
||||||
}
|
}
|
||||||
@@ -257,7 +258,7 @@ export function startCouchdbProxy(
|
|||||||
const upstream = await fetch(targetUrl, {
|
const upstream = await fetch(targetUrl, {
|
||||||
method: request.method,
|
method: request.method,
|
||||||
headers,
|
headers,
|
||||||
body: requestBody,
|
body: requestBody === undefined ? undefined : Uint8Array.from(requestBody),
|
||||||
redirect: "manual",
|
redirect: "manual",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -267,13 +268,33 @@ export function startCouchdbProxy(
|
|||||||
counters.responseBodyBytes += responseBody.byteLength;
|
counters.responseBodyBytes += responseBody.byteLength;
|
||||||
await delay(halfDelayMs);
|
await delay(halfDelayMs);
|
||||||
|
|
||||||
return new Response(responseBody, {
|
response.statusCode = upstream.status;
|
||||||
status: upstream.status,
|
response.statusMessage = upstream.statusText;
|
||||||
statusText: upstream.statusText,
|
responseHeaders.forEach((value, name) => response.setHeader(name, value));
|
||||||
headers: responseHeaders,
|
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<void>((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 {
|
return {
|
||||||
applied: true,
|
applied: true,
|
||||||
@@ -287,8 +308,9 @@ export function startCouchdbProxy(
|
|||||||
},
|
},
|
||||||
snapshotCounters: () => ({ ...counters }),
|
snapshotCounters: () => ({ ...counters }),
|
||||||
stop: async () => {
|
stop: async () => {
|
||||||
controller.abort();
|
await new Promise<void>((resolve, reject) => {
|
||||||
await listener.finished.catch(() => {});
|
server.close((error) => error ? reject(error) : resolve());
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -302,8 +324,8 @@ async function main(): Promise<void> {
|
|||||||
const vaultB = workDir.join("vault-b");
|
const vaultB = workDir.join("vault-b");
|
||||||
const settingsA = workDir.join("settings-a.json");
|
const settingsA = workDir.join("settings-a.json");
|
||||||
const settingsB = workDir.join("settings-b.json");
|
const settingsB = workDir.join("settings-b.json");
|
||||||
await Deno.mkdir(vaultA, { recursive: true });
|
await mkdir(vaultA, { recursive: true });
|
||||||
await Deno.mkdir(vaultB, { recursive: true });
|
await mkdir(vaultB, { recursive: true });
|
||||||
|
|
||||||
await initSettingsFile(settingsA);
|
await initSettingsFile(settingsA);
|
||||||
await initSettingsFile(settingsB);
|
await initSettingsFile(settingsB);
|
||||||
@@ -327,7 +349,7 @@ async function main(): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const proxy = startCouchdbProxy({
|
const proxy = await startCouchdbProxy({
|
||||||
backendUri: config.couchdbBackendUri,
|
backendUri: config.couchdbBackendUri,
|
||||||
proxyUri: config.couchdbProxyUri,
|
proxyUri: config.couchdbProxyUri,
|
||||||
requestedRttMs: config.requestedRttMs,
|
requestedRttMs: config.requestedRttMs,
|
||||||
@@ -448,7 +470,7 @@ async function main(): Promise<void> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (resultPath) {
|
if (resultPath) {
|
||||||
await Deno.writeTextFile(
|
await writeFile(
|
||||||
resultPath,
|
resultPath,
|
||||||
JSON.stringify(result, null, 2),
|
JSON.stringify(result, null, 2),
|
||||||
);
|
);
|
||||||
@@ -477,6 +499,6 @@ async function main(): Promise<void> {
|
|||||||
if (import.meta.main) {
|
if (import.meta.main) {
|
||||||
main().catch((error) => {
|
main().catch((error) => {
|
||||||
console.error(`[Fatal Error]`, error);
|
console.error(`[Fatal Error]`, error);
|
||||||
Deno.exit(1);
|
process.exitCode = 1;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
+18
-15
@@ -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 = {
|
type SweepResult = {
|
||||||
name: string;
|
name: string;
|
||||||
runner: "p2p" | "couchdb";
|
runner: "p2p" | "couchdb";
|
||||||
@@ -8,7 +13,7 @@ type SweepResult = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function readEnvString(name: string, fallback: string): string {
|
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;
|
return value && value.length > 0 ? value : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,9 +73,8 @@ async function runBenchmark(options: {
|
|||||||
const suffix = options.repeatCount > 1
|
const suffix = options.repeatCount > 1
|
||||||
? `-r${String(options.repeatIndex).padStart(2, "0")}`
|
? `-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 = {
|
const env = {
|
||||||
...Deno.env.toObject(),
|
|
||||||
...options.env,
|
...options.env,
|
||||||
BENCH_RESULT_JSON: resultPath,
|
BENCH_RESULT_JSON: resultPath,
|
||||||
BENCH_REPEAT_INDEX: String(options.repeatIndex),
|
BENCH_REPEAT_INDEX: String(options.repeatIndex),
|
||||||
@@ -78,29 +82,28 @@ async function runBenchmark(options: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
console.log(`[latency-sweep] running ${options.name}`);
|
console.log(`[latency-sweep] running ${options.name}`);
|
||||||
const child = new Deno.Command("deno", {
|
const child = await runProcess(process.execPath, {
|
||||||
args: ["task", options.taskName],
|
args: [fileURLToPath(new URL("./run-task.ts", import.meta.url)), options.taskName],
|
||||||
cwd: import.meta.dirname,
|
cwd: import.meta.dirname,
|
||||||
env,
|
env,
|
||||||
stdin: "null",
|
stdin: "null",
|
||||||
stdout: "inherit",
|
stdout: "inherit",
|
||||||
stderr: "inherit",
|
stderr: "inherit",
|
||||||
}).spawn();
|
});
|
||||||
const status = await child.status;
|
if (!child.success) {
|
||||||
if (status.code !== 0) {
|
throw new Error(`benchmark failed: ${options.name} (exit ${child.code})`);
|
||||||
throw new Error(`benchmark failed: ${options.name} (exit ${status.code})`);
|
|
||||||
}
|
}
|
||||||
return JSON.parse(await Deno.readTextFile(resultPath)) as Record<string, unknown>;
|
return JSON.parse(await readFile(resultPath, "utf8")) as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const outRoot = readEnvString("BENCH_SWEEP_ROOT", `${import.meta.dirname}/bench-results`);
|
const outRoot = readEnvString("BENCH_SWEEP_ROOT", join(import.meta.dirname, "bench-results"));
|
||||||
const outputDir = `${outRoot}/latency-sweep-${timestamp()}`;
|
const outputDir = join(outRoot, `latency-sweep-${timestamp()}`);
|
||||||
const rtts = parseRttList(readEnvString("BENCH_SWEEP_RTT_MS", "20,50,100,150,300"));
|
const rtts = parseRttList(readEnvString("BENCH_SWEEP_RTT_MS", "20,50,100,150,300"));
|
||||||
const repeatCount = readEnvInteger("BENCH_REPEAT_COUNT", 1);
|
const repeatCount = readEnvInteger("BENCH_REPEAT_COUNT", 1);
|
||||||
const base = buildBaseEnv();
|
const base = buildBaseEnv();
|
||||||
|
|
||||||
await Deno.mkdir(outputDir, { recursive: true });
|
await mkdir(outputDir, { recursive: true });
|
||||||
|
|
||||||
const results: SweepResult[] = [];
|
const results: SweepResult[] = [];
|
||||||
if (readEnvString("BENCH_SWEEP_INCLUDE_P2P", "true") !== "false") {
|
if (readEnvString("BENCH_SWEEP_INCLUDE_P2P", "true") !== "false") {
|
||||||
@@ -162,7 +165,7 @@ async function main(): Promise<void> {
|
|||||||
repeatCount,
|
repeatCount,
|
||||||
results,
|
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(JSON.stringify(summary, null, 2));
|
||||||
console.log(`[latency-sweep] result directory: ${outputDir}`);
|
console.log(`[latency-sweep] result directory: ${outputDir}`);
|
||||||
}
|
}
|
||||||
@@ -170,6 +173,6 @@ async function main(): Promise<void> {
|
|||||||
if (import.meta.main) {
|
if (import.meta.main) {
|
||||||
main().catch((error) => {
|
main().catch((error) => {
|
||||||
console.error("[Fatal Error]", error);
|
console.error("[Fatal Error]", error);
|
||||||
Deno.exit(1);
|
process.exitCode = 1;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
+20
-18
@@ -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 = {
|
export type BenchmarkCase = {
|
||||||
name: string;
|
name: string;
|
||||||
runner: "p2p" | "couchdb";
|
runner: "p2p" | "couchdb";
|
||||||
@@ -10,7 +15,7 @@ export type BenchmarkCase = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function readEnvString(name: string, fallback: string): string {
|
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;
|
return value && value.length > 0 ? value : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,10 +329,9 @@ async function runCase(
|
|||||||
const suffix = repeatCount > 1
|
const suffix = repeatCount > 1
|
||||||
? `-r${String(repeatIndex).padStart(2, "0")}`
|
? `-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 taskName = testCase.runner === "p2p" ? "bench:p2p" : "bench:couchdb";
|
||||||
const env = {
|
const env = {
|
||||||
...Deno.env.toObject(),
|
|
||||||
...testCase.env,
|
...testCase.env,
|
||||||
BENCH_RESULT_JSON: resultPath,
|
BENCH_RESULT_JSON: resultPath,
|
||||||
BENCH_REPEAT_INDEX: String(repeatIndex),
|
BENCH_REPEAT_INDEX: String(repeatIndex),
|
||||||
@@ -340,8 +344,8 @@ async function runCase(
|
|||||||
console.log(
|
console.log(
|
||||||
`[bench-cases] running ${testCase.name}${repeatLabel}: ${testCase.description}`,
|
`[bench-cases] running ${testCase.name}${repeatLabel}: ${testCase.description}`,
|
||||||
);
|
);
|
||||||
const command = new Deno.Command("deno", {
|
const child = await runProcess(process.execPath, {
|
||||||
args: ["task", taskName],
|
args: [fileURLToPath(new URL("./run-task.ts", import.meta.url)), taskName],
|
||||||
cwd: import.meta.dirname,
|
cwd: import.meta.dirname,
|
||||||
env,
|
env,
|
||||||
stdin: "null",
|
stdin: "null",
|
||||||
@@ -349,13 +353,11 @@ async function runCase(
|
|||||||
stderr: "inherit",
|
stderr: "inherit",
|
||||||
});
|
});
|
||||||
|
|
||||||
const child = command.spawn();
|
if (!child.success) {
|
||||||
const status = await child.status;
|
throw new Error(`case failed: ${testCase.name} (exit ${child.code})`);
|
||||||
if (status.code !== 0) {
|
|
||||||
throw new Error(`case failed: ${testCase.name} (exit ${status.code})`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = JSON.parse(await Deno.readTextFile(resultPath)) as Record<
|
const result = JSON.parse(await readFile(resultPath, "utf8")) as Record<
|
||||||
string,
|
string,
|
||||||
unknown
|
unknown
|
||||||
>;
|
>;
|
||||||
@@ -394,16 +396,16 @@ function selectCases(allCases: BenchmarkCase[]): BenchmarkCase[] {
|
|||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const outRoot = readEnvString(
|
const outRoot = readEnvString(
|
||||||
"BENCH_CASES_ROOT",
|
"BENCH_CASES_ROOT",
|
||||||
`${import.meta.dirname}/bench-results`,
|
join(import.meta.dirname, "bench-results"),
|
||||||
);
|
);
|
||||||
const outputDir = `${outRoot}/cases-${timestamp()}`;
|
const outputDir = join(outRoot, `cases-${timestamp()}`);
|
||||||
await Deno.mkdir(outputDir, { recursive: true });
|
await mkdir(outputDir, { recursive: true });
|
||||||
|
|
||||||
const allCases = buildCases();
|
const allCases = buildCases();
|
||||||
const cases = selectCases(allCases);
|
const cases = selectCases(allCases);
|
||||||
const repeatCount = readEnvInteger("BENCH_REPEAT_COUNT", 1);
|
const repeatCount = readEnvInteger("BENCH_REPEAT_COUNT", 1);
|
||||||
await Deno.writeTextFile(
|
await writeFile(
|
||||||
`${outputDir}/case-manifest.json`,
|
join(outputDir, "case-manifest.json"),
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
@@ -431,8 +433,8 @@ async function main(): Promise<void> {
|
|||||||
repeatCount,
|
repeatCount,
|
||||||
results,
|
results,
|
||||||
};
|
};
|
||||||
await Deno.writeTextFile(
|
await writeFile(
|
||||||
`${outputDir}/summary.json`,
|
join(outputDir, "summary.json"),
|
||||||
JSON.stringify(summary, null, 2),
|
JSON.stringify(summary, null, 2),
|
||||||
);
|
);
|
||||||
console.log(JSON.stringify(summary, null, 2));
|
console.log(JSON.stringify(summary, null, 2));
|
||||||
@@ -442,6 +444,6 @@ async function main(): Promise<void> {
|
|||||||
if (import.meta.main) {
|
if (import.meta.main) {
|
||||||
main().catch((error) => {
|
main().catch((error) => {
|
||||||
console.error("[Fatal Error]", error);
|
console.error("[Fatal Error]", error);
|
||||||
Deno.exit(1);
|
process.exitCode = 1;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
+35
-33
@@ -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 { startCliInBackground } from "./helpers/backgroundCli.ts";
|
||||||
import { assertFilesEqual, runCliOrFail } from "./helpers/cli.ts";
|
import { assertFilesEqual, runCliOrFail } from "./helpers/cli.ts";
|
||||||
import { createDeterministicDataset, type DatasetEntry } from "./helpers/dataset.ts";
|
import { createDeterministicDataset, type DatasetEntry } from "./helpers/dataset.ts";
|
||||||
import { discoverPeer } from "./helpers/p2p.ts";
|
import { discoverPeer } from "./helpers/p2p.ts";
|
||||||
import { applyP2pSettings, applyP2pTestTweaks, initSettingsFile } from "./helpers/settings.ts";
|
import { applyP2pSettings, applyP2pTestTweaks, initSettingsFile } from "./helpers/settings.ts";
|
||||||
|
import { runProcess } from "./helpers/process.ts";
|
||||||
|
|
||||||
type Role = "host" | "client";
|
type Role = "host" | "client";
|
||||||
|
|
||||||
@@ -53,12 +55,12 @@ function errorToRecord(error: unknown): Record<string, unknown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readEnvString(name: string, fallback: string): string {
|
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;
|
return value && value.length > 0 ? value : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readEnvNumber(name: string, fallback: number): number {
|
function readEnvNumber(name: string, fallback: number): number {
|
||||||
const raw = Deno.env.get(name);
|
const raw = process.env[name];
|
||||||
if (raw === undefined || raw.trim() === "") {
|
if (raw === undefined || raw.trim() === "") {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
@@ -74,12 +76,12 @@ function nowMs(): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function commandOutput(command: string, args: string[]): Promise<string> {
|
async function commandOutput(command: string, args: string[]): Promise<string> {
|
||||||
const output = await new Deno.Command(command, {
|
const output = await runProcess(command, {
|
||||||
args,
|
args,
|
||||||
stdin: "null",
|
stdin: "null",
|
||||||
stdout: "piped",
|
stdout: "piped",
|
||||||
stderr: "piped",
|
stderr: "piped",
|
||||||
}).output();
|
});
|
||||||
const stdout = new TextDecoder().decode(output.stdout);
|
const stdout = new TextDecoder().decode(output.stdout);
|
||||||
const stderr = new TextDecoder().decode(output.stderr);
|
const stderr = new TextDecoder().decode(output.stderr);
|
||||||
if (!output.success) {
|
if (!output.success) {
|
||||||
@@ -118,7 +120,7 @@ async function applyNetemIfRequested(): Promise<NetemSummary> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await commandOk("ip", ["link", "set", "dev", iface, "mtu", String(mtu)]);
|
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", [
|
await commandOk("tc", [
|
||||||
"qdisc",
|
"qdisc",
|
||||||
"add",
|
"add",
|
||||||
@@ -144,8 +146,8 @@ async function waitForFile(path: string, timeoutMs: number): Promise<void> {
|
|||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
while (Date.now() - started < timeoutMs) {
|
while (Date.now() - started < timeoutMs) {
|
||||||
try {
|
try {
|
||||||
const stat = await Deno.stat(path);
|
const fileStat = await stat(path);
|
||||||
if (stat.isFile) {
|
if (fileStat.isFile()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -157,7 +159,7 @@ async function waitForFile(path: string, timeoutMs: number): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function readJsonFile<T>(path: string): Promise<T> {
|
async function readJsonFile<T>(path: string): Promise<T> {
|
||||||
return JSON.parse(await Deno.readTextFile(path)) as T;
|
return JSON.parse(await readFile(path, "utf8")) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickSampleFiles(entries: DatasetEntry[]): DatasetEntry[] {
|
function pickSampleFiles(entries: DatasetEntry[]): DatasetEntry[] {
|
||||||
@@ -172,7 +174,7 @@ function pickSampleFiles(entries: DatasetEntry[]): DatasetEntry[] {
|
|||||||
|
|
||||||
async function readLatestP2PConnectionStats(path: string): Promise<P2PConnectionStats | undefined> {
|
async function readLatestP2PConnectionStats(path: string): Promise<P2PConnectionStats | undefined> {
|
||||||
try {
|
try {
|
||||||
const lines = (await Deno.readTextFile(path))
|
const lines = (await readFile(path, "utf8"))
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.filter((line) => line.length > 0);
|
.filter((line) => line.length > 0);
|
||||||
@@ -188,7 +190,7 @@ function buildCommonConfig() {
|
|||||||
return {
|
return {
|
||||||
runId,
|
runId,
|
||||||
workRoot: join(baseWorkRoot, 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/"),
|
relay: readEnvString("BENCH_RELAY", "ws://nostr-relay:7777/"),
|
||||||
appId: readEnvString("BENCH_APP_ID", "self-hosted-livesync-cli-benchmark"),
|
appId: readEnvString("BENCH_APP_ID", "self-hosted-livesync-cli-benchmark"),
|
||||||
roomId: readEnvString("BENCH_ROOM_ID", "bench-split-room"),
|
roomId: readEnvString("BENCH_ROOM_ID", "bench-split-room"),
|
||||||
@@ -229,12 +231,12 @@ async function prepareP2PSettings(
|
|||||||
async function runHost(): Promise<void> {
|
async function runHost(): Promise<void> {
|
||||||
const config = buildCommonConfig();
|
const config = buildCommonConfig();
|
||||||
const netem = await applyNetemIfRequested();
|
const netem = await applyNetemIfRequested();
|
||||||
await Deno.mkdir(config.workRoot, { recursive: true });
|
await mkdir(config.workRoot, { recursive: true });
|
||||||
await Deno.mkdir(config.resultRoot, { recursive: true });
|
await mkdir(config.resultRoot, { recursive: true });
|
||||||
|
|
||||||
const hostVault = join(config.workRoot, "vault-host");
|
const hostVault = join(config.workRoot, "vault-host");
|
||||||
const hostSettings = join(config.workRoot, "settings-host.json");
|
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);
|
await prepareP2PSettings(hostSettings, "p2p-split-host", config);
|
||||||
|
|
||||||
const seedFiles = await createDeterministicDataset({
|
const seedFiles = await createDeterministicDataset({
|
||||||
@@ -247,7 +249,7 @@ async function runHost(): Promise<void> {
|
|||||||
binCount: config.binFileCount,
|
binCount: config.binFileCount,
|
||||||
binSizeBytes: config.binSizeBytes,
|
binSizeBytes: config.binSizeBytes,
|
||||||
});
|
});
|
||||||
await Deno.writeTextFile(
|
await writeFile(
|
||||||
join(config.workRoot, "sample-files.json"),
|
join(config.workRoot, "sample-files.json"),
|
||||||
JSON.stringify(pickSampleFiles(seedFiles.entries), null, 2)
|
JSON.stringify(pickSampleFiles(seedFiles.entries), null, 2)
|
||||||
);
|
);
|
||||||
@@ -264,12 +266,12 @@ async function runHost(): Promise<void> {
|
|||||||
mirrorElapsedMs,
|
mirrorElapsedMs,
|
||||||
netem,
|
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");
|
const host = startCliInBackground(hostVault, "--settings", hostSettings, "p2p-host");
|
||||||
try {
|
try {
|
||||||
await host.waitUntilContains("P2P host is running", 20_000);
|
await host.waitUntilContains("P2P host is running", 20_000);
|
||||||
await Deno.writeTextFile(
|
await writeFile(
|
||||||
join(config.workRoot, "p2p-host-ready.json"),
|
join(config.workRoot, "p2p-host-ready.json"),
|
||||||
JSON.stringify({ generatedAt: new Date().toISOString() })
|
JSON.stringify({ generatedAt: new Date().toISOString() })
|
||||||
);
|
);
|
||||||
@@ -282,23 +284,23 @@ async function runHost(): Promise<void> {
|
|||||||
async function runClient(): Promise<void> {
|
async function runClient(): Promise<void> {
|
||||||
const config = buildCommonConfig();
|
const config = buildCommonConfig();
|
||||||
const netem = await applyNetemIfRequested();
|
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, "host-ready.json"), config.nodeTimeoutMs);
|
||||||
await waitForFile(join(config.workRoot, "p2p-host-ready.json"), config.nodeTimeoutMs);
|
await waitForFile(join(config.workRoot, "p2p-host-ready.json"), config.nodeTimeoutMs);
|
||||||
|
|
||||||
const clientVault = join(config.workRoot, "vault-client");
|
const clientVault = join(config.workRoot, "vault-client");
|
||||||
const clientSettings = join(config.workRoot, "settings-client.json");
|
const clientSettings = join(config.workRoot, "settings-client.json");
|
||||||
const statsPath = join(config.workRoot, "p2p-connection-stats.jsonl");
|
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);
|
await prepareP2PSettings(clientSettings, "p2p-split-client", config);
|
||||||
|
|
||||||
const hostReady = await readJsonFile<HostReady>(join(config.workRoot, "host-ready.json"));
|
const hostReady = await readJsonFile<HostReady>(join(config.workRoot, "host-ready.json"));
|
||||||
const timestamp = new Date().toISOString().replace(/[-:]/g, "").slice(0, 15);
|
const timestamp = new Date().toISOString().replace(/[-:]/g, "").slice(0, 15);
|
||||||
const outputDir = join(config.resultRoot, `p2p-split-${config.profile}-${timestamp}`);
|
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");
|
const previousStatsPath = process.env.LIVESYNC_P2P_STATS_JSONL;
|
||||||
Deno.env.set("LIVESYNC_P2P_STATS_JSONL", statsPath);
|
process.env.LIVESYNC_P2P_STATS_JSONL = statsPath;
|
||||||
let stage = "peer-discovery";
|
let stage = "peer-discovery";
|
||||||
let peerDiscoveryCommandElapsedMs: number | undefined;
|
let peerDiscoveryCommandElapsedMs: number | undefined;
|
||||||
let syncElapsedMs: number | undefined;
|
let syncElapsedMs: number | undefined;
|
||||||
@@ -338,10 +340,10 @@ async function runClient(): Promise<void> {
|
|||||||
caseName: "p2p-split-compose",
|
caseName: "p2p-split-compose",
|
||||||
mode: "p2p-split-compose-benchmark",
|
mode: "p2p-split-compose-benchmark",
|
||||||
runId: config.runId,
|
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,
|
networkProfile: config.profile,
|
||||||
networkModel:
|
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,
|
relay: config.relay,
|
||||||
turnServers: config.turnServers,
|
turnServers: config.turnServers,
|
||||||
turnEnabled: config.turnServers.trim().length > 0,
|
turnEnabled: config.turnServers.trim().length > 0,
|
||||||
@@ -360,8 +362,8 @@ async function runClient(): Promise<void> {
|
|||||||
throughputBytesPerSec: Number((hostReady.totalBytes / (syncElapsedMs / 1000)).toFixed(2)),
|
throughputBytesPerSec: Number((hostReady.totalBytes / (syncElapsedMs / 1000)).toFixed(2)),
|
||||||
throughputMiBPerSec: Number((hostReady.totalBytes / (syncElapsedMs / 1000) / 1024 / 1024).toFixed(4)),
|
throughputMiBPerSec: Number((hostReady.totalBytes / (syncElapsedMs / 1000) / 1024 / 1024).toFixed(4)),
|
||||||
};
|
};
|
||||||
await Deno.writeTextFile(join(outputDir, "summary.json"), JSON.stringify(result, null, 2));
|
await writeFile(join(outputDir, "summary.json"), JSON.stringify(result, null, 2));
|
||||||
await Deno.writeTextFile(
|
await writeFile(
|
||||||
join(config.workRoot, "client-done.json"),
|
join(config.workRoot, "client-done.json"),
|
||||||
JSON.stringify({ generatedAt: new Date().toISOString(), outputDir, ok: true })
|
JSON.stringify({ generatedAt: new Date().toISOString(), outputDir, ok: true })
|
||||||
);
|
);
|
||||||
@@ -374,10 +376,10 @@ async function runClient(): Promise<void> {
|
|||||||
caseName: "p2p-split-compose",
|
caseName: "p2p-split-compose",
|
||||||
mode: "p2p-split-compose-benchmark",
|
mode: "p2p-split-compose-benchmark",
|
||||||
runId: config.runId,
|
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,
|
networkProfile: config.profile,
|
||||||
networkModel:
|
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,
|
relay: config.relay,
|
||||||
turnServers: config.turnServers,
|
turnServers: config.turnServers,
|
||||||
turnEnabled: config.turnServers.trim().length > 0,
|
turnEnabled: config.turnServers.trim().length > 0,
|
||||||
@@ -398,8 +400,8 @@ async function runClient(): Promise<void> {
|
|||||||
...errorToRecord(error),
|
...errorToRecord(error),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
await Deno.writeTextFile(join(outputDir, "summary.json"), JSON.stringify(result, null, 2));
|
await writeFile(join(outputDir, "summary.json"), JSON.stringify(result, null, 2));
|
||||||
await Deno.writeTextFile(
|
await writeFile(
|
||||||
join(config.workRoot, "client-done.json"),
|
join(config.workRoot, "client-done.json"),
|
||||||
JSON.stringify({ generatedAt: new Date().toISOString(), outputDir, ok: false })
|
JSON.stringify({ generatedAt: new Date().toISOString(), outputDir, ok: false })
|
||||||
);
|
);
|
||||||
@@ -407,9 +409,9 @@ async function runClient(): Promise<void> {
|
|||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
if (previousStatsPath === undefined) {
|
if (previousStatsPath === undefined) {
|
||||||
Deno.env.delete("LIVESYNC_P2P_STATS_JSONL");
|
delete process.env.LIVESYNC_P2P_STATS_JSONL;
|
||||||
} else {
|
} else {
|
||||||
Deno.env.set("LIVESYNC_P2P_STATS_JSONL", previousStatsPath);
|
process.env.LIVESYNC_P2P_STATS_JSONL = previousStatsPath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -430,6 +432,6 @@ async function main(): Promise<void> {
|
|||||||
if (import.meta.main) {
|
if (import.meta.main) {
|
||||||
main().catch((error) => {
|
main().catch((error) => {
|
||||||
console.error("[Fatal Error]", error);
|
console.error("[Fatal Error]", error);
|
||||||
Deno.exit(1);
|
process.exitCode = 1;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
import { TempDir } from "./helpers/temp.ts";
|
import { TempDir } from "./helpers/temp.ts";
|
||||||
import {
|
import {
|
||||||
applyP2pSettings,
|
applyP2pSettings,
|
||||||
@@ -82,12 +83,12 @@ type P2PConnectionStats = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function readEnvString(name: string, fallback: string): string {
|
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;
|
return value && value.length > 0 ? value : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readEnvNumber(name: string, fallback: number): number {
|
function readEnvNumber(name: string, fallback: number): number {
|
||||||
const raw = Deno.env.get(name);
|
const raw = process.env[name];
|
||||||
if (raw === undefined || raw.trim() === "") {
|
if (raw === undefined || raw.trim() === "") {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
@@ -100,7 +101,7 @@ function readEnvNumber(name: string, fallback: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readEnvStringArray(name: string, fallback: string[]): string[] {
|
function readEnvStringArray(name: string, fallback: string[]): string[] {
|
||||||
const raw = Deno.env.get(name)?.trim();
|
const raw = process.env[name]?.trim();
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
@@ -187,7 +188,7 @@ function buildConfig(): BenchmarkConfig {
|
|||||||
"This benchmark result is scoped to the configured dataset, network model, and selected ICE path.",
|
"This benchmark result is scoped to the configured dataset, network model, and selected ICE path.",
|
||||||
]),
|
]),
|
||||||
verificationMode: parseBenchmarkVerificationMode(
|
verificationMode: parseBenchmarkVerificationMode(
|
||||||
Deno.env.get("BENCH_VERIFY_MODE"),
|
process.env.BENCH_VERIFY_MODE,
|
||||||
),
|
),
|
||||||
repeatIndex: Math.floor(readEnvNumber("BENCH_REPEAT_INDEX", 1)),
|
repeatIndex: Math.floor(readEnvNumber("BENCH_REPEAT_INDEX", 1)),
|
||||||
repeatCount: Math.floor(readEnvNumber("BENCH_REPEAT_COUNT", 1)),
|
repeatCount: Math.floor(readEnvNumber("BENCH_REPEAT_COUNT", 1)),
|
||||||
@@ -195,7 +196,7 @@ function buildConfig(): BenchmarkConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readOptionalResultPath(): string | undefined {
|
function readOptionalResultPath(): string | undefined {
|
||||||
const raw = Deno.env.get("BENCH_RESULT_JSON")?.trim();
|
const raw = process.env.BENCH_RESULT_JSON?.trim();
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -206,7 +207,7 @@ async function readLatestP2PConnectionStats(
|
|||||||
statsPath: string,
|
statsPath: string,
|
||||||
): Promise<P2PConnectionStats | undefined> {
|
): Promise<P2PConnectionStats | undefined> {
|
||||||
try {
|
try {
|
||||||
const text = await Deno.readTextFile(statsPath);
|
const text = await readFile(statsPath, "utf8");
|
||||||
const lines = text
|
const lines = text
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
@@ -233,13 +234,13 @@ async function main(): Promise<void> {
|
|||||||
const hostSettings = workDir.join("settings-host.json");
|
const hostSettings = workDir.join("settings-host.json");
|
||||||
const clientSettings = workDir.join("settings-client.json");
|
const clientSettings = workDir.join("settings-client.json");
|
||||||
const p2pStatsPath = workDir.join("p2p-connection-stats.jsonl");
|
const p2pStatsPath = workDir.join("p2p-connection-stats.jsonl");
|
||||||
const previousStatsPath = Deno.env.get("LIVESYNC_P2P_STATS_JSONL");
|
const previousStatsPath = process.env.LIVESYNC_P2P_STATS_JSONL;
|
||||||
Deno.env.set("LIVESYNC_P2P_STATS_JSONL", p2pStatsPath);
|
process.env.LIVESYNC_P2P_STATS_JSONL = p2pStatsPath;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
Deno.mkdir(hostVault, { recursive: true }),
|
mkdir(hostVault, { recursive: true }),
|
||||||
Deno.mkdir(clientVault, { recursive: true }),
|
mkdir(clientVault, { recursive: true }),
|
||||||
initSettingsFile(hostSettings),
|
initSettingsFile(hostSettings),
|
||||||
initSettingsFile(clientSettings),
|
initSettingsFile(clientSettings),
|
||||||
]);
|
]);
|
||||||
@@ -407,7 +408,7 @@ async function main(): Promise<void> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (resultPath) {
|
if (resultPath) {
|
||||||
await Deno.writeTextFile(
|
await writeFile(
|
||||||
resultPath,
|
resultPath,
|
||||||
JSON.stringify(result, null, 2),
|
JSON.stringify(result, null, 2),
|
||||||
);
|
);
|
||||||
@@ -428,9 +429,9 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (previousStatsPath === undefined) {
|
if (previousStatsPath === undefined) {
|
||||||
Deno.env.delete("LIVESYNC_P2P_STATS_JSONL");
|
delete process.env.LIVESYNC_P2P_STATS_JSONL;
|
||||||
} else {
|
} else {
|
||||||
Deno.env.set("LIVESYNC_P2P_STATS_JSONL", previousStatsPath);
|
process.env.LIVESYNC_P2P_STATS_JSONL = previousStatsPath;
|
||||||
}
|
}
|
||||||
await stopCoturnIfStarted(coturnStarted);
|
await stopCoturnIfStarted(coturnStarted);
|
||||||
await stopLocalRelayIfStarted(relayStarted);
|
await stopLocalRelayIfStarted(relayStarted);
|
||||||
@@ -440,6 +441,6 @@ async function main(): Promise<void> {
|
|||||||
if (import.meta.main) {
|
if (import.meta.main) {
|
||||||
main().catch((error) => {
|
main().catch((error) => {
|
||||||
console.error(`[Fatal Error]`, error);
|
console.error(`[Fatal Error]`, error);
|
||||||
Deno.exit(1);
|
process.exitCode = 1;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -13,13 +13,13 @@ echo "[bench-wrapper] output directory: ${OUT_DIR}"
|
|||||||
echo "[bench-wrapper] running p2p benchmark"
|
echo "[bench-wrapper] running p2p benchmark"
|
||||||
(
|
(
|
||||||
cd "${SCRIPT_DIR}"
|
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)"
|
echo "[bench-wrapper] running couchdb benchmark with RTT ${BENCH_COUCHDB_RTT_MS:-default} ms (emulating HTTP network latency)"
|
||||||
(
|
(
|
||||||
cd "${SCRIPT_DIR}"
|
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" <<EOF
|
cat > "${OUT_DIR}/README.txt" <<EOF
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
[
|
[
|
||||||
|
"test:helpers",
|
||||||
"test:settings-writeback",
|
"test:settings-writeback",
|
||||||
"test:remote-administration-exit-codes",
|
"test:remote-administration-exit-codes",
|
||||||
"test:setup-put-cat",
|
"test:setup-put-cat",
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { join, sep } from "node:path";
|
||||||
|
import { REPOSITORY_ROOT } from "./helpers/paths.ts";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
type FileCoverage = { f: Record<string, number> };
|
||||||
|
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<string, FileCoverage>;
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
+17
-12
@@ -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 { 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 CLI_DIST = join(CLI_DIR, "dist", "index.cjs");
|
||||||
const VERBOSE_ENABLED = Deno.env.get("LIVESYNC_CLI_VERBOSE") === "1";
|
const VERBOSE_ENABLED = process.env.LIVESYNC_CLI_VERBOSE === "1";
|
||||||
const DEBUG_ENABLED = Deno.env.get("LIVESYNC_CLI_DEBUG") === "1";
|
const DEBUG_ENABLED = process.env.LIVESYNC_CLI_DEBUG === "1";
|
||||||
|
|
||||||
function decorateArgs(args: string[]): string[] {
|
function decorateArgs(args: string[]): string[] {
|
||||||
return DEBUG_ENABLED ? ["-d", ...args] : VERBOSE_ENABLED ? ["-v", ...args] : args;
|
return DEBUG_ENABLED ? ["-d", ...args] : VERBOSE_ENABLED ? ["-v", ...args] : args;
|
||||||
@@ -38,24 +40,26 @@ export class BackgroundCliProcess {
|
|||||||
#stdoutDone: Promise<void>;
|
#stdoutDone: Promise<void>;
|
||||||
#stderrDone: Promise<void>;
|
#stderrDone: Promise<void>;
|
||||||
|
|
||||||
constructor(
|
readonly child: RunningProcess;
|
||||||
readonly child: Deno.ChildProcess,
|
readonly args: string[];
|
||||||
readonly args: string[]
|
|
||||||
) {
|
constructor(child: RunningProcess, args: string[]) {
|
||||||
|
this.child = child;
|
||||||
|
this.args = args;
|
||||||
const cliArgs = decorateArgs(args);
|
const cliArgs = decorateArgs(args);
|
||||||
this.#stdoutDone = pump(
|
this.#stdoutDone = pump(
|
||||||
child.stdout,
|
child.stdout,
|
||||||
(text) => {
|
(text) => {
|
||||||
this.#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(
|
this.#stderrDone = pump(
|
||||||
child.stderr,
|
child.stderr,
|
||||||
(text) => {
|
(text) => {
|
||||||
this.#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 {
|
export function startCliInBackground(...args: string[]): BackgroundCliProcess {
|
||||||
const cliArgs = decorateArgs(args);
|
const cliArgs = decorateArgs(args);
|
||||||
const child = new Deno.Command("node", {
|
const child = spawnProcess(process.execPath, {
|
||||||
args: [CLI_DIST, ...cliArgs],
|
args: [CLI_DIST, ...cliArgs],
|
||||||
cwd: CLI_DIR,
|
cwd: CLI_DIR,
|
||||||
stdin: "null",
|
stdin: "null",
|
||||||
stdout: "piped",
|
stdout: "piped",
|
||||||
stderr: "piped",
|
stderr: "piped",
|
||||||
}).spawn();
|
});
|
||||||
|
|
||||||
if (TEE_ENABLED) {
|
if (TEE_ENABLED) {
|
||||||
Deno.stdout.writeSync(
|
writeSync(
|
||||||
|
1,
|
||||||
new TextEncoder().encode(`[CLI tee pid=${child.pid}] process(bg): ${formatTeeCommand(cliArgs)}\n`)
|
new TextEncoder().encode(`[CLI tee pid=${child.pid}] process(bg): ${formatTeeCommand(cliArgs)}\n`)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+6
-12
@@ -1,3 +1,5 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
import type { DatasetEntry } from "./dataset.ts";
|
import type { DatasetEntry } from "./dataset.ts";
|
||||||
|
|
||||||
export type BenchmarkVerificationMode = "all" | "sample";
|
export type BenchmarkVerificationMode = "all" | "sample";
|
||||||
@@ -9,16 +11,8 @@ export type BenchmarkVerificationResult = {
|
|||||||
datasetDigestSha256: string;
|
datasetDigestSha256: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function toHex(bytes: ArrayBuffer): string {
|
function sha256(bytes: Uint8Array): string {
|
||||||
return [...new Uint8Array(bytes)]
|
return createHash("sha256").update(bytes).digest("hex");
|
||||||
.map((value) => value.toString(16).padStart(2, "0"))
|
|
||||||
.join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sha256(bytes: Uint8Array): Promise<string> {
|
|
||||||
const input = new ArrayBuffer(bytes.byteLength);
|
|
||||||
new Uint8Array(input).set(bytes);
|
|
||||||
return toHex(await crypto.subtle.digest("SHA-256", input));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseBenchmarkVerificationMode(
|
export function parseBenchmarkVerificationMode(
|
||||||
@@ -53,12 +47,12 @@ export async function computeDatasetDigestSha256(
|
|||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const manifest: string[] = [];
|
const manifest: string[] = [];
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const contentDigest = await sha256(await Deno.readFile(entry.absolutePath));
|
const contentDigest = sha256(await readFile(entry.absolutePath));
|
||||||
manifest.push(
|
manifest.push(
|
||||||
`${entry.kind}\t${entry.relativePath}\t${entry.size}\t${contentDigest}`,
|
`${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(
|
export async function verifyBenchmarkDataset(
|
||||||
@@ -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";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
import { CLI_DIR, CLI_DIST } from "./paths.ts";
|
||||||
// Path resolution
|
export { CLI_DIR, CLI_DIST } from "./paths.ts";
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 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");
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Result type
|
// Result type
|
||||||
@@ -20,9 +17,9 @@ export interface CliResult {
|
|||||||
code: number;
|
code: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TEE_ENABLED = Deno.env.get("LIVESYNC_TEST_TEE") === "1";
|
export const TEE_ENABLED = process.env.LIVESYNC_TEST_TEE === "1";
|
||||||
const VERBOSE_ENABLED = Deno.env.get("LIVESYNC_CLI_VERBOSE") === "1";
|
const VERBOSE_ENABLED = process.env.LIVESYNC_CLI_VERBOSE === "1";
|
||||||
const DEBUG_ENABLED = Deno.env.get("LIVESYNC_CLI_DEBUG") === "1";
|
const DEBUG_ENABLED = process.env.LIVESYNC_CLI_DEBUG === "1";
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
function sleep(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
@@ -40,7 +37,7 @@ function concatChunks(chunks: Uint8Array[]): Uint8Array {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatTeeCommand(args: string[]): string {
|
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(
|
export function createLineTeeWriter(
|
||||||
@@ -114,27 +111,25 @@ async function collectStream(
|
|||||||
|
|
||||||
async function runNodeCommand(args: string[], stdinData?: Uint8Array): Promise<CliResult> {
|
async function runNodeCommand(args: string[], stdinData?: Uint8Array): Promise<CliResult> {
|
||||||
const cliArgs = DEBUG_ENABLED ? ["-d", ...args] : VERBOSE_ENABLED ? ["-v", ...args] : args;
|
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],
|
args: [CLI_DIST, ...cliArgs],
|
||||||
cwd: CLI_DIR,
|
cwd: CLI_DIR,
|
||||||
stdin: stdinData ? "piped" : "null",
|
stdin: stdinData ? "piped" : "null",
|
||||||
stdout: "piped",
|
stdout: "piped",
|
||||||
stderr: "piped",
|
stderr: "piped",
|
||||||
}).spawn();
|
});
|
||||||
|
|
||||||
if (TEE_ENABLED) {
|
if (TEE_ENABLED) {
|
||||||
Deno.stdout.writeSync(
|
writeSync(1, new TextEncoder().encode(`[CLI tee pid=${child.pid}] process: ${formatTeeCommand(cliArgs)}\n`));
|
||||||
new TextEncoder().encode(`[CLI tee pid=${child.pid}] process: ${formatTeeCommand(cliArgs)}\n`)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const stdoutPromise = collectStream(
|
const stdoutPromise = collectStream(
|
||||||
child.stdout,
|
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(
|
const stderrPromise = collectStream(
|
||||||
child.stderr,
|
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) {
|
if (stdinData) {
|
||||||
@@ -172,7 +167,7 @@ function isTransientNetworkError(message: string): boolean {
|
|||||||
* do. Does NOT throw on non-zero exit — check `.code` yourself.
|
* do. Does NOT throw on non-zero exit — check `.code` yourself.
|
||||||
*/
|
*/
|
||||||
export async function runCli(...args: string[]): Promise<CliResult> {
|
export async function runCli(...args: string[]): Promise<CliResult> {
|
||||||
const retries = Number(Deno.env.get("LIVESYNC_CLI_RETRY") ?? "0");
|
const retries = Number(process.env.LIVESYNC_CLI_RETRY ?? "0");
|
||||||
for (let attempt = 0; ; attempt++) {
|
for (let attempt = 0; ; attempt++) {
|
||||||
const result = await runNodeCommand(args);
|
const result = await runNodeCommand(args);
|
||||||
if (result.code === 0) return result;
|
if (result.code === 0) return result;
|
||||||
@@ -204,7 +199,7 @@ export async function runCliOrFail(...args: string[]): Promise<string> {
|
|||||||
export async function runCliWithInput(input: string | Uint8Array, ...args: string[]): Promise<CliResult> {
|
export async function runCliWithInput(input: string | Uint8Array, ...args: string[]): Promise<CliResult> {
|
||||||
const data = typeof input === "string" ? new TextEncoder().encode(input) : input;
|
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++) {
|
for (let attempt = 0; ; attempt++) {
|
||||||
const result = await runNodeCommand(args, data);
|
const result = await runNodeCommand(args, data);
|
||||||
if (result.code === 0) return result;
|
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<void> {
|
export async function assertFilesEqual(expectedPath: string, actualPath: string, message: string): Promise<void> {
|
||||||
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])) {
|
if (expected.length !== actual.length || expected.some((b, i) => b !== actual[i])) {
|
||||||
const hex = async (d: Uint8Array<ArrayBuffer>) => {
|
const hex = (data: Uint8Array) => createHash("sha256").update(data).digest("hex");
|
||||||
const h = await crypto.subtle.digest("SHA-256", d);
|
|
||||||
return [...new Uint8Array(h)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
||||||
};
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`[FAIL] ${message}\nexpected SHA-256: ${await hex(expected)}\nactual SHA-256: ${await hex(actual)}`
|
`[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<T = Record<string, unknown>>(filePath: string): Promise<T> {
|
export async function readJsonFile<T = Record<string, unknown>>(filePath: string): Promise<T> {
|
||||||
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 {
|
export function jsonStringField(jsonText: string, field: string): string {
|
||||||
+22
-21
@@ -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 type { DatasetEntry, DatasetKind } from "./dataset.ts";
|
||||||
|
import { REPOSITORY_ROOT } from "./paths.ts";
|
||||||
|
|
||||||
export type CompressionDatasetEntry = DatasetEntry & {
|
export type CompressionDatasetEntry = DatasetEntry & {
|
||||||
source: string;
|
source: string;
|
||||||
@@ -18,8 +21,6 @@ export type JpegEncoder = (inputPpm: string, outputJpeg: string) => Promise<stri
|
|||||||
|
|
||||||
const ALL_KINDS: DatasetKind[] = ["md", "jpg", "png", "json", "ts", "gz", "bin"];
|
const ALL_KINDS: DatasetKind[] = ["md", "jpg", "png", "json", "ts", "gz", "bin"];
|
||||||
|
|
||||||
const REPOSITORY_ROOT = join(import.meta.dirname!, "..", "..", "..", "..", "..");
|
|
||||||
|
|
||||||
function fnv1a32(input: string): number {
|
function fnv1a32(input: string): number {
|
||||||
let hash = 0x811c9dc5;
|
let hash = 0x811c9dc5;
|
||||||
for (let i = 0; i < input.length; i++) {
|
for (let i = 0; i < input.length; i++) {
|
||||||
@@ -66,17 +67,16 @@ async function gzip(input: Uint8Array): Promise<Uint8Array> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function encodeJpegWithCjpeg(inputPpm: string, outputJpeg: string): Promise<string> {
|
export async function encodeJpegWithCjpeg(inputPpm: string, outputJpeg: string): Promise<string> {
|
||||||
const command = new Deno.Command("cjpeg", {
|
let result: Awaited<ReturnType<typeof runProcess>>;
|
||||||
args: ["-quality", "85", "-optimize", "-outfile", outputJpeg, inputPpm],
|
|
||||||
stdin: "null",
|
|
||||||
stdout: "piped",
|
|
||||||
stderr: "piped",
|
|
||||||
});
|
|
||||||
let result: Deno.CommandOutput;
|
|
||||||
try {
|
try {
|
||||||
result = await command.output();
|
result = await runProcess("cjpeg", {
|
||||||
|
args: ["-quality", "85", "-optimize", "-outfile", outputJpeg, inputPpm],
|
||||||
|
stdin: "null",
|
||||||
|
stdout: "piped",
|
||||||
|
stderr: "piped",
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Deno.errors.NotFound) {
|
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"cjpeg is required for the compression benchmark. Use the Compose runner or install libjpeg tools."
|
"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++) {
|
for (let index = 0; index < bytes.length; index++) {
|
||||||
bytes[index] = nextRandom() & 0xff;
|
bytes[index] = nextRandom() & 0xff;
|
||||||
}
|
}
|
||||||
await Deno.writeFile(path, bytes);
|
await writeFile(path, bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createCompressionBenchmarkDataset(options: {
|
export async function createCompressionBenchmarkDataset(options: {
|
||||||
@@ -114,7 +114,7 @@ export async function createCompressionBenchmarkDataset(options: {
|
|||||||
let jpegGenerator = "";
|
let jpegGenerator = "";
|
||||||
|
|
||||||
for (const kind of ALL_KINDS) {
|
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) => {
|
const addFile = async (kind: DatasetKind, absolutePath: string, source: string) => {
|
||||||
@@ -122,13 +122,13 @@ export async function createCompressionBenchmarkDataset(options: {
|
|||||||
.slice(options.rootDir.length + 1)
|
.slice(options.rootDir.length + 1)
|
||||||
.split("\\")
|
.split("\\")
|
||||||
.join("/");
|
.join("/");
|
||||||
const size = (await Deno.stat(absolutePath)).size;
|
const size = (await stat(absolutePath)).size;
|
||||||
entries.push({ kind, relativePath, absolutePath, size, source });
|
entries.push({ kind, relativePath, absolutePath, size, source });
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyRepositoryFile = async (kind: DatasetKind, sourcePath: string, targetName: string) => {
|
const copyRepositoryFile = async (kind: DatasetKind, sourcePath: string, targetName: string) => {
|
||||||
const destination = join(datasetRoot, kind, targetName);
|
const destination = join(datasetRoot, kind, targetName);
|
||||||
await Deno.copyFile(join(repositoryRoot, sourcePath), destination);
|
await copyFile(join(repositoryRoot, sourcePath), destination);
|
||||||
await addFile(kind, destination, sourcePath);
|
await addFile(kind, destination, sourcePath);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -150,9 +150,9 @@ export async function createCompressionBenchmarkDataset(options: {
|
|||||||
"ReplicateResultProcessor.ts"
|
"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");
|
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");
|
await addFile("gz", gzipPath, "generated gzip of docs/settings.md");
|
||||||
|
|
||||||
const randomPath = join(datasetRoot, "bin", "deterministic-random.bin");
|
const randomPath = join(datasetRoot, "bin", "deterministic-random.bin");
|
||||||
@@ -163,13 +163,14 @@ export async function createCompressionBenchmarkDataset(options: {
|
|||||||
["smooth-gradient.jpg", false],
|
["smooth-gradient.jpg", false],
|
||||||
["textured-gradient.jpg", true],
|
["textured-gradient.jpg", true],
|
||||||
] as const) {
|
] 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);
|
const jpegPath = join(datasetRoot, "jpg", name);
|
||||||
try {
|
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);
|
jpegGenerator = await jpegEncoder(ppmPath, jpegPath);
|
||||||
} finally {
|
} finally {
|
||||||
await Deno.remove(ppmPath).catch(() => {});
|
await rm(temporaryDirectory, { recursive: true, force: true }).catch(() => {});
|
||||||
}
|
}
|
||||||
await addFile("jpg", jpegPath, `${jpegGenerator}; ${textured ? "textured" : "smooth"}`);
|
await addFile("jpg", jpegPath, `${jpegGenerator}; ${textured ? "textured" : "smooth"}`);
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
export type DeterministicDatasetConfig = {
|
export type DeterministicDatasetConfig = {
|
||||||
rootDir: string;
|
rootDir: string;
|
||||||
datasetDirName: string;
|
datasetDirName: string;
|
||||||
@@ -65,10 +68,6 @@ function createTextBytes(size: number, fileIndex: number, seed: string): Uint8Ar
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toPath(rootDir: string, relativePath: string): string {
|
|
||||||
return `${rootDir}/${relativePath}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createDeterministicDataset(config: DeterministicDatasetConfig): Promise<DeterministicDataset> {
|
export async function createDeterministicDataset(config: DeterministicDatasetConfig): Promise<DeterministicDataset> {
|
||||||
if (config.mdCount < 0 || config.binCount < 0) {
|
if (config.mdCount < 0 || config.binCount < 0) {
|
||||||
throw new Error("mdCount and binCount must be non-negative");
|
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");
|
throw new Error("mdMinSizeBytes must be <= mdMaxSizeBytes");
|
||||||
}
|
}
|
||||||
|
|
||||||
const datasetRoot = toPath(config.rootDir, config.datasetDirName);
|
const datasetRoot = join(config.rootDir, config.datasetDirName);
|
||||||
const mdDir = `${datasetRoot}/md`;
|
const mdDir = join(datasetRoot, "md");
|
||||||
const binDir = `${datasetRoot}/bin`;
|
const binDir = join(datasetRoot, "bin");
|
||||||
await Deno.mkdir(mdDir, { recursive: true });
|
await mkdir(mdDir, { recursive: true });
|
||||||
await Deno.mkdir(binDir, { recursive: true });
|
await mkdir(binDir, { recursive: true });
|
||||||
|
|
||||||
const nextRandom = createXorshift32(fnv1a32(config.seed));
|
const nextRandom = createXorshift32(fnv1a32(config.seed));
|
||||||
const mdRange = config.mdMaxSizeBytes - config.mdMinSizeBytes + 1;
|
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++) {
|
for (let index = 0; index < config.mdCount; index++) {
|
||||||
const size = config.mdMinSizeBytes + (nextRandom() % mdRange);
|
const size = config.mdMinSizeBytes + (nextRandom() % mdRange);
|
||||||
const relativePath = `${config.datasetDirName}/md/file-${String(index).padStart(4, "0")}.md`;
|
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);
|
const body = createTextBytes(size, index, config.seed);
|
||||||
await Deno.writeFile(absolutePath, body);
|
await writeFile(absolutePath, body);
|
||||||
entries.push({ kind: "md", relativePath, absolutePath, size });
|
entries.push({ kind: "md", relativePath, absolutePath, size });
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let index = 0; index < config.binCount; index++) {
|
for (let index = 0; index < config.binCount; index++) {
|
||||||
const size = config.binSizeBytes;
|
const size = config.binSizeBytes;
|
||||||
const relativePath = `${config.datasetDirName}/bin/file-${String(index).padStart(4, "0")}.bin`;
|
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);
|
const body = new Uint8Array(size);
|
||||||
for (let i = 0; i < size; i++) {
|
for (let i = 0; i < size; i++) {
|
||||||
body[i] = nextRandom() & 0xff;
|
body[i] = nextRandom() & 0xff;
|
||||||
}
|
}
|
||||||
await Deno.writeFile(absolutePath, body);
|
await writeFile(absolutePath, body);
|
||||||
entries.push({ kind: "bin", relativePath, absolutePath, size });
|
entries.push({ kind: "bin", relativePath, absolutePath, size });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { writeSync } from "node:fs";
|
||||||
|
import { spawnProcess } from "./process.ts";
|
||||||
/**
|
/**
|
||||||
* Docker service management for tests.
|
* Docker service management for tests.
|
||||||
*
|
*
|
||||||
@@ -13,12 +15,12 @@ type DockerInvoker = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let dockerInvokerPromise: Promise<DockerInvoker> | null = null;
|
let dockerInvokerPromise: Promise<DockerInvoker> | 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<string>();
|
const trackedContainers = new Set<string>();
|
||||||
const CLEANUP_SIGNALS: Deno.Signal[] = ["SIGINT", "SIGTERM"];
|
const CLEANUP_SIGNALS: NodeJS.Signals[] = ["SIGINT", "SIGTERM"];
|
||||||
let signalCleanupHandlersInstalled = false;
|
let signalCleanupHandlersInstalled = false;
|
||||||
let signalCleanupInProgress = false;
|
let signalCleanupInProgress = false;
|
||||||
const signalCleanupHandlers = new Map<Deno.Signal, () => void>();
|
const signalCleanupHandlers = new Map<NodeJS.Signals, () => void>();
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Low-level docker wrapper
|
// 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 }> {
|
async function runCommand(bin: string, args: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
|
||||||
try {
|
try {
|
||||||
const child = new Deno.Command(bin, {
|
const child = spawnProcess(bin, {
|
||||||
args,
|
args,
|
||||||
stdin: "null",
|
stdin: "null",
|
||||||
stdout: "piped",
|
stdout: "piped",
|
||||||
stderr: "piped",
|
stderr: "piped",
|
||||||
}).spawn();
|
});
|
||||||
const stdoutPromise = collectStream(child.stdout, DOCKER_TEE ? (chunk) => Deno.stdout.writeSync(chunk) : null);
|
const stdoutPromise = collectStream(child.stdout, DOCKER_TEE ? (chunk) => writeSync(1, chunk) : null);
|
||||||
const stderrPromise = collectStream(child.stderr, DOCKER_TEE ? (chunk) => Deno.stderr.writeSync(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 [status, stdout, stderr] = await Promise.all([child.status, stdoutPromise, stderrPromise]);
|
||||||
const dec = new TextDecoder();
|
const dec = new TextDecoder();
|
||||||
const result = {
|
const result = {
|
||||||
@@ -81,7 +83,7 @@ async function runCommand(bin: string, args: string[]): Promise<{ code: number;
|
|||||||
};
|
};
|
||||||
return result;
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Deno.errors.NotFound) {
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||||
return {
|
return {
|
||||||
code: 127,
|
code: 127,
|
||||||
stdout: "",
|
stdout: "",
|
||||||
@@ -93,7 +95,7 @@ async function runCommand(bin: string, args: string[]): Promise<{ code: number;
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resolveDockerInvoker(): Promise<DockerInvoker> {
|
async function resolveDockerInvoker(): Promise<DockerInvoker> {
|
||||||
const custom = Deno.env.get("LIVESYNC_DOCKER_COMMAND")?.trim();
|
const custom = process.env.LIVESYNC_DOCKER_COMMAND?.trim();
|
||||||
if (custom) {
|
if (custom) {
|
||||||
const parsed = parseCommand(custom);
|
const parsed = parseCommand(custom);
|
||||||
const runner: DockerInvoker = {
|
const runner: DockerInvoker = {
|
||||||
@@ -110,8 +112,8 @@ async function resolveDockerInvoker(): Promise<DockerInvoker> {
|
|||||||
return runner;
|
return runner;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mode = (Deno.env.get("LIVESYNC_DOCKER_MODE") ?? "auto").toLowerCase();
|
const mode = (process.env.LIVESYNC_DOCKER_MODE ?? "auto").toLowerCase();
|
||||||
const onWindows = Deno.build.os === "windows";
|
const onWindows = process.platform === "win32";
|
||||||
|
|
||||||
const native: DockerInvoker = { bin: "docker", prefix: [], label: "docker" };
|
const native: DockerInvoker = { bin: "docker", prefix: [], label: "docker" };
|
||||||
const wsl: DockerInvoker = { bin: "wsl", prefix: [], label: "wsl docker" };
|
const wsl: DockerInvoker = { bin: "wsl", prefix: [], label: "wsl docker" };
|
||||||
@@ -204,13 +206,13 @@ async function cleanupTrackedContainers(reason: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSignalCleanup(signal: Deno.Signal): Promise<void> {
|
async function handleSignalCleanup(signal: NodeJS.Signals): Promise<void> {
|
||||||
if (signalCleanupInProgress) return;
|
if (signalCleanupInProgress) return;
|
||||||
signalCleanupInProgress = true;
|
signalCleanupInProgress = true;
|
||||||
try {
|
try {
|
||||||
await cleanupTrackedContainers(`signal ${signal}`);
|
await cleanupTrackedContainers(`signal ${signal}`);
|
||||||
} finally {
|
} finally {
|
||||||
Deno.exit(signal === "SIGINT" ? 130 : 143);
|
process.exit(signal === "SIGINT" ? 130 : 143);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +224,7 @@ function ensureSignalCleanupHandlers(): void {
|
|||||||
void handleSignalCleanup(signal);
|
void handleSignalCleanup(signal);
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
Deno.addSignalListener(signal, listener);
|
process.on(signal, listener);
|
||||||
signalCleanupHandlers.set(signal, listener);
|
signalCleanupHandlers.set(signal, listener);
|
||||||
} catch {
|
} catch {
|
||||||
// Unsupported signal on this platform.
|
// Unsupported signal on this platform.
|
||||||
@@ -234,7 +236,7 @@ function removeSignalCleanupHandlers(): void {
|
|||||||
if (!signalCleanupHandlersInstalled) return;
|
if (!signalCleanupHandlersInstalled) return;
|
||||||
for (const [signal, listener] of signalCleanupHandlers) {
|
for (const [signal, listener] of signalCleanupHandlers) {
|
||||||
try {
|
try {
|
||||||
Deno.removeSignalListener(signal, listener);
|
process.off(signal, listener);
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore if already removed or unsupported.
|
// Ignore if already removed or unsupported.
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
/**
|
/**
|
||||||
* Load a .env-style file (KEY=value per line) into a plain object.
|
* Load a .env-style file (KEY=value per line) into a plain object.
|
||||||
* Equivalent to `source $TEST_ENV_FILE; set -a` in bash.
|
* Equivalent to `source $TEST_ENV_FILE; set -a` in bash.
|
||||||
@@ -10,7 +11,7 @@
|
|||||||
* # comment lines are ignored
|
* # comment lines are ignored
|
||||||
*/
|
*/
|
||||||
export async function loadEnvFile(filePath: string): Promise<Record<string, string>> {
|
export async function loadEnvFile(filePath: string): Promise<Record<string, string>> {
|
||||||
const text = await Deno.readTextFile(filePath);
|
const text = await readFile(filePath, "utf8");
|
||||||
const result: Record<string, string> = {};
|
const result: Record<string, string> = {};
|
||||||
for (const line of text.split("\n")) {
|
for (const line of text.split("\n")) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { runProcess } from "./process.ts";
|
||||||
import { CLI_DIR, CLI_DIST } from "./cli.ts";
|
import { CLI_DIR, CLI_DIST } from "./cli.ts";
|
||||||
|
|
||||||
export type CliProcessMeasurement = {
|
export type CliProcessMeasurement = {
|
||||||
@@ -13,17 +14,17 @@ const MARKER = "__LIVESYNC_GNU_TIME__";
|
|||||||
|
|
||||||
export async function runMeasuredCliOrFail(...args: string[]): Promise<CliProcessMeasurement> {
|
export async function runMeasuredCliOrFail(...args: string[]): Promise<CliProcessMeasurement> {
|
||||||
const started = performance.now();
|
const started = performance.now();
|
||||||
let output: Deno.CommandOutput;
|
let output: Awaited<ReturnType<typeof runProcess>>;
|
||||||
try {
|
try {
|
||||||
output = await new Deno.Command("/usr/bin/time", {
|
output = await runProcess("/usr/bin/time", {
|
||||||
args: ["-f", `${MARKER}%U\t%S\t%M`, "node", CLI_DIST, ...args],
|
args: ["-f", `${MARKER}%U\t%S\t%M`, process.execPath, CLI_DIST, ...args],
|
||||||
cwd: CLI_DIR,
|
cwd: CLI_DIR,
|
||||||
stdin: "null",
|
stdin: "null",
|
||||||
stdout: "piped",
|
stdout: "piped",
|
||||||
stderr: "piped",
|
stderr: "piped",
|
||||||
}).output();
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Deno.errors.NotFound) {
|
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"GNU /usr/bin/time is required for the compression benchmark. Use the Compose runner or install GNU time."
|
"GNU /usr/bin/time is required for the compression benchmark. Use the Compose runner or install GNU time."
|
||||||
);
|
);
|
||||||
@@ -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<void> {
|
||||||
|
await new Promise<void>((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<void> {
|
||||||
|
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<number> {
|
||||||
|
const server = createServer();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.once("error", reject);
|
||||||
|
server.listen(0, hostname, resolve);
|
||||||
|
});
|
||||||
|
const address = server.address();
|
||||||
|
await new Promise<void>((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<string> {
|
||||||
|
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";
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
import { runCli } from "./cli.ts";
|
import { runCli } from "./cli.ts";
|
||||||
import { isLocalP2pRelay, startP2pRelay, stopP2pRelay, startCoturn, stopCoturn } from "./docker.ts";
|
import { isLocalP2pRelay, startP2pRelay, stopP2pRelay, startCoturn, stopCoturn } from "./docker.ts";
|
||||||
import { waitForPort } from "./net.ts";
|
import { waitForPort } from "./net.ts";
|
||||||
@@ -35,7 +36,7 @@ export async function discoverPeer(
|
|||||||
timeoutSeconds: number,
|
timeoutSeconds: number,
|
||||||
targetPeer?: string
|
targetPeer?: string
|
||||||
): Promise<PeerEntry> {
|
): Promise<PeerEntry> {
|
||||||
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 = "";
|
let lastCombined = "";
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||||
@@ -82,9 +83,9 @@ export async function maybeStartLocalRelay(relay: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
const endpoint = parseRelayEndpoint(relay);
|
const endpoint = parseRelayEndpoint(relay);
|
||||||
await waitForPort(endpoint.hostname, endpoint.port, {
|
await waitForPort(endpoint.hostname, endpoint.port, {
|
||||||
timeoutMs: Number(Deno.env.get("LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS") ?? "15000"),
|
timeoutMs: Number(process.env.LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS ?? "15000"),
|
||||||
intervalMs: Number(Deno.env.get("LIVESYNC_P2P_RELAY_READY_INTERVAL_MS") ?? "250"),
|
intervalMs: Number(process.env.LIVESYNC_P2P_RELAY_READY_INTERVAL_MS ?? "250"),
|
||||||
connectTimeoutMs: Number(Deno.env.get("LIVESYNC_P2P_RELAY_CONNECT_TIMEOUT_MS") ?? "1000"),
|
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.
|
// 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.
|
// Wait an additional few seconds to ensure strfry is actually accepting WebSockets.
|
||||||
@@ -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");
|
||||||
@@ -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" });
|
||||||
|
});
|
||||||
@@ -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<Uint8Array>;
|
||||||
|
readonly stdout: ReadableStream<Uint8Array>;
|
||||||
|
readonly stderr: ReadableStream<Uint8Array>;
|
||||||
|
readonly status: Promise<ProcessStatus>;
|
||||||
|
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<ProcessStatus>((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<Uint8Array>;
|
||||||
|
},
|
||||||
|
get stdout() {
|
||||||
|
if (!child.stdout) throw new Error("The child process stdout is not piped");
|
||||||
|
return Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>;
|
||||||
|
},
|
||||||
|
get stderr() {
|
||||||
|
if (!child.stderr) throw new Error("The child process stderr is not piped");
|
||||||
|
return Readable.toWeb(child.stderr) as ReadableStream<Uint8Array>;
|
||||||
|
},
|
||||||
|
status,
|
||||||
|
kill(signal = "SIGTERM") {
|
||||||
|
child.kill(signal);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runProcess(command: string, options: ProcessOptions = {}): Promise<ProcessOutput> {
|
||||||
|
const child = spawnProcess(command, options);
|
||||||
|
const collect = async (stream: ReadableStream<Uint8Array>): Promise<Uint8Array> =>
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { runProcess } from "./process.ts";
|
||||||
import { CLI_DIR, runCliOrFail } from "./cli.ts";
|
import { CLI_DIR, runCliOrFail } from "./cli.ts";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -41,49 +43,27 @@ export async function generateSetupUriFromSettings(
|
|||||||
"})();",
|
"})();",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
const scriptPath = await Deno.makeTempFile({
|
const { code, stdout, stderr } = await runProcess(process.execPath, {
|
||||||
dir: CLI_DIR,
|
args: ["--input-type=module", "--eval", script],
|
||||||
prefix: "livesync-setup-uri-",
|
cwd: CLI_DIR,
|
||||||
suffix: ".mts",
|
env: { SETTINGS_FILE: settingsFile, SETUP_PASSPHRASE: setupPassphrase },
|
||||||
});
|
});
|
||||||
await Deno.writeTextFile(scriptPath, script);
|
const dec = new TextDecoder();
|
||||||
|
if (code !== 0) {
|
||||||
try {
|
throw new Error(
|
||||||
const cmd = new Deno.Command("npx", {
|
`Failed to generate setup URI (code ${code})\nstdout: ${dec.decode(stdout)}\nstderr: ${dec.decode(stderr)}`
|
||||||
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 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). */
|
/** Set isConfigured=true in a settings file (required for mirror / scan). */
|
||||||
export async function markSettingsConfigured(settingsFile: string): Promise<void> {
|
export async function markSettingsConfigured(settingsFile: string): Promise<void> {
|
||||||
const data = JSON.parse(await Deno.readTextFile(settingsFile));
|
const data = JSON.parse(await readFile(settingsFile, "utf8"));
|
||||||
data.isConfigured = true;
|
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,
|
couchdbDbname: string,
|
||||||
liveSync = false
|
liveSync = false
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const data = JSON.parse(await Deno.readTextFile(settingsFile));
|
const data = JSON.parse(await readFile(settingsFile, "utf8"));
|
||||||
data.couchDB_URI = couchdbUri;
|
data.couchDB_URI = couchdbUri;
|
||||||
data.couchDB_USER = couchdbUser;
|
data.couchDB_USER = couchdbUser;
|
||||||
data.couchDB_PASSWORD = couchdbPassword;
|
data.couchDB_PASSWORD = couchdbPassword;
|
||||||
@@ -114,7 +94,7 @@ export async function applyCouchdbSettings(
|
|||||||
data.usePluginSync = false;
|
data.usePluginSync = false;
|
||||||
}
|
}
|
||||||
data.isConfigured = true;
|
data.isConfigured = true;
|
||||||
await Deno.writeTextFile(settingsFile, JSON.stringify(data, null, 2));
|
await writeFile(settingsFile, JSON.stringify(data, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function applyRemoteSyncSettings(
|
export async function applyRemoteSyncSettings(
|
||||||
@@ -135,7 +115,7 @@ export async function applyRemoteSyncSettings(
|
|||||||
usePathObfuscation?: boolean;
|
usePathObfuscation?: boolean;
|
||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const data = JSON.parse(await Deno.readTextFile(settingsFile));
|
const data = JSON.parse(await readFile(settingsFile, "utf8"));
|
||||||
|
|
||||||
if (options.remoteType === "COUCHDB") {
|
if (options.remoteType === "COUCHDB") {
|
||||||
data.remoteType = "";
|
data.remoteType = "";
|
||||||
@@ -166,7 +146,7 @@ export async function applyRemoteSyncSettings(
|
|||||||
data.usePathObfuscation = options.usePathObfuscation;
|
data.usePathObfuscation = options.usePathObfuscation;
|
||||||
}
|
}
|
||||||
data.isConfigured = true;
|
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 = "~.*",
|
autoAccept = "~.*",
|
||||||
turnServers = "turn:127.0.0.1:3478"
|
turnServers = "turn:127.0.0.1:3478"
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const data = JSON.parse(await Deno.readTextFile(settingsFile));
|
const data = JSON.parse(await readFile(settingsFile, "utf8"));
|
||||||
data.P2P_Enabled = true;
|
data.P2P_Enabled = true;
|
||||||
data.P2P_AutoStart = false;
|
data.P2P_AutoStart = false;
|
||||||
data.P2P_AutoBroadcast = false;
|
data.P2P_AutoBroadcast = false;
|
||||||
@@ -201,11 +181,11 @@ export async function applyP2pSettings(
|
|||||||
data.P2P_turnCredential = "testpass";
|
data.P2P_turnCredential = "testpass";
|
||||||
data.P2P_IsHeadless = true;
|
data.P2P_IsHeadless = true;
|
||||||
data.isConfigured = 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<void> {
|
export async function applyP2pTestTweaks(settingsFile: string, deviceName: string, passphrase: string): Promise<void> {
|
||||||
const data = JSON.parse(await Deno.readTextFile(settingsFile));
|
const data = JSON.parse(await readFile(settingsFile, "utf8"));
|
||||||
data.remoteType = "ONLY_P2P";
|
data.remoteType = "ONLY_P2P";
|
||||||
data.encrypt = true;
|
data.encrypt = true;
|
||||||
data.passphrase = passphrase;
|
data.passphrase = passphrase;
|
||||||
@@ -215,5 +195,5 @@ export async function applyP2pTestTweaks(settingsFile: string, deviceName: strin
|
|||||||
data.usePluginSyncV2 = true;
|
data.usePluginSyncV2 = true;
|
||||||
data.P2P_DevicePeerName = deviceName;
|
data.P2P_DevicePeerName = deviceName;
|
||||||
data.isConfigured = true;
|
data.isConfigured = true;
|
||||||
await Deno.writeTextFile(settingsFile, JSON.stringify(data, null, 2));
|
await writeFile(settingsFile, JSON.stringify(data, null, 2));
|
||||||
}
|
}
|
||||||
@@ -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<TempDir> {
|
||||||
|
return new TempDir(await mkdtemp(join(tmpdir(), `${prefix}.`)));
|
||||||
|
}
|
||||||
|
join(...parts: string[]): string {
|
||||||
|
return join(this.path, ...parts);
|
||||||
|
}
|
||||||
|
async [Symbol.asyncDispose](): Promise<void> {
|
||||||
|
await rm(this.path, { recursive: true, force: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
File renamed without changes.
@@ -1,18 +1,20 @@
|
|||||||
|
import { spawnProcess } from "./helpers/process.ts";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import TASKS from "./ci-tasks.json" with { type: "json" };
|
import TASKS from "./ci-tasks.json" with { type: "json" };
|
||||||
|
|
||||||
for (const [index, task] of TASKS.entries()) {
|
for (const [index, task] of TASKS.entries()) {
|
||||||
console.log(`\n[CLI E2E ${index + 1}/${TASKS.length}] ${task}`);
|
console.log(`\n[CLI E2E ${index + 1}/${TASKS.length}] ${task}`);
|
||||||
const child = new Deno.Command(Deno.execPath(), {
|
const child = spawnProcess(process.execPath, {
|
||||||
args: ["task", task],
|
args: [fileURLToPath(new URL("./run-task.ts", import.meta.url)), task],
|
||||||
cwd: import.meta.dirname,
|
cwd: import.meta.dirname,
|
||||||
stdin: "inherit",
|
stdin: "inherit",
|
||||||
stdout: "inherit",
|
stdout: "inherit",
|
||||||
stderr: "inherit",
|
stderr: "inherit",
|
||||||
}).spawn();
|
});
|
||||||
const status = await child.status;
|
const status = await child.status;
|
||||||
if (!status.success) {
|
if (!status.success) {
|
||||||
console.error(`[CLI E2E] ${task} failed with exit code ${status.code}.`);
|
console.error(`[CLI E2E] ${task} failed with exit code ${status.code}.`);
|
||||||
Deno.exit(status.code);
|
process.exit(status.code);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5,7 +5,7 @@ TASK="${CLI_E2E_TASK:-test:p2p:ci}"
|
|||||||
|
|
||||||
case "$TASK" in
|
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)
|
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
|
echo "Unknown CLI_E2E_TASK: $TASK" >&2
|
||||||
@@ -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<string, string>): Promise<ProcessStatus> {
|
||||||
|
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.");
|
||||||
@@ -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<void> {
|
||||||
|
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}`);
|
||||||
@@ -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<number> {
|
||||||
|
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<string, Task>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+67
-63
@@ -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 { type BenchmarkCase, buildCases } from "./bench-network-cases.ts";
|
||||||
import { startCouchdbProxy } from "./bench-couchdb.ts";
|
import { startCouchdbProxy } from "./bench-couchdb.ts";
|
||||||
import {
|
import {
|
||||||
@@ -7,14 +12,10 @@ import {
|
|||||||
} from "./helpers/benchmarkVerification.ts";
|
} from "./helpers/benchmarkVerification.ts";
|
||||||
import type { DatasetEntry } from "./helpers/dataset.ts";
|
import type { DatasetEntry } from "./helpers/dataset.ts";
|
||||||
import { createCompressionBenchmarkDataset } from "./helpers/compressionDataset.ts";
|
import { createCompressionBenchmarkDataset } from "./helpers/compressionDataset.ts";
|
||||||
|
import { getFreePort } from "./helpers/net.ts";
|
||||||
|
|
||||||
function getFreePort(): number {
|
function assertStringIncludes(actual: string, expected: string, message?: string): void {
|
||||||
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
|
assert.ok(actual.includes(expected), message ?? `Expected ${JSON.stringify(actual)} to include ${JSON.stringify(expected)}`);
|
||||||
try {
|
|
||||||
return (listener.addr as Deno.NetAddr).port;
|
|
||||||
} finally {
|
|
||||||
listener.close();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCase(cases: BenchmarkCase[], name: string): BenchmarkCase {
|
function getCase(cases: BenchmarkCase[], name: string): BenchmarkCase {
|
||||||
@@ -42,7 +43,7 @@ function parsedLimitations(testCase: BenchmarkCase): string[] {
|
|||||||
return parsed;
|
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();
|
const cases = buildCases();
|
||||||
assert(cases.length > 0);
|
assert(cases.length > 0);
|
||||||
|
|
||||||
@@ -67,31 +68,34 @@ Deno.test("benchmark cases record scope and limitations for paper use", () => {
|
|||||||
testCase.limitations.length > 0,
|
testCase.limitations.length > 0,
|
||||||
`${testCase.name} must list limitations`,
|
`${testCase.name} must list limitations`,
|
||||||
);
|
);
|
||||||
assertEquals(
|
assert.deepStrictEqual(
|
||||||
testCase.env.BENCH_MEASUREMENT_SCOPE,
|
testCase.env.BENCH_MEASUREMENT_SCOPE,
|
||||||
testCase.measurementScope,
|
testCase.measurementScope,
|
||||||
);
|
);
|
||||||
assertEquals(parsedLimitations(testCase), testCase.limitations);
|
assert.deepStrictEqual(parsedLimitations(testCase), testCase.limitations);
|
||||||
assertEquals(
|
assert.deepStrictEqual(
|
||||||
testCase.env.BENCH_VERIFY_MODE,
|
testCase.env.BENCH_VERIFY_MODE,
|
||||||
"all",
|
"all",
|
||||||
`${testCase.name} must verify the complete dataset`,
|
`${testCase.name} must verify the complete dataset`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
Deno.test("CouchDB latency proxy applies half the requested RTT in each direction", async () => {
|
test("CouchDB latency proxy applies half the requested RTT in each direction", async () => {
|
||||||
const backendPort = getFreePort();
|
const backend = createServer((_request, response) => response.end("ok"));
|
||||||
const proxyPort = getFreePort();
|
const backendPort = await new Promise<number>((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 delays: number[] = [];
|
||||||
const backend = Deno.serve(
|
const proxy = await startCouchdbProxy({
|
||||||
{
|
|
||||||
hostname: "127.0.0.1",
|
|
||||||
port: backendPort,
|
|
||||||
onListen() {},
|
|
||||||
},
|
|
||||||
() => new Response("ok"),
|
|
||||||
);
|
|
||||||
const proxy = startCouchdbProxy({
|
|
||||||
backendUri: `http://127.0.0.1:${backendPort}`,
|
backendUri: `http://127.0.0.1:${backendPort}`,
|
||||||
proxyUri: `http://127.0.0.1:${proxyPort}`,
|
proxyUri: `http://127.0.0.1:${proxyPort}`,
|
||||||
requestedRttMs: 20,
|
requestedRttMs: 20,
|
||||||
@@ -103,10 +107,10 @@ Deno.test("CouchDB latency proxy applies half the requested RTT in each directio
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`http://127.0.0.1:${proxyPort}/probe`);
|
const response = await fetch(`http://127.0.0.1:${proxyPort}/probe`);
|
||||||
assertEquals(await response.text(), "ok");
|
assert.deepStrictEqual(await response.text(), "ok");
|
||||||
assertEquals(proxy.directionalDelayMs, 10);
|
assert.deepStrictEqual(proxy.directionalDelayMs, 10);
|
||||||
assertEquals(delays, [10, 10]);
|
assert.deepStrictEqual(delays, [10, 10]);
|
||||||
assertEquals(proxy.snapshotCounters(), {
|
assert.deepStrictEqual(proxy.snapshotCounters(), {
|
||||||
requestCount: 1,
|
requestCount: 1,
|
||||||
requestBodyBytes: 0,
|
requestBodyBytes: 0,
|
||||||
responseBodyBytes: 2,
|
responseBodyBytes: 2,
|
||||||
@@ -116,34 +120,34 @@ Deno.test("CouchDB latency proxy applies half the requested RTT in each directio
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: "abc",
|
body: "abc",
|
||||||
});
|
});
|
||||||
assertEquals(await posted.text(), "ok");
|
assert.deepStrictEqual(await posted.text(), "ok");
|
||||||
assertEquals(proxy.snapshotCounters(), {
|
assert.deepStrictEqual(proxy.snapshotCounters(), {
|
||||||
requestCount: 1,
|
requestCount: 1,
|
||||||
requestBodyBytes: 3,
|
requestBodyBytes: 3,
|
||||||
responseBodyBytes: 2,
|
responseBodyBytes: 2,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
await proxy.stop();
|
await proxy.stop();
|
||||||
await backend.shutdown();
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
backend.close((error) => error ? reject(error) : resolve());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const halfMillisecondProxy = startCouchdbProxy({
|
const halfMillisecondProxy = await startCouchdbProxy({
|
||||||
backendUri: "http://127.0.0.1:1",
|
backendUri: "http://127.0.0.1:1",
|
||||||
proxyUri: `http://127.0.0.1:${getFreePort()}`,
|
proxyUri: `http://127.0.0.1:${await getFreePort()}`,
|
||||||
requestedRttMs: 1,
|
requestedRttMs: 1,
|
||||||
delay: () => Promise.resolve(),
|
delay: () => Promise.resolve(),
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
assertEquals(halfMillisecondProxy.directionalDelayMs, 0.5);
|
assert.deepStrictEqual(halfMillisecondProxy.directionalDelayMs, 0.5);
|
||||||
} finally {
|
} finally {
|
||||||
await halfMillisecondProxy.stop();
|
await halfMillisecondProxy.stop();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("compression benchmark dataset covers representative file kinds deterministically", async () => {
|
test("compression benchmark dataset covers representative file kinds deterministically", async () => {
|
||||||
const fixtureRoot = await Deno.makeTempDir({
|
const fixtureRoot = await mkdtemp(join(tmpdir(), "livesync-compression-contract-"));
|
||||||
prefix: "livesync-compression-contract-",
|
|
||||||
});
|
|
||||||
const repositoryRoot = `${fixtureRoot}/repository`;
|
const repositoryRoot = `${fixtureRoot}/repository`;
|
||||||
const vaultA = `${fixtureRoot}/vault-a`;
|
const vaultA = `${fixtureRoot}/vault-a`;
|
||||||
const vaultB = `${fixtureRoot}/vault-b`;
|
const vaultB = `${fixtureRoot}/vault-b`;
|
||||||
@@ -161,21 +165,21 @@ Deno.test("compression benchmark dataset covers representative file kinds determ
|
|||||||
try {
|
try {
|
||||||
for (const [index, relativePath] of repositoryFiles.entries()) {
|
for (const [index, relativePath] of repositoryFiles.entries()) {
|
||||||
const absolutePath = `${repositoryRoot}/${relativePath}`;
|
const absolutePath = `${repositoryRoot}/${relativePath}`;
|
||||||
await Deno.mkdir(
|
await mkdir(
|
||||||
absolutePath.slice(0, absolutePath.lastIndexOf("/")),
|
absolutePath.slice(0, absolutePath.lastIndexOf("/")),
|
||||||
{ recursive: true },
|
{ recursive: true },
|
||||||
);
|
);
|
||||||
await Deno.writeFile(
|
await writeFile(
|
||||||
absolutePath,
|
absolutePath,
|
||||||
new TextEncoder().encode(
|
new TextEncoder().encode(
|
||||||
`fixture-${index}-${relativePath}\n`.repeat(20),
|
`fixture-${index}-${relativePath}\n`.repeat(20),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await Deno.mkdir(vaultA, { recursive: true });
|
await mkdir(vaultA, { recursive: true });
|
||||||
await Deno.mkdir(vaultB, { recursive: true });
|
await mkdir(vaultB, { recursive: true });
|
||||||
const jpegEncoder = async (_input: string, output: string) => {
|
const jpegEncoder = async (_input: string, output: string) => {
|
||||||
await Deno.writeFile(
|
await writeFile(
|
||||||
output,
|
output,
|
||||||
new Uint8Array([
|
new Uint8Array([
|
||||||
0xff,
|
0xff,
|
||||||
@@ -205,7 +209,7 @@ Deno.test("compression benchmark dataset covers representative file kinds determ
|
|||||||
jpegEncoder,
|
jpegEncoder,
|
||||||
});
|
});
|
||||||
|
|
||||||
assertEquals(first.filesByKind, {
|
assert.deepStrictEqual(first.filesByKind, {
|
||||||
md: 3,
|
md: 3,
|
||||||
jpg: 2,
|
jpg: 2,
|
||||||
png: 2,
|
png: 2,
|
||||||
@@ -214,9 +218,9 @@ Deno.test("compression benchmark dataset covers representative file kinds determ
|
|||||||
gz: 1,
|
gz: 1,
|
||||||
bin: 1,
|
bin: 1,
|
||||||
});
|
});
|
||||||
assertEquals(first.totalFiles, 13);
|
assert.deepStrictEqual(first.totalFiles, 13);
|
||||||
assertEquals(first.jpegGenerator, "contract JPEG encoder");
|
assert.deepStrictEqual(first.jpegGenerator, "contract JPEG encoder");
|
||||||
assertEquals(
|
assert.deepStrictEqual(
|
||||||
first.entries.map((entry) => [
|
first.entries.map((entry) => [
|
||||||
entry.kind,
|
entry.kind,
|
||||||
entry.relativePath,
|
entry.relativePath,
|
||||||
@@ -230,11 +234,11 @@ Deno.test("compression benchmark dataset covers representative file kinds determ
|
|||||||
);
|
);
|
||||||
assert(first.entries.every((entry) => entry.size > 0));
|
assert(first.entries.every((entry) => entry.size > 0));
|
||||||
} finally {
|
} 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[] = [
|
const entries: DatasetEntry[] = [
|
||||||
{ kind: "md", relativePath: "a.md", absolutePath: "/a", size: 1 },
|
{ kind: "md", relativePath: "a.md", absolutePath: "/a", size: 1 },
|
||||||
{ kind: "md", relativePath: "b.md", absolutePath: "/b", 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 },
|
{ kind: "bin", relativePath: "e.bin", absolutePath: "/e", size: 1 },
|
||||||
];
|
];
|
||||||
|
|
||||||
assertEquals(parseBenchmarkVerificationMode("ALL"), "all");
|
assert.deepStrictEqual(parseBenchmarkVerificationMode("ALL"), "all");
|
||||||
assertEquals(selectVerificationEntries(entries, "all").length, entries.length);
|
assert.deepStrictEqual(selectVerificationEntries(entries, "all").length, entries.length);
|
||||||
const sample = selectVerificationEntries(entries, "sample");
|
const sample = selectVerificationEntries(entries, "sample");
|
||||||
assert(sample.length > 0 && sample.length < entries.length);
|
assert(sample.length > 0 && sample.length < entries.length);
|
||||||
assert(sample.some((entry) => entry.kind === "md"));
|
assert(sample.some((entry) => entry.kind === "md"));
|
||||||
assert(sample.some((entry) => entry.kind === "bin"));
|
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();
|
const cases = buildCases();
|
||||||
for (
|
for (
|
||||||
const name of [
|
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);
|
const testCase = getCase(cases, name);
|
||||||
assertEquals(testCase.runner, "p2p");
|
assert.deepStrictEqual(testCase.runner, "p2p");
|
||||||
assertEquals(testCase.env.BENCH_TURN_SERVERS, "");
|
assert.deepStrictEqual(testCase.env.BENCH_TURN_SERVERS, "");
|
||||||
assertEquals(testCase.env.BENCH_SIMULATION_TIER, "2");
|
assert.deepStrictEqual(testCase.env.BENCH_SIMULATION_TIER, "2");
|
||||||
assertEquals(
|
assert.deepStrictEqual(
|
||||||
testCase.env.BENCH_NETWORK_MODEL,
|
testCase.env.BENCH_NETWORK_MODEL,
|
||||||
"compose-netem-signalling-shim",
|
"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 cases = buildCases();
|
||||||
|
|
||||||
const smartphone = getCase(cases, "p2p-smartphone-vpn-direct");
|
const smartphone = getCase(cases, "p2p-smartphone-vpn-direct");
|
||||||
assertEquals(smartphone.env.BENCH_SIMULATION_TIER, "unmeasured");
|
assert.deepStrictEqual(smartphone.env.BENCH_SIMULATION_TIER, "unmeasured");
|
||||||
assertEquals(smartphone.env.BENCH_NETWORK_MODEL, "local-runner-no-netem");
|
assert.deepStrictEqual(smartphone.env.BENCH_NETWORK_MODEL, "local-runner-no-netem");
|
||||||
assert(
|
assert(
|
||||||
smartphone.limitations.some((limitation) =>
|
smartphone.limitations.some((limitation) =>
|
||||||
limitation.includes("must not be reported as smartphone")
|
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();
|
const cases = buildCases();
|
||||||
for (
|
for (
|
||||||
const name of ["couchdb-netem-home-wifi", "couchdb-netem-tethering-vpn"]
|
const name of ["couchdb-netem-home-wifi", "couchdb-netem-tethering-vpn"]
|
||||||
) {
|
) {
|
||||||
const testCase = getCase(cases, name);
|
const testCase = getCase(cases, name);
|
||||||
assertEquals(testCase.runner, "couchdb");
|
assert.deepStrictEqual(testCase.runner, "couchdb");
|
||||||
assertEquals(testCase.env.BENCH_SIMULATION_TIER, "2");
|
assert.deepStrictEqual(testCase.env.BENCH_SIMULATION_TIER, "2");
|
||||||
assertEquals(
|
assert.deepStrictEqual(
|
||||||
testCase.env.BENCH_NETWORK_MODEL,
|
testCase.env.BENCH_NETWORK_MODEL,
|
||||||
"compose-netem-tcp-shim",
|
"compose-netem-tcp-shim",
|
||||||
);
|
);
|
||||||
+25
-23
@@ -1,5 +1,7 @@
|
|||||||
import { assertEquals } from "@std/assert";
|
import { test } from "node:test";
|
||||||
import { join } from "@std/path";
|
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 { TempDir } from "./helpers/temp.ts";
|
||||||
import { runCliOrFail, runCliWithInputOrFail } from "./helpers/cli.ts";
|
import { runCliOrFail, runCliWithInputOrFail } from "./helpers/cli.ts";
|
||||||
import { applyCouchdbSettings, initSettingsFile } from "./helpers/settings.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 {
|
function envOrDefault(keys: string[], fallback: string): string {
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const value = Deno.env.get(key)?.trim();
|
const value = process.env[key]?.trim();
|
||||||
if (value) return value;
|
if (value) return value;
|
||||||
}
|
}
|
||||||
return fallback;
|
return fallback;
|
||||||
@@ -23,10 +25,10 @@ async function waitForText(filePath: string, expected: string, timeoutMs = 45_00
|
|||||||
let actual = "";
|
let actual = "";
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
try {
|
try {
|
||||||
actual = await Deno.readTextFile(filePath);
|
actual = await readFile(filePath, "utf8");
|
||||||
if (actual === expected) return;
|
if (actual === expected) return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!(error instanceof Deno.errors.NotFound)) throw error;
|
if ((error as { code?: string }).code !== "ENOENT") throw error;
|
||||||
}
|
}
|
||||||
await waitForTick();
|
await waitForTick();
|
||||||
}
|
}
|
||||||
@@ -39,9 +41,9 @@ async function waitForMissing(filePath: string, timeoutMs = 45_000): Promise<voi
|
|||||||
const deadline = Date.now() + timeoutMs;
|
const deadline = Date.now() + timeoutMs;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
try {
|
try {
|
||||||
await Deno.stat(filePath);
|
await stat(filePath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Deno.errors.NotFound) return;
|
if ((error as { code?: string }).code === "ENOENT") return;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
await waitForTick();
|
await waitForTick();
|
||||||
@@ -54,7 +56,7 @@ async function stopDaemon(daemon: BackgroundCliProcess | undefined): Promise<voi
|
|||||||
await daemon.stop().catch(() => {});
|
await daemon.stop().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
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");
|
await using workDir = await TempDir.create("livesync-cli-daemon-startup");
|
||||||
|
|
||||||
const couchdbUri = envOrDefault(["COUCHDB_URI", "hostname"], "http://127.0.0.1:5989").replace(/\/$/, "");
|
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");
|
const settingsC = workDir.join("settings-c.json");
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
Deno.mkdir(databaseA, { recursive: true }),
|
mkdir(databaseA, { recursive: true }),
|
||||||
Deno.mkdir(databaseB, { recursive: true }),
|
mkdir(databaseB, { recursive: true }),
|
||||||
Deno.mkdir(databaseC, { recursive: true }),
|
mkdir(databaseC, { recursive: true }),
|
||||||
Deno.mkdir(vaultA, { recursive: true }),
|
mkdir(vaultA, { recursive: true }),
|
||||||
Deno.mkdir(vaultB, { recursive: true }),
|
mkdir(vaultB, { recursive: true }),
|
||||||
Deno.mkdir(vaultC, { recursive: true }),
|
mkdir(vaultC, { recursive: true }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const startupPath = "notes/present-before-start.md";
|
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 deleteFileB = join(vaultB, deletePath);
|
||||||
const remoteOnlyFileB = join(vaultB, remoteOnlyPath);
|
const remoteOnlyFileB = join(vaultB, remoteOnlyPath);
|
||||||
|
|
||||||
await Deno.mkdir(join(vaultA, "notes"), { recursive: true });
|
await mkdir(join(vaultA, "notes"), { recursive: true });
|
||||||
await Deno.writeTextFile(startupFileA, "created before daemon startup\n");
|
await writeFile(startupFileA, "created before daemon startup\n");
|
||||||
const initialTime = new Date(Date.now() - 10_000);
|
const initialTime = new Date(Date.now() - 10_000);
|
||||||
await Deno.utime(startupFileA, initialTime, initialTime);
|
await utimes(startupFileA, initialTime, initialTime);
|
||||||
await Deno.writeTextFile(deleteFileA, "delete this after the first run\n");
|
await writeFile(deleteFileA, "delete this after the first run\n");
|
||||||
|
|
||||||
let daemonA: BackgroundCliProcess | undefined;
|
let daemonA: BackgroundCliProcess | undefined;
|
||||||
let daemonB: 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");
|
await waitForText(deleteFileB, "delete this after the first run\n");
|
||||||
|
|
||||||
// Changes made while A is stopped must be found by its next startup scan.
|
// 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;
|
daemonA = undefined;
|
||||||
await Deno.writeTextFile(startupFileA, "edited while daemon was stopped\n");
|
await writeFile(startupFileA, "edited while daemon was stopped\n");
|
||||||
await Deno.remove(deleteFileA);
|
await unlink(deleteFileA);
|
||||||
|
|
||||||
daemonA = startCliInBackground(databaseA, "--vault", vaultA, "--settings", settingsA, "daemon");
|
daemonA = startCliInBackground(databaseA, "--vault", vaultA, "--settings", settingsA, "daemon");
|
||||||
await daemonA.waitUntilContains("[Daemon] Initial replication complete", 45_000);
|
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 runCliOrFail(databaseC, "--vault", vaultC, "--settings", settingsC, "sync");
|
||||||
await waitForText(remoteOnlyFileB, "created in a different local database\n");
|
await waitForText(remoteOnlyFileB, "created in a different local database\n");
|
||||||
assertEquals(await Deno.readTextFile(startupFileB), "edited while daemon was stopped\n");
|
assert.deepStrictEqual(await readFile(startupFileB, "utf8"), "edited while daemon was stopped\n");
|
||||||
assertEquals((await Deno.stat(remoteOnlyFileB)).isFile, true);
|
assert.deepStrictEqual(((await stat(remoteOnlyFileB))).isFile(), true);
|
||||||
} finally {
|
} finally {
|
||||||
await stopDaemon(daemonB);
|
await stopDaemon(daemonB);
|
||||||
await stopDaemon(daemonA);
|
await stopDaemon(daemonA);
|
||||||
@@ -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.
|
* Tests daemon-related ignore rules behaviour.
|
||||||
*
|
*
|
||||||
@@ -13,33 +13,36 @@
|
|||||||
* 3. import: .gitignore directive → patterns from .gitignore are merged
|
* 3. import: .gitignore directive → patterns from .gitignore are merged
|
||||||
*
|
*
|
||||||
* Run:
|
* Run:
|
||||||
* deno test -A test-daemon.ts
|
* node --test test-daemon.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { join } from "@std/path";
|
import { test } from "node:test";
|
||||||
import { assertEquals } from "@std/assert";
|
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 { TempDir } from "./helpers/temp.ts";
|
||||||
import { runCliOrFail, runCli, assertContains, assertNotContains } from "./helpers/cli.ts";
|
import { runCliOrFail, runCli, assertContains, assertNotContains } from "./helpers/cli.ts";
|
||||||
import { initSettingsFile, markSettingsConfigured } from "./helpers/settings.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
|
// 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");
|
await using workDir = await TempDir.create("livesync-cli-daemon-c1");
|
||||||
const settingsFile = workDir.join("data.json");
|
const settingsFile = workDir.join("data.json");
|
||||||
const vaultDir = workDir.join("vault");
|
const vaultDir = workDir.join("vault");
|
||||||
|
|
||||||
await Deno.mkdir(join(vaultDir, ".livesync"), { recursive: true });
|
await mkdir(join(vaultDir, ".livesync"), { recursive: true });
|
||||||
await Deno.mkdir(join(vaultDir, "notes"), { recursive: true });
|
await mkdir(join(vaultDir, "notes"), { recursive: true });
|
||||||
|
|
||||||
await initSettingsFile(settingsFile);
|
await initSettingsFile(settingsFile);
|
||||||
await markSettingsConfigured(settingsFile);
|
await markSettingsConfigured(settingsFile);
|
||||||
|
|
||||||
await Deno.writeTextFile(join(vaultDir, ".livesync", "ignore"), "*.tmp\n");
|
await writeFile(join(vaultDir, ".livesync", "ignore"), "*.tmp\n");
|
||||||
await Deno.writeTextFile(join(vaultDir, "notes", "normal.md"), "normal content\n");
|
await writeFile(join(vaultDir, "notes", "normal.md"), "normal content\n");
|
||||||
await Deno.writeTextFile(join(vaultDir, "notes", "scratch.tmp"), "tmp content\n");
|
await writeFile(join(vaultDir, "notes", "scratch.tmp"), "tmp content\n");
|
||||||
|
|
||||||
console.log("[INFO] Running mirror for Case 1...");
|
console.log("[INFO] Running mirror for Case 1...");
|
||||||
await runCliOrFail(vaultDir, "--settings", settingsFile, "mirror");
|
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.
|
// The normal file should be in the database.
|
||||||
const resultNormal = workDir.join("case1-normal.txt");
|
const resultNormal = workDir.join("case1-normal.txt");
|
||||||
await runCliOrFail(vaultDir, "--settings", settingsFile, "pull", "notes/normal.md", resultNormal);
|
await runCliOrFail(vaultDir, "--settings", settingsFile, "pull", "notes/normal.md", resultNormal);
|
||||||
const normalContent = await Deno.readTextFile(resultNormal);
|
const normalContent = await readFile(resultNormal, "utf8");
|
||||||
assertEquals(normalContent, "normal content\n", "normal.md content mismatch after mirror");
|
assert.deepStrictEqual(normalContent, "normal content\n", "normal.md content mismatch after mirror");
|
||||||
|
|
||||||
// The .tmp file should NOT be in the database.
|
// The .tmp file should NOT be in the database.
|
||||||
const dbList = await runCliOrFail(vaultDir, "--settings", settingsFile, "ls");
|
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
|
// 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");
|
await using workDir = await TempDir.create("livesync-cli-daemon-c2");
|
||||||
const settingsFile = workDir.join("data2.json");
|
const settingsFile = workDir.join("data2.json");
|
||||||
const vaultDir = workDir.join("vault2");
|
const vaultDir = workDir.join("vault2");
|
||||||
|
|
||||||
await Deno.mkdir(join(vaultDir, "notes"), { recursive: true });
|
await mkdir(join(vaultDir, "notes"), { recursive: true });
|
||||||
|
|
||||||
await initSettingsFile(settingsFile);
|
await initSettingsFile(settingsFile);
|
||||||
await markSettingsConfigured(settingsFile);
|
await markSettingsConfigured(settingsFile);
|
||||||
|
|
||||||
// No .livesync directory at all.
|
// 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...");
|
console.log("[INFO] Running mirror for Case 2...");
|
||||||
const result = await runCli(vaultDir, "--settings", settingsFile, "mirror");
|
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.
|
// The normal file should have been synced.
|
||||||
const resultHello = workDir.join("case2-hello.txt");
|
const resultHello = workDir.join("case2-hello.txt");
|
||||||
await runCliOrFail(vaultDir, "--settings", settingsFile, "pull", "notes/hello.md", resultHello);
|
await runCliOrFail(vaultDir, "--settings", settingsFile, "pull", "notes/hello.md", resultHello);
|
||||||
const helloContent = await Deno.readTextFile(resultHello);
|
const helloContent = await readFile(resultHello, "utf8");
|
||||||
assertEquals(helloContent, "hello\n", "file content mismatch when .livesync/ignore is absent");
|
assert.deepStrictEqual(helloContent, "hello\n", "file content mismatch when .livesync/ignore is absent");
|
||||||
console.log("[PASS] Case 2 verified successfully");
|
console.log("[PASS] Case 2 verified successfully");
|
||||||
});
|
});
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Case 3: import: .gitignore merges patterns
|
// 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");
|
await using workDir = await TempDir.create("livesync-cli-daemon-c3");
|
||||||
const settingsFile = workDir.join("data3.json");
|
const settingsFile = workDir.join("data3.json");
|
||||||
const vaultDir = workDir.join("vault3");
|
const vaultDir = workDir.join("vault3");
|
||||||
|
|
||||||
await Deno.mkdir(join(vaultDir, ".livesync"), { recursive: true });
|
await mkdir(join(vaultDir, ".livesync"), { recursive: true });
|
||||||
await Deno.mkdir(join(vaultDir, "notes"), { recursive: true });
|
await mkdir(join(vaultDir, "notes"), { recursive: true });
|
||||||
|
|
||||||
await initSettingsFile(settingsFile);
|
await initSettingsFile(settingsFile);
|
||||||
await markSettingsConfigured(settingsFile);
|
await markSettingsConfigured(settingsFile);
|
||||||
|
|
||||||
await Deno.writeTextFile(join(vaultDir, ".livesync", "ignore"), "import: .gitignore\n");
|
await writeFile(join(vaultDir, ".livesync", "ignore"), "import: .gitignore\n");
|
||||||
await Deno.writeTextFile(join(vaultDir, ".gitignore"), "# gitignore comment\n*.log\nbuild/\n");
|
await writeFile(join(vaultDir, ".gitignore"), "# gitignore comment\n*.log\nbuild/\n");
|
||||||
|
|
||||||
await Deno.writeTextFile(join(vaultDir, "notes", "regular.md"), "regular note\n");
|
await writeFile(join(vaultDir, "notes", "regular.md"), "regular note\n");
|
||||||
await Deno.writeTextFile(join(vaultDir, "notes", "debug.log"), "log content\n");
|
await writeFile(join(vaultDir, "notes", "debug.log"), "log content\n");
|
||||||
|
|
||||||
console.log("[INFO] Running mirror for Case 3...");
|
console.log("[INFO] Running mirror for Case 3...");
|
||||||
await runCliOrFail(vaultDir, "--settings", settingsFile, "mirror");
|
await runCliOrFail(vaultDir, "--settings", settingsFile, "mirror");
|
||||||
+24
-21
@@ -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
|
* Tests push, pull, and mirror command behaviour when the vault directory is
|
||||||
* decoupled (separated) from the database directory.
|
* decoupled (separated) from the database directory.
|
||||||
*
|
*
|
||||||
* Run:
|
* Run:
|
||||||
* deno test -A test-decoupled-vault.ts
|
* node --test test-decoupled-vault.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { join } from "@std/path";
|
import { test } from "node:test";
|
||||||
import { assertEquals } from "@std/assert";
|
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 { TempDir } from "./helpers/temp.ts";
|
||||||
import { runCliOrFail } from "./helpers/cli.ts";
|
import { runCliOrFail } from "./helpers/cli.ts";
|
||||||
import { applyCouchdbSettings, initSettingsFile, markSettingsConfigured } from "./helpers/settings.ts";
|
import { applyCouchdbSettings, initSettingsFile, markSettingsConfigured } from "./helpers/settings.ts";
|
||||||
import { startCouchdb, stopCouchdb } from "./helpers/docker.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");
|
await using workDir = await TempDir.create("livesync-cli-decoupled");
|
||||||
|
|
||||||
const settingsFile = workDir.join("data.json");
|
const settingsFile = workDir.join("data.json");
|
||||||
const vaultDir = workDir.join("vault");
|
const vaultDir = workDir.join("vault");
|
||||||
const dbDir = workDir.join("db");
|
const dbDir = workDir.join("db");
|
||||||
|
|
||||||
await Deno.mkdir(join(vaultDir, "test"), { recursive: true });
|
await mkdir(join(vaultDir, "test"), { recursive: true });
|
||||||
await Deno.mkdir(dbDir, { recursive: true });
|
await mkdir(dbDir, { recursive: true });
|
||||||
|
|
||||||
const uri = Deno.env.get("COUCHDB_URI") ?? "http://127.0.0.1:5989/";
|
const uri = process.env["COUCHDB_URI"] ?? "http://127.0.0.1:5989/";
|
||||||
const user = Deno.env.get("COUCHDB_USER") ?? "admin";
|
const user = process.env["COUCHDB_USER"] ?? "admin";
|
||||||
const password = Deno.env.get("COUCHDB_PASSWORD") ?? "testpassword";
|
const password = process.env["COUCHDB_PASSWORD"] ?? "testpassword";
|
||||||
const dbname = Deno.env.get("COUCHDB_DBNAME") ?? `decoupled-${Date.now()}`;
|
const dbname = process.env["COUCHDB_DBNAME"] ?? `decoupled-${Date.now()}`;
|
||||||
|
|
||||||
const shouldStartDocker = Deno.env.get("LIVESYNC_START_DOCKER") !== "0";
|
const shouldStartDocker = process.env["LIVESYNC_START_DOCKER"] !== "0";
|
||||||
const keepDocker = Deno.env.get("LIVESYNC_DEBUG_KEEP_DOCKER") === "1";
|
const keepDocker = process.env["LIVESYNC_DEBUG_KEEP_DOCKER"] === "1";
|
||||||
|
|
||||||
if (shouldStartDocker) {
|
if (shouldStartDocker) {
|
||||||
await startCouchdb(uri, user, password, dbname);
|
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 srcFile = workDir.join("push-source.txt");
|
||||||
const pulledFile = workDir.join("pull-result.txt");
|
const pulledFile = workDir.join("pull-result.txt");
|
||||||
const content = `push-pull-decoupled-test ${new Date().toISOString()}\n`;
|
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
|
// 1. Test push command with decoupled vault directory
|
||||||
console.log(`[INFO] push with decoupled vault -> ${REMOTE_PATH}`);
|
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}`);
|
console.log(`[INFO] pull with decoupled vault <- ${REMOTE_PATH}`);
|
||||||
await runCliOrFail(dbDir, "--vault", vaultDir, "--settings", settingsFile, "pull", REMOTE_PATH, pulledFile);
|
await runCliOrFail(dbDir, "--vault", vaultDir, "--settings", settingsFile, "pull", REMOTE_PATH, pulledFile);
|
||||||
|
|
||||||
const pulled = await Deno.readTextFile(pulledFile);
|
const pulled = await readFile(pulledFile, "utf8");
|
||||||
assertEquals(pulled, content, "push/pull roundtrip with decoupled vault content mismatch");
|
assert.deepStrictEqual(pulled, content, "push/pull roundtrip with decoupled vault content mismatch");
|
||||||
console.log("[PASS] push/pull roundtrip with decoupled vault matched");
|
console.log("[PASS] push/pull roundtrip with decoupled vault matched");
|
||||||
|
|
||||||
// 3. Clean up pulled file and vault test directory to verify mirror
|
// 3. Clean up pulled file and vault test directory to verify mirror
|
||||||
await Deno.remove(pulledFile).catch(() => {});
|
await unlink(pulledFile).catch(() => {});
|
||||||
await Deno.remove(join(vaultDir, "test"), { recursive: true }).catch(() => {});
|
await rm(join(vaultDir, "test"), { recursive: true }).catch(() => {});
|
||||||
|
|
||||||
// 4. Test mirror command with decoupled vault directory
|
// 4. Test mirror command with decoupled vault directory
|
||||||
console.log("[INFO] mirror with decoupled vault");
|
console.log("[INFO] mirror with decoupled vault");
|
||||||
await runCliOrFail(dbDir, "--vault", vaultDir, "--settings", settingsFile, "mirror");
|
await runCliOrFail(dbDir, "--vault", vaultDir, "--settings", settingsFile, "mirror");
|
||||||
|
|
||||||
const restoredFile = join(vaultDir, REMOTE_PATH);
|
const restoredFile = join(vaultDir, REMOTE_PATH);
|
||||||
const restored = await Deno.readTextFile(restoredFile);
|
const restored = await readFile(restoredFile, "utf8");
|
||||||
assertEquals(restored, content, "mirror with decoupled vault content mismatch");
|
assert.deepStrictEqual(restored, content, "mirror with decoupled vault content mismatch");
|
||||||
console.log("[PASS] mirror with decoupled vault matched");
|
console.log("[PASS] mirror with decoupled vault matched");
|
||||||
} finally {
|
} finally {
|
||||||
if (shouldStartDocker && !keepDocker) {
|
if (shouldStartDocker && !keepDocker) {
|
||||||
+11
-9
@@ -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 { TempDir } from "./helpers/temp.ts";
|
||||||
import {
|
import {
|
||||||
runCli,
|
runCli,
|
||||||
@@ -14,7 +16,7 @@ type RemoteType = "COUCHDB" | "MINIO";
|
|||||||
|
|
||||||
function requireEnv(...keys: string[]): string {
|
function requireEnv(...keys: string[]): string {
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const value = Deno.env.get(key)?.trim();
|
const value = process.env[key]?.trim();
|
||||||
if (value) return value;
|
if (value) return value;
|
||||||
}
|
}
|
||||||
throw new Error(`Required env var is missing: ${keys.join(" or ")}`);
|
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 pullDst = workDir.join("pull-destination.txt");
|
||||||
const pushBinarySrc = workDir.join("push-source.bin");
|
const pushBinarySrc = workDir.join("push-source.bin");
|
||||||
const pullBinaryDst = workDir.join("pull-destination.bin");
|
const pullBinaryDst = workDir.join("pull-destination.bin");
|
||||||
await Deno.mkdir(vaultA, { recursive: true });
|
await mkdir(vaultA, { recursive: true });
|
||||||
await Deno.mkdir(vaultB, { 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") {
|
if (remoteType === "COUCHDB") {
|
||||||
await startCouchdb(couchdbUri, couchdbUser, couchdbPassword, dbname);
|
await startCouchdb(couchdbUri, couchdbUser, couchdbPassword, dbname);
|
||||||
} else {
|
} else {
|
||||||
@@ -168,7 +170,7 @@ export async function runScenario(remoteType: RemoteType, encrypt: boolean): Pro
|
|||||||
).trimEnd();
|
).trimEnd();
|
||||||
assert(secondVisible === `second-sync-round-${dbSuffix}`);
|
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 runCliOrFail(vaultA, "--settings", settingsA, "push", pushSrc, targetPush);
|
||||||
await runCliWithInputOrFail(`put-content-${dbSuffix}\n`, vaultA, "--settings", settingsA, "put", targetPut);
|
await runCliWithInputOrFail(`put-content-${dbSuffix}\n`, vaultA, "--settings", settingsA, "put", targetPut);
|
||||||
await syncBoth();
|
await syncBoth();
|
||||||
@@ -181,7 +183,7 @@ export async function runScenario(remoteType: RemoteType, encrypt: boolean): Pro
|
|||||||
|
|
||||||
const binary = new Uint8Array(4096);
|
const binary = new Uint8Array(4096);
|
||||||
binary.fill(0x61);
|
binary.fill(0x61);
|
||||||
await Deno.writeFile(pushBinarySrc, binary);
|
await writeFile(pushBinarySrc, binary);
|
||||||
await runCliOrFail(vaultA, "--settings", settingsA, "push", pushBinarySrc, targetPushBinary);
|
await runCliOrFail(vaultA, "--settings", settingsA, "push", pushBinarySrc, targetPushBinary);
|
||||||
await syncBoth();
|
await syncBoth();
|
||||||
await runCliOrFail(vaultB, "--settings", settingsB, "pull", targetPushBinary, pullBinaryDst);
|
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);
|
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);
|
await runScenario("COUCHDB", true);
|
||||||
});
|
});
|
||||||
+2
-1
@@ -1,3 +1,4 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
import { runScenario } from "./test-e2e-two-vaults-couchdb.ts";
|
import { runScenario } from "./test-e2e-two-vaults-couchdb.ts";
|
||||||
|
|
||||||
type MatrixCase = {
|
type MatrixCase = {
|
||||||
@@ -14,7 +15,7 @@ const matrixCases: MatrixCase[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
for (const tc of matrixCases) {
|
for (const tc of matrixCases) {
|
||||||
Deno.test(`e2e matrix: ${tc.label}`, async () => {
|
test(`e2e matrix: ${tc.label}`, async () => {
|
||||||
await runScenario(tc.remoteType, tc.encrypt);
|
await runScenario(tc.remoteType, tc.encrypt);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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
|
* Tests the `mirror` command — bidirectional synchronisation between a local
|
||||||
* storage directory (vault) and an in-process database.
|
* storage directory (vault) and an in-process database.
|
||||||
@@ -16,15 +16,18 @@
|
|||||||
* No external services are required.
|
* No external services are required.
|
||||||
*
|
*
|
||||||
* Run:
|
* 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 { TempDir } from "./helpers/temp.ts";
|
||||||
import { runCliOrFail, runCliWithInputOrFail } from "./helpers/cli.ts";
|
import { runCliOrFail, runCliWithInputOrFail } from "./helpers/cli.ts";
|
||||||
import { initSettingsFile, markSettingsConfigured } from "./helpers/settings.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");
|
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 settingsFile = workDir.join("data.json");
|
||||||
const vaultDir = workDir.join("vault");
|
const vaultDir = workDir.join("vault");
|
||||||
const dbDir = workDir.join("db");
|
const dbDir = workDir.join("db");
|
||||||
await Deno.mkdir(workDir.join("vault", "test"), { recursive: true });
|
await mkdir(workDir.join("vault", "test"), { recursive: true });
|
||||||
await Deno.mkdir(dbDir, { recursive: true });
|
await mkdir(dbDir, { recursive: true });
|
||||||
|
|
||||||
await initSettingsFile(settingsFile);
|
await initSettingsFile(settingsFile);
|
||||||
// isConfigured=true is required for canProceedScan in the mirror command.
|
// isConfigured=true is required for canProceedScan in the mirror command.
|
||||||
await markSettingsConfigured(settingsFile);
|
await markSettingsConfigured(settingsFile);
|
||||||
|
|
||||||
const data = JSON.parse(await Deno.readTextFile(settingsFile));
|
const data = JSON.parse(await readFile(settingsFile, "utf8"));
|
||||||
data.writeDocumentsIfConflicted = true;
|
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)
|
// Copy settings to the DB directory (separated-path mode)
|
||||||
const dbSettings = workDir.join("db", "settings.json");
|
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. */
|
/** Run mirror in separated-path mode: DB dir ≠ vault dir. */
|
||||||
const runMirror = () => runCliOrFail(dbDir, "--settings", dbSettings, "mirror", vaultDir);
|
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)
|
// 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");
|
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();
|
await runMirror();
|
||||||
|
|
||||||
const resultFile = workDir.join("case1-pull.txt");
|
const resultFile = workDir.join("case1-pull.txt");
|
||||||
await dbRun("pull", "test/storage-only.md", resultFile);
|
await dbRun("pull", "test/storage-only.md", resultFile);
|
||||||
|
|
||||||
const storageContent = await Deno.readTextFile(storageFile);
|
const storageContent = await readFile(storageFile, "utf8");
|
||||||
const pulledContent = await Deno.readTextFile(resultFile);
|
const pulledContent = await readFile(resultFile, "utf8");
|
||||||
assert(
|
assert(
|
||||||
storageContent === pulledContent,
|
storageContent === pulledContent,
|
||||||
`storage-only file NOT synced into DB\nexpected: ${storageContent}\ngot: ${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)
|
// 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(
|
await dbRun(
|
||||||
"push",
|
"push",
|
||||||
// write inline via push (pipe not needed — push takes a file path)
|
// write inline via push (pipe not needed — push takes a file path)
|
||||||
// create a temp file with content and push it
|
// create a temp file with content and push it
|
||||||
await (async () => {
|
await (async () => {
|
||||||
const tmp = workDir.join("db-only-src.txt");
|
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;
|
return tmp;
|
||||||
})(),
|
})(),
|
||||||
"test/db-only.md"
|
"test/db-only.md"
|
||||||
@@ -101,7 +104,7 @@ Deno.test("mirror: storage <-> DB synchronisation", async (t) => {
|
|||||||
await runMirror();
|
await runMirror();
|
||||||
|
|
||||||
assert(await exists(storagePath), "DB-only file NOT restored to storage after mirror");
|
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}'`);
|
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");
|
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
|
// 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");
|
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("push", deletedSrc, "test/deleted.md");
|
||||||
await dbRun("rm", "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)
|
// 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)
|
// Seed DB with old content (mtime ~ now)
|
||||||
const seedFile = workDir.join("case4-seed.txt");
|
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");
|
await dbRun("push", seedFile, "test/sync-storage-newer.md");
|
||||||
|
|
||||||
// Reflect the shared base into the actual Vault before editing it.
|
// Reflect the shared base into the actual Vault before editing it.
|
||||||
await runMirror();
|
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
|
// Write new content to storage with a timestamp 1 hour in the future
|
||||||
const storageFile = workDir.join("vault", "test", "sync-storage-newer.md");
|
const storageFile = workDir.join("vault", "test", "sync-storage-newer.md");
|
||||||
await Deno.writeTextFile(storageFile, "new content\n");
|
await writeFile(storageFile, "new content\n");
|
||||||
await Deno.utime(storageFile, new Date(), new Date(Date.now() + 3600_000));
|
await utimes(storageFile, new Date(), new Date(Date.now() + 3600_000));
|
||||||
|
|
||||||
await runMirror();
|
await runMirror();
|
||||||
|
|
||||||
const resultFile = workDir.join("case4-pull.txt");
|
const resultFile = workDir.join("case4-pull.txt");
|
||||||
const info = JSON.parse(await dbRun("info", "test/sync-storage-newer.md"));
|
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);
|
await dbRun("pull", "test/sync-storage-newer.md", resultFile);
|
||||||
const storageContent = await Deno.readTextFile(storageFile);
|
const storageContent = await readFile(storageFile, "utf8");
|
||||||
const pulledContent = await Deno.readTextFile(resultFile);
|
const pulledContent = await readFile(resultFile, "utf8");
|
||||||
assert(
|
assert(
|
||||||
storageContent === pulledContent,
|
storageContent === pulledContent,
|
||||||
`DB NOT updated to match newer storage file\nexpected: ${storageContent}\ngot: ${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)
|
// 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
|
// Write old content to storage with a timestamp 1 hour in the past
|
||||||
const storageFile = workDir.join("vault", "test", "sync-db-newer.md");
|
const storageFile = workDir.join("vault", "test", "sync-db-newer.md");
|
||||||
await Deno.writeTextFile(storageFile, "old storage content\n");
|
await writeFile(storageFile, "old storage content\n");
|
||||||
await Deno.utime(storageFile, new Date(), new Date(Date.now() - 3600_000));
|
await utimes(storageFile, new Date(), new Date(Date.now() - 3600_000));
|
||||||
|
|
||||||
// Write new content to DB only (mtime ~ now, newer than the storage file)
|
// Write new content to DB only (mtime ~ now, newer than the storage file)
|
||||||
const dbNewFile = workDir.join("case5-db-new.txt");
|
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 dbRun("push", dbNewFile, "test/sync-db-newer.md");
|
||||||
|
|
||||||
await runMirror();
|
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}')`);
|
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");
|
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)
|
// 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");
|
const compatFile = workDir.join("vault", "compat.md");
|
||||||
await Deno.writeTextFile(compatFile, "compat-content\n");
|
await writeFile(compatFile, "compat-content\n");
|
||||||
|
|
||||||
await runMirrorCompat();
|
await runMirrorCompat();
|
||||||
|
|
||||||
const resultFile = workDir.join("case6-pull.txt");
|
const resultFile = workDir.join("case6-pull.txt");
|
||||||
await compatRun("pull", "compat.md", resultFile);
|
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}')`);
|
assert(pulled === "compat-content\n", `Compatibility mode failed to sync file into DB (got: '${pulled}')`);
|
||||||
console.log("[PASS] case 6: compatibility mode works");
|
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.
|
// Case 7: unknown local origin must preserve both contents regardless of mtime.
|
||||||
// This deliberately uses put: push would record a file provenance entry.
|
// 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 path = "test/unknown-origin.md";
|
||||||
const storageFile = workDir.join("vault", "test", "unknown-origin.md");
|
const storageFile = workDir.join("vault", "test", "unknown-origin.md");
|
||||||
await runCliWithInputOrFail("original DB content\n", dbDir, "--settings", dbSettings, "put", path);
|
await runCliWithInputOrFail("original DB content\n", dbDir, "--settings", dbSettings, "put", path);
|
||||||
const writeUnknownFile = async () => {
|
const writeUnknownFile = async () => {
|
||||||
await Deno.writeTextFile(storageFile, "unrelated local content\n");
|
await writeFile(storageFile, "unrelated local content\n");
|
||||||
await Deno.utime(storageFile, new Date(), new Date(Date.now() + 3600_000));
|
await utimes(storageFile, new Date(), new Date(Date.now() + 3600_000));
|
||||||
};
|
};
|
||||||
await writeUnknownFile();
|
await writeUnknownFile();
|
||||||
await runMirror();
|
await runMirror();
|
||||||
@@ -215,22 +218,22 @@ Deno.test("mirror: storage <-> DB synchronisation", async (t) => {
|
|||||||
for (const revision of [info.revision, info.conflicts]) {
|
for (const revision of [info.revision, info.conflicts]) {
|
||||||
contents.set(await dbRun("cat-rev", path, revision), revision);
|
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
|
// Re-submit identical local bytes even if incoming reflection replaced
|
||||||
// the file under writeDocumentsIfConflicted; no third branch is needed.
|
// the file under writeDocumentsIfConflicted; no third branch is needed.
|
||||||
await writeUnknownFile();
|
await writeUnknownFile();
|
||||||
await runMirror();
|
await runMirror();
|
||||||
const repeated = JSON.parse(await dbRun("info", path));
|
const repeated = JSON.parse(await dbRun("info", path));
|
||||||
assertEquals(repeated.revision, info.revision);
|
assert.deepStrictEqual(repeated.revision, info.revision);
|
||||||
assertEquals(repeated.conflicts, info.conflicts);
|
assert.deepStrictEqual(repeated.conflicts, info.conflicts);
|
||||||
|
|
||||||
const localRevision = contents.get("unrelated local content\n")!;
|
const localRevision = contents.get("unrelated local content\n")!;
|
||||||
await runCliOrFail(dbDir, "--vault", vaultDir, "--settings", dbSettings, "resolve", path, localRevision);
|
await runCliOrFail(dbDir, "--vault", vaultDir, "--settings", dbSettings, "resolve", path, localRevision);
|
||||||
const resolved = JSON.parse(await dbRun("info", path));
|
const resolved = JSON.parse(await dbRun("info", path));
|
||||||
assertEquals(resolved.conflicts, "N/A");
|
assert.deepStrictEqual(resolved.conflicts, "N/A");
|
||||||
assertEquals(resolved.revision, localRevision);
|
assert.deepStrictEqual(resolved.revision, localRevision);
|
||||||
assertEquals(await Deno.readTextFile(storageFile), "unrelated local content\n");
|
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<boolean> {
|
async function exists(path: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await Deno.stat(path);
|
await stat(path);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
+28
-21
@@ -1,5 +1,9 @@
|
|||||||
import { assert, assertEquals } from "@std/assert";
|
import { test } from "node:test";
|
||||||
import { join } from "@std/path";
|
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 { TempDir } from "./helpers/temp.ts";
|
||||||
import { applyP2pSettings, applyP2pTestTweaks, initSettingsFile } from "./helpers/settings.ts";
|
import { applyP2pSettings, applyP2pTestTweaks, initSettingsFile } from "./helpers/settings.ts";
|
||||||
import { CLI_DIR } from "./helpers/cli.ts";
|
import { CLI_DIR } from "./helpers/cli.ts";
|
||||||
@@ -86,10 +90,13 @@ class AutomaticP2PHostProcess {
|
|||||||
#stdinWriter: WritableStreamDefaultWriter<Uint8Array>;
|
#stdinWriter: WritableStreamDefaultWriter<Uint8Array>;
|
||||||
#stdoutTask: Promise<void>;
|
#stdoutTask: Promise<void>;
|
||||||
#stderrTask: Promise<void>;
|
#stderrTask: Promise<void>;
|
||||||
#status: Promise<Deno.CommandStatus>;
|
#status: Promise<ProcessStatus>;
|
||||||
#stopped = false;
|
#stopped = false;
|
||||||
|
|
||||||
private constructor(readonly child: Deno.ChildProcess) {
|
readonly child: RunningProcess;
|
||||||
|
|
||||||
|
private constructor(child: RunningProcess) {
|
||||||
|
this.child = child;
|
||||||
this.#stdinWriter = child.stdin.getWriter();
|
this.#stdinWriter = child.stdin.getWriter();
|
||||||
this.#stdoutTask = this.#pump(child.stdout, (text) => this.#handleStdout(text));
|
this.#stdoutTask = this.#pump(child.stdout, (text) => this.#handleStdout(text));
|
||||||
this.#stderrTask = this.#pump(child.stderr, (text) => (this.#stderr += 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_PATH_ENV] = seed.path;
|
||||||
env[SEED_CONTENT_ENV] = seed.content;
|
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"],
|
args: [join(CLI_DIR, "dist", "p2p-lifecycle-test.cjs"), vaultPath, "--settings", settingsPath, "p2p-host"],
|
||||||
cwd: CLI_DIR,
|
cwd: CLI_DIR,
|
||||||
env,
|
env,
|
||||||
stdin: "piped",
|
stdin: "piped",
|
||||||
stdout: "piped",
|
stdout: "piped",
|
||||||
stderr: "piped",
|
stderr: "piped",
|
||||||
}).spawn();
|
});
|
||||||
const fixture = new AutomaticP2PHostProcess(child);
|
const fixture = new AutomaticP2PHostProcess(child);
|
||||||
try {
|
try {
|
||||||
await withTimeout(fixture.#ready, readyTimeoutMs, "waiting for the P2P automatic-host fixture to connect");
|
await withTimeout(fixture.#ready, readyTimeoutMs, "waiting for the P2P automatic-host fixture to connect");
|
||||||
@@ -282,7 +289,7 @@ async function configureAutomaticSettings(
|
|||||||
turnServers
|
turnServers
|
||||||
);
|
);
|
||||||
await applyP2pTestTweaks(settingsPath, peerName, passphrase);
|
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_AutoAccepting = 0;
|
||||||
settings.P2P_AutoStart = false;
|
settings.P2P_AutoStart = false;
|
||||||
settings.P2P_AutoBroadcast = true;
|
settings.P2P_AutoBroadcast = true;
|
||||||
@@ -290,21 +297,21 @@ async function configureAutomaticSettings(
|
|||||||
settings.P2P_AutoWatchPeers = autoWatchPeers;
|
settings.P2P_AutoWatchPeers = autoWatchPeers;
|
||||||
settings.P2P_AutoAcceptingPeers = autoAcceptingPeers;
|
settings.P2P_AutoAcceptingPeers = autoAcceptingPeers;
|
||||||
settings.P2P_AutoDenyingPeers = "";
|
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 () => {
|
test("p2p-host: restored automatic policy syncs clients through a persistent middle peer", async () => {
|
||||||
const relay = Deno.env.get("RELAY") ?? "ws://localhost:4000/";
|
const relay = process.env["RELAY"] ?? "ws://localhost:4000/";
|
||||||
const peersTimeoutMs = Number(Deno.env.get("PEERS_TIMEOUT") ?? "20") * 1000;
|
const peersTimeoutMs = Number(process.env["PEERS_TIMEOUT"] ?? "20") * 1000;
|
||||||
const syncTimeoutMs = Number(Deno.env.get("SYNC_TIMEOUT") ?? "60") * 1000;
|
const syncTimeoutMs = Number(process.env["SYNC_TIMEOUT"] ?? "60") * 1000;
|
||||||
const nonce = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
|
const nonce = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
|
||||||
const roomId = `automatic-host-room-${nonce}`;
|
const roomId = `automatic-host-room-${nonce}`;
|
||||||
const passphrase = `automatic-host-pass-${nonce}`;
|
const passphrase = `automatic-host-pass-${nonce}`;
|
||||||
const hostPeerName = `p2p-auto-host-${nonce}`;
|
const hostPeerName = `p2p-auto-host-${nonce}`;
|
||||||
const clientAPeerName = `p2p-auto-client-a-${nonce}`;
|
const clientAPeerName = `p2p-auto-client-a-${nonce}`;
|
||||||
const clientBPeerName = `p2p-auto-client-b-${nonce}`;
|
const clientBPeerName = `p2p-auto-client-b-${nonce}`;
|
||||||
const useCoturn = Deno.env.get("LIVESYNC_USE_COTURN") !== "0";
|
const useCoturn = process.env["LIVESYNC_USE_COTURN"] !== "0";
|
||||||
const turnServers = Deno.env.get("TURN_SERVERS") ?? (useCoturn ? "turn:127.0.0.1:3478" : "none");
|
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");
|
await using workDir = await TempDir.create("livesync-cli-p2p-automatic-host");
|
||||||
const hostVault = workDir.join("vault-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 clientASettings = workDir.join("settings-client-a.json");
|
||||||
const clientBSettings = workDir.join("settings-client-b.json");
|
const clientBSettings = workDir.join("settings-client-b.json");
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
Deno.mkdir(hostVault, { recursive: true }),
|
mkdir(hostVault, { recursive: true }),
|
||||||
Deno.mkdir(clientAVault, { recursive: true }),
|
mkdir(clientAVault, { recursive: true }),
|
||||||
Deno.mkdir(clientBVault, { recursive: true }),
|
mkdir(clientBVault, { recursive: true }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const relayStarted = await maybeStartLocalRelay(relay);
|
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);
|
host = await AutomaticP2PHostProcess.start(hostVault, hostSettings, HOST_BASELINE, 20000);
|
||||||
const initialHostState = await host.state();
|
const initialHostState = await host.state();
|
||||||
assert(initialHostState.connected, "p2p-host did not explicitly connect while P2P_AutoStart was false");
|
assert(initialHostState.connected, "p2p-host did not explicitly connect while P2P_AutoStart was false");
|
||||||
assertEquals(
|
assert.deepStrictEqual(
|
||||||
initialHostState.policy,
|
initialHostState.policy,
|
||||||
{
|
{
|
||||||
P2P_Enabled: true,
|
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 host.write(HOST_UPDATE.path, HOST_UPDATE.content);
|
||||||
await clientA.waitForContent(HOST_UPDATE, syncTimeoutMs);
|
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;
|
clientA = undefined;
|
||||||
|
|
||||||
clientB = await AutomaticP2PHostProcess.start(clientBVault, clientBSettings, undefined, 20000);
|
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 clientB.write(CLIENT_B_UPDATE.path, CLIENT_B_UPDATE.content);
|
||||||
await host.waitForContent(CLIENT_B_UPDATE, syncTimeoutMs);
|
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;
|
host = undefined;
|
||||||
await clientB.write(CLIENT_B_DURING_OUTAGE.path, CLIENT_B_DURING_OUTAGE.content);
|
await clientB.write(CLIENT_B_DURING_OUTAGE.path, CLIENT_B_DURING_OUTAGE.content);
|
||||||
|
|
||||||
host = await AutomaticP2PHostProcess.start(hostVault, hostSettings, undefined, 20000);
|
host = await AutomaticP2PHostProcess.start(hostVault, hostSettings, undefined, 20000);
|
||||||
const restartedHostState = await host.state();
|
const restartedHostState = await host.state();
|
||||||
assert(restartedHostState.connected, "The restarted p2p-host did not connect");
|
assert(restartedHostState.connected, "The restarted p2p-host did not connect");
|
||||||
assertEquals(
|
assert.deepStrictEqual(
|
||||||
restartedHostState.policy,
|
restartedHostState.policy,
|
||||||
initialHostState.policy,
|
initialHostState.policy,
|
||||||
"The restarted host did not restore its original P2P policy"
|
"The restarted host did not restore its original P2P policy"
|
||||||
@@ -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 { TempDir } from "./helpers/temp.ts";
|
||||||
import { initSettingsFile, applyP2pSettings } from "./helpers/settings.ts";
|
import { initSettingsFile, applyP2pSettings } from "./helpers/settings.ts";
|
||||||
import { startP2pRelay, stopP2pRelay, isLocalP2pRelay } from "./helpers/docker.ts";
|
import { startP2pRelay, stopP2pRelay, isLocalP2pRelay } from "./helpers/docker.ts";
|
||||||
import { startCliInBackground } from "./helpers/backgroundCli.ts";
|
import { startCliInBackground } from "./helpers/backgroundCli.ts";
|
||||||
|
|
||||||
Deno.test("p2p-host: starts and becomes ready", async () => {
|
test("p2p-host: starts and becomes ready", async () => {
|
||||||
const relay = Deno.env.get("RELAY") ?? "ws://localhost:4000/";
|
const relay = process.env["RELAY"] ?? "ws://localhost:4000/";
|
||||||
const roomId = Deno.env.get("ROOM_ID") ?? `room-${Date.now()}`;
|
const roomId = process.env["ROOM_ID"] ?? `room-${Date.now()}`;
|
||||||
const passphrase = Deno.env.get("PASSPHRASE") ?? "test";
|
const passphrase = process.env["PASSPHRASE"] ?? "test";
|
||||||
const appId = Deno.env.get("APP_ID") ?? "self-hosted-livesync-cli-tests";
|
const appId = process.env["APP_ID"] ?? "self-hosted-livesync-cli-tests";
|
||||||
const useInternalRelay = Deno.env.get("USE_INTERNAL_RELAY") !== "0";
|
const useInternalRelay = process.env["USE_INTERNAL_RELAY"] !== "0";
|
||||||
|
|
||||||
await using workDir = await TempDir.create("livesync-cli-p2p-host");
|
await using workDir = await TempDir.create("livesync-cli-p2p-host");
|
||||||
const vaultDir = workDir.join("vault-host");
|
const vaultDir = workDir.join("vault-host");
|
||||||
const settingsFile = workDir.join("settings-host.json");
|
const settingsFile = workDir.join("settings-host.json");
|
||||||
await Deno.mkdir(vaultDir, { recursive: true });
|
await mkdir(vaultDir, { recursive: true });
|
||||||
|
|
||||||
let relayStarted = false;
|
let relayStarted = false;
|
||||||
if (useInternalRelay && isLocalP2pRelay(relay)) {
|
if (useInternalRelay && isLocalP2pRelay(relay)) {
|
||||||
+14
-12
@@ -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 { TempDir } from "./helpers/temp.ts";
|
||||||
import { initSettingsFile, applyP2pSettings, applyP2pTestTweaks } from "./helpers/settings.ts";
|
import { initSettingsFile, applyP2pSettings, applyP2pTestTweaks } from "./helpers/settings.ts";
|
||||||
import { startCliInBackground } from "./helpers/backgroundCli.ts";
|
import { startCliInBackground } from "./helpers/backgroundCli.ts";
|
||||||
@@ -11,27 +13,27 @@ import {
|
|||||||
} from "./helpers/p2p.ts";
|
} from "./helpers/p2p.ts";
|
||||||
import { getOptimalLoopbackIp } from "./helpers/net.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 loopbackIp = await getOptimalLoopbackIp();
|
||||||
const loopbackHost = loopbackIp === "::1" ? "[::1]" : loopbackIp;
|
const loopbackHost = loopbackIp === "::1" ? "[::1]" : loopbackIp;
|
||||||
|
|
||||||
const relay = Deno.env.get("RELAY") ?? `ws://${loopbackHost}:4000/`;
|
const relay = process.env["RELAY"] ?? `ws://${loopbackHost}:4000/`;
|
||||||
const roomId = Deno.env.get("ROOM_ID") ?? `room-${Date.now()}`;
|
const roomId = process.env["ROOM_ID"] ?? `room-${Date.now()}`;
|
||||||
const passphrase = Deno.env.get("PASSPHRASE") ?? "test";
|
const passphrase = process.env["PASSPHRASE"] ?? "test";
|
||||||
const timeoutSeconds = Number(Deno.env.get("TIMEOUT_SECONDS") ?? "8");
|
const timeoutSeconds = Number(process.env["TIMEOUT_SECONDS"] ?? "8");
|
||||||
const nonce = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
|
const nonce = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
|
||||||
const hostPeerName = Deno.env.get("HOST_PEER_NAME") ?? `p2p-host-${nonce}`;
|
const hostPeerName = process.env["HOST_PEER_NAME"] ?? `p2p-host-${nonce}`;
|
||||||
const clientPeerName = Deno.env.get("CLIENT_PEER_NAME") ?? `p2p-client-${nonce}`;
|
const clientPeerName = process.env["CLIENT_PEER_NAME"] ?? `p2p-client-${nonce}`;
|
||||||
const useCoturn = Deno.env.get("LIVESYNC_USE_COTURN") !== "0";
|
const useCoturn = process.env["LIVESYNC_USE_COTURN"] !== "0";
|
||||||
const turnServers = Deno.env.get("TURN_SERVERS") ?? (useCoturn ? `turn:${loopbackHost}:3478` : "none");
|
const turnServers = process.env["TURN_SERVERS"] ?? (useCoturn ? `turn:${loopbackHost}:3478` : "none");
|
||||||
|
|
||||||
await using workDir = await TempDir.create("livesync-cli-p2p-peers-local-relay");
|
await using workDir = await TempDir.create("livesync-cli-p2p-peers-local-relay");
|
||||||
const hostVault = workDir.join("vault-host");
|
const hostVault = workDir.join("vault-host");
|
||||||
const hostSettings = workDir.join("settings-host.json");
|
const hostSettings = workDir.join("settings-host.json");
|
||||||
const clientVault = workDir.join("vault");
|
const clientVault = workDir.join("vault");
|
||||||
const clientSettings = workDir.join("settings.json");
|
const clientSettings = workDir.join("settings.json");
|
||||||
await Deno.mkdir(hostVault, { recursive: true });
|
await mkdir(hostVault, { recursive: true });
|
||||||
await Deno.mkdir(clientVault, { recursive: true });
|
await mkdir(clientVault, { recursive: true });
|
||||||
|
|
||||||
const relayStarted = await maybeStartLocalRelay(relay);
|
const relayStarted = await maybeStartLocalRelay(relay);
|
||||||
const coturnStarted = await maybeStartCoturn(turnServers);
|
const coturnStarted = await maybeStartCoturn(turnServers);
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
+23
-16
@@ -1,11 +1,19 @@
|
|||||||
import { assert, assertEquals, assertStringIncludes } from "@std/assert";
|
import { test } from "node:test";
|
||||||
import { join } from "@std/path";
|
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 { TempDir } from "./helpers/temp.ts";
|
||||||
import { initSettingsFile, applyP2pSettings, applyP2pTestTweaks } from "./helpers/settings.ts";
|
import { initSettingsFile, applyP2pSettings, applyP2pTestTweaks } from "./helpers/settings.ts";
|
||||||
import { startCliInBackground } from "./helpers/backgroundCli.ts";
|
import { startCliInBackground } from "./helpers/backgroundCli.ts";
|
||||||
import { maybeStartLocalRelay, stopLocalRelayIfStarted, maybeStartCoturn, stopCoturnIfStarted } from "./helpers/p2p.ts";
|
import { maybeStartLocalRelay, stopLocalRelayIfStarted, maybeStartCoturn, stopCoturnIfStarted } from "./helpers/p2p.ts";
|
||||||
import { CLI_DIR, runCli, sanitiseCatStdout } from "./helpers/cli.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_PATH = "p2p-replicator-replacement.md";
|
||||||
const NOTE_CONTENT = "Replicated after replacing the active P2P replicator.";
|
const NOTE_CONTENT = "Replicated after replacing the active P2P replicator.";
|
||||||
|
|
||||||
@@ -15,7 +23,7 @@ async function runReplacementProbe(
|
|||||||
targetPeer: string,
|
targetPeer: string,
|
||||||
timeoutMs: number
|
timeoutMs: number
|
||||||
): Promise<{ code: number; stdout: string; stderr: string }> {
|
): Promise<{ code: number; stdout: string; stderr: string }> {
|
||||||
const command = new Deno.Command("node", {
|
const result = await runProcess(process.execPath, {
|
||||||
args: [
|
args: [
|
||||||
join(CLI_DIR, "dist", "p2p-lifecycle-test.cjs"),
|
join(CLI_DIR, "dist", "p2p-lifecycle-test.cjs"),
|
||||||
vaultPath,
|
vaultPath,
|
||||||
@@ -31,7 +39,6 @@ async function runReplacementProbe(
|
|||||||
stdout: "piped",
|
stdout: "piped",
|
||||||
stderr: "piped",
|
stderr: "piped",
|
||||||
});
|
});
|
||||||
const result = await command.output();
|
|
||||||
return {
|
return {
|
||||||
code: result.code,
|
code: result.code,
|
||||||
stdout: new TextDecoder().decode(result.stdout),
|
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 () => {
|
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 relay = process.env["RELAY"] ?? "ws://localhost:4000/";
|
||||||
const peersTimeout = Number(Deno.env.get("PEERS_TIMEOUT") ?? "20");
|
const peersTimeout = Number(process.env["PEERS_TIMEOUT"] ?? "20");
|
||||||
const syncTimeout = Number(Deno.env.get("SYNC_TIMEOUT") ?? "60");
|
const syncTimeout = Number(process.env["SYNC_TIMEOUT"] ?? "60");
|
||||||
const probeTimeoutMs = Math.max(peersTimeout, syncTimeout) * 1000;
|
const probeTimeoutMs = Math.max(peersTimeout, syncTimeout) * 1000;
|
||||||
const nonce = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
|
const nonce = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
|
||||||
const roomId = Deno.env.get("ROOM_ID") ?? `replacement-room-${nonce}`;
|
const roomId = process.env["ROOM_ID"] ?? `replacement-room-${nonce}`;
|
||||||
const passphrase = Deno.env.get("PASSPHRASE") ?? `replacement-pass-${nonce}`;
|
const passphrase = process.env["PASSPHRASE"] ?? `replacement-pass-${nonce}`;
|
||||||
const appId = "self-hosted-livesync-cli-replacement-test";
|
const appId = "self-hosted-livesync-cli-replacement-test";
|
||||||
const hostPeerName = `p2p-replacement-host-${nonce}`;
|
const hostPeerName = `p2p-replacement-host-${nonce}`;
|
||||||
const probePeerName = `p2p-replacement-probe-${nonce}`;
|
const probePeerName = `p2p-replacement-probe-${nonce}`;
|
||||||
const verifierPeerName = `p2p-replacement-verifier-${nonce}`;
|
const verifierPeerName = `p2p-replacement-verifier-${nonce}`;
|
||||||
const useCoturn = Deno.env.get("LIVESYNC_USE_COTURN") !== "0";
|
const useCoturn = process.env["LIVESYNC_USE_COTURN"] !== "0";
|
||||||
const turnServers = Deno.env.get("TURN_SERVERS") ?? (useCoturn ? "turn:127.0.0.1:3478" : "none");
|
const turnServers = process.env["TURN_SERVERS"] ?? (useCoturn ? "turn:127.0.0.1:3478" : "none");
|
||||||
|
|
||||||
await using workDir = await TempDir.create("livesync-cli-p2p-replacement");
|
await using workDir = await TempDir.create("livesync-cli-p2p-replacement");
|
||||||
const hostVault = workDir.join("vault-host");
|
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 probeSettings = workDir.join("settings-probe.json");
|
||||||
const verifierSettings = workDir.join("settings-verifier.json");
|
const verifierSettings = workDir.join("settings-verifier.json");
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
Deno.mkdir(hostVault, { recursive: true }),
|
mkdir(hostVault, { recursive: true }),
|
||||||
Deno.mkdir(probeVault, { recursive: true }),
|
mkdir(probeVault, { recursive: true }),
|
||||||
Deno.mkdir(verifierVault, { recursive: true }),
|
mkdir(verifierVault, { recursive: true }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const relayStarted = await maybeStartLocalRelay(relay);
|
const relayStarted = await maybeStartLocalRelay(relay);
|
||||||
@@ -103,7 +110,7 @@ Deno.test("p2p lifecycle: active-adapter replacement keeps real CLI communicatio
|
|||||||
catResult.code === 0,
|
catResult.code === 0,
|
||||||
`Verifier could not read ${NOTE_PATH}\nstdout: ${catResult.stdout}\nstderr: ${catResult.stderr}`
|
`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 {
|
} finally {
|
||||||
await host.stop();
|
await host.stop();
|
||||||
}
|
}
|
||||||
@@ -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 { TempDir } from "./helpers/temp.ts";
|
||||||
import {
|
import {
|
||||||
initSettingsFile,
|
initSettingsFile,
|
||||||
@@ -17,20 +19,20 @@ import {
|
|||||||
import { runCli, runCliOrFail, runCliWithInputOrFail, sanitiseCatStdout } from "./helpers/cli.ts";
|
import { runCli, runCliOrFail, runCliWithInputOrFail, sanitiseCatStdout } from "./helpers/cli.ts";
|
||||||
import { getOptimalLoopbackIp } from "./helpers/net.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 loopbackIp = await getOptimalLoopbackIp();
|
||||||
const loopbackHost = loopbackIp === "::1" ? "[::1]" : loopbackIp;
|
const loopbackHost = loopbackIp === "::1" ? "[::1]" : loopbackIp;
|
||||||
|
|
||||||
const relay = Deno.env.get("RELAY") ?? `ws://${loopbackHost}:4000/`;
|
const relay = process.env["RELAY"] ?? `ws://${loopbackHost}:4000/`;
|
||||||
const roomId = Deno.env.get("ROOM_ID") ?? `room-${Date.now()}`;
|
const roomId = process.env["ROOM_ID"] ?? `room-${Date.now()}`;
|
||||||
const passphrase = Deno.env.get("PASSPHRASE") ?? "test";
|
const passphrase = process.env["PASSPHRASE"] ?? "test";
|
||||||
const peersTimeout = Number(Deno.env.get("PEERS_TIMEOUT") ?? "12");
|
const peersTimeout = Number(process.env["PEERS_TIMEOUT"] ?? "12");
|
||||||
const syncTimeout = Number(Deno.env.get("SYNC_TIMEOUT") ?? "15");
|
const syncTimeout = Number(process.env["SYNC_TIMEOUT"] ?? "15");
|
||||||
const nonce = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
|
const nonce = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
|
||||||
const hostPeerName = Deno.env.get("HOST_PEER_NAME") ?? `p2p-host-${nonce}`;
|
const hostPeerName = process.env["HOST_PEER_NAME"] ?? `p2p-host-${nonce}`;
|
||||||
const clientPeerName = Deno.env.get("CLIENT_PEER_NAME") ?? `p2p-client-${nonce}`;
|
const clientPeerName = process.env["CLIENT_PEER_NAME"] ?? `p2p-client-${nonce}`;
|
||||||
const useCoturn = Deno.env.get("LIVESYNC_USE_COTURN") !== "0";
|
const useCoturn = process.env["LIVESYNC_USE_COTURN"] !== "0";
|
||||||
const turnServers = Deno.env.get("TURN_SERVERS") ?? (useCoturn ? `turn:${loopbackHost}:3478` : "none");
|
const turnServers = process.env["TURN_SERVERS"] ?? (useCoturn ? `turn:${loopbackHost}:3478` : "none");
|
||||||
|
|
||||||
await using workDir = await TempDir.create("livesync-cli-p2p-sync");
|
await using workDir = await TempDir.create("livesync-cli-p2p-sync");
|
||||||
const hostVault = workDir.join("vault-host");
|
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 clientSettings = workDir.join("settings-sync.json");
|
||||||
const rejectedVault = workDir.join("vault-rejected");
|
const rejectedVault = workDir.join("vault-rejected");
|
||||||
const rejectedSettings = workDir.join("settings-rejected.json");
|
const rejectedSettings = workDir.join("settings-rejected.json");
|
||||||
await Deno.mkdir(hostVault, { recursive: true });
|
await mkdir(hostVault, { recursive: true });
|
||||||
await Deno.mkdir(clientVault, { recursive: true });
|
await mkdir(clientVault, { recursive: true });
|
||||||
await Deno.mkdir(rejectedVault, { recursive: true });
|
await mkdir(rejectedVault, { recursive: true });
|
||||||
|
|
||||||
const relayStarted = await maybeStartLocalRelay(relay);
|
const relayStarted = await maybeStartLocalRelay(relay);
|
||||||
const coturnStarted = await maybeStartCoturn(turnServers);
|
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"],
|
[clientVault, clientSettings, "ab".repeat(32), "client"],
|
||||||
[rejectedVault, rejectedSettings, "cd".repeat(32), "rejected"],
|
[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.idDerivationVersion = 1;
|
||||||
settings.idDerivationKey = key;
|
settings.idDerivationKey = key;
|
||||||
const sourcePath = workDir.join("setup-source-" + label + ".json");
|
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 setupPassphrase = "independent-id-setup-passphrase";
|
||||||
const setupUri = await generateSetupUriFromSettings(sourcePath, setupPassphrase, true);
|
const setupUri = await generateSetupUriFromSettings(sourcePath, setupPassphrase, true);
|
||||||
await runCliWithInputOrFail(setupPassphrase + "\n", vault, "--settings", path, "setup", setupUri);
|
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.idDerivationVersion === 1, "The Setup URI lost the ID derivation version.");
|
||||||
assert(persisted.idDerivationKey === "", "The CLI stored the ID key in plain text.");
|
assert(persisted.idDerivationKey === "", "The CLI stored the ID key in plain text.");
|
||||||
assert(
|
assert(
|
||||||
@@ -121,7 +123,7 @@ Deno.test("p2p-sync: transfers with the same ID key and rejects a different docu
|
|||||||
clientVault,
|
clientVault,
|
||||||
clientSettings,
|
clientSettings,
|
||||||
peersTimeout,
|
peersTimeout,
|
||||||
Deno.env.get("TARGET_PEER") ?? hostPeerName
|
process.env["TARGET_PEER"] ?? hostPeerName
|
||||||
);
|
);
|
||||||
const syncResult = await runCli(
|
const syncResult = await runCli(
|
||||||
clientVault,
|
clientVault,
|
||||||
+17
-15
@@ -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 { TempDir } from "./helpers/temp.ts";
|
||||||
import { applyP2pSettings, applyP2pTestTweaks, initSettingsFile } from "./helpers/settings.ts";
|
import { applyP2pSettings, applyP2pTestTweaks, initSettingsFile } from "./helpers/settings.ts";
|
||||||
import { startCliInBackground } from "./helpers/backgroundCli.ts";
|
import { startCliInBackground } from "./helpers/backgroundCli.ts";
|
||||||
@@ -12,22 +14,22 @@ import {
|
|||||||
import { jsonStringField, runCliOrFail, runCliWithInputOrFail, sanitiseCatStdout } from "./helpers/cli.ts";
|
import { jsonStringField, runCliOrFail, runCliWithInputOrFail, sanitiseCatStdout } from "./helpers/cli.ts";
|
||||||
import { getOptimalLoopbackIp } from "./helpers/net.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 loopbackIp = await getOptimalLoopbackIp();
|
||||||
const loopbackHost = loopbackIp === "::1" ? "[::1]" : loopbackIp;
|
const loopbackHost = loopbackIp === "::1" ? "[::1]" : loopbackIp;
|
||||||
|
|
||||||
const relay = Deno.env.get("RELAY") ?? `ws://${loopbackHost}:4000/`;
|
const relay = process.env["RELAY"] ?? `ws://${loopbackHost}:4000/`;
|
||||||
const roomId = Deno.env.get("ROOM_ID") ?? `room-${Date.now()}`;
|
const roomId = process.env["ROOM_ID"] ?? `room-${Date.now()}`;
|
||||||
const passphrase = Deno.env.get("PASSPHRASE") ?? "test";
|
const passphrase = process.env["PASSPHRASE"] ?? "test";
|
||||||
const appId = "self-hosted-livesync-cli-tests";
|
const appId = "self-hosted-livesync-cli-tests";
|
||||||
const peersTimeout = Number(Deno.env.get("PEERS_TIMEOUT") ?? "10");
|
const peersTimeout = Number(process.env["PEERS_TIMEOUT"] ?? "10");
|
||||||
const syncTimeout = Number(Deno.env.get("SYNC_TIMEOUT") ?? "15");
|
const syncTimeout = Number(process.env["SYNC_TIMEOUT"] ?? "15");
|
||||||
const nonce = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
|
const nonce = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
|
||||||
const hostPeerName = Deno.env.get("HOST_PEER_NAME") ?? `p2p-host-${nonce}`;
|
const hostPeerName = process.env["HOST_PEER_NAME"] ?? `p2p-host-${nonce}`;
|
||||||
const peerNameB = Deno.env.get("PEER_NAME_B") ?? `p2p-client-b-${nonce}`;
|
const peerNameB = process.env["PEER_NAME_B"] ?? `p2p-client-b-${nonce}`;
|
||||||
const peerNameC = Deno.env.get("PEER_NAME_C") ?? `p2p-client-c-${nonce}`;
|
const peerNameC = process.env["PEER_NAME_C"] ?? `p2p-client-c-${nonce}`;
|
||||||
const useCoturn = Deno.env.get("LIVESYNC_USE_COTURN") !== "0";
|
const useCoturn = process.env["LIVESYNC_USE_COTURN"] !== "0";
|
||||||
const turnServers = Deno.env.get("TURN_SERVERS") ?? (useCoturn ? `turn:${loopbackHost}:3478` : "none");
|
const turnServers = process.env["TURN_SERVERS"] ?? (useCoturn ? `turn:${loopbackHost}:3478` : "none");
|
||||||
|
|
||||||
await using workDir = await TempDir.create("livesync-cli-p2p-3nodes");
|
await using workDir = await TempDir.create("livesync-cli-p2p-3nodes");
|
||||||
const vaultA = workDir.join("vault-a");
|
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 settingsA = workDir.join("settings-a.json");
|
||||||
const settingsB = workDir.join("settings-b.json");
|
const settingsB = workDir.join("settings-b.json");
|
||||||
const settingsC = workDir.join("settings-c.json");
|
const settingsC = workDir.join("settings-c.json");
|
||||||
await Deno.mkdir(vaultA, { recursive: true });
|
await mkdir(vaultA, { recursive: true });
|
||||||
await Deno.mkdir(vaultB, { recursive: true });
|
await mkdir(vaultB, { recursive: true });
|
||||||
await Deno.mkdir(vaultC, { recursive: true });
|
await mkdir(vaultC, { recursive: true });
|
||||||
|
|
||||||
const relayStarted = await maybeStartLocalRelay(relay);
|
const relayStarted = await maybeStartLocalRelay(relay);
|
||||||
const coturnStarted = await maybeStartCoturn(turnServers);
|
const coturnStarted = await maybeStartCoturn(turnServers);
|
||||||
+15
-13
@@ -1,3 +1,5 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
import { TempDir } from "./helpers/temp.ts";
|
import { TempDir } from "./helpers/temp.ts";
|
||||||
import { applyP2pSettings, applyP2pTestTweaks, initSettingsFile } from "./helpers/settings.ts";
|
import { applyP2pSettings, applyP2pTestTweaks, initSettingsFile } from "./helpers/settings.ts";
|
||||||
import { startCliInBackground } from "./helpers/backgroundCli.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<void> {
|
async function writeFilledFile(path: string, size: number, byte: number): Promise<void> {
|
||||||
const data = new Uint8Array(size);
|
const data = new Uint8Array(size);
|
||||||
data.fill(byte);
|
data.fill(byte);
|
||||||
await Deno.writeFile(path, data);
|
await writeFile(path, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
Deno.test("p2p: upload/download reproduction scenario", async () => {
|
test("p2p: upload/download reproduction scenario", async () => {
|
||||||
const relay = Deno.env.get("RELAY") ?? "ws://localhost:4000/";
|
const relay = process.env["RELAY"] ?? "ws://localhost:4000/";
|
||||||
const appId = Deno.env.get("APP_ID") ?? "self-hosted-livesync-cli-tests";
|
const appId = process.env["APP_ID"] ?? "self-hosted-livesync-cli-tests";
|
||||||
const peersTimeout = Number(Deno.env.get("PEERS_TIMEOUT") ?? "20");
|
const peersTimeout = Number(process.env["PEERS_TIMEOUT"] ?? "20");
|
||||||
const syncTimeout = Number(Deno.env.get("SYNC_TIMEOUT") ?? "240");
|
const syncTimeout = Number(process.env["SYNC_TIMEOUT"] ?? "240");
|
||||||
const roomId = `p2p-room-${Date.now()}`;
|
const roomId = `p2p-room-${Date.now()}`;
|
||||||
const passphrase = `p2p-pass-${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 settingsUp = workDir.join("settings-up.json");
|
||||||
const settingsDown = workDir.join("settings-down.json");
|
const settingsDown = workDir.join("settings-down.json");
|
||||||
for (const dir of [vaultHost, vaultUp, vaultDown]) {
|
for (const dir of [vaultHost, vaultUp, vaultDown]) {
|
||||||
await Deno.mkdir(dir, { recursive: true });
|
await mkdir(dir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const relayStarted = await maybeStartLocalRelay(relay);
|
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 diffA = workDir.join("test-diff-1.md");
|
||||||
const diffB = workDir.join("test-diff-2.md");
|
const diffB = workDir.join("test-diff-2.md");
|
||||||
const diffC = workDir.join("test-diff-3.md");
|
const diffC = workDir.join("test-diff-3.md");
|
||||||
await Deno.writeTextFile(storeText, "Hello, World!\n");
|
await writeFile(storeText, "Hello, World!\n");
|
||||||
await Deno.writeTextFile(diffA, "Content A\n");
|
await writeFile(diffA, "Content A\n");
|
||||||
await Deno.writeTextFile(diffB, "Content B\n");
|
await writeFile(diffB, "Content B\n");
|
||||||
await Deno.writeTextFile(diffC, "Content C\n");
|
await writeFile(diffC, "Content C\n");
|
||||||
await runCliOrFail(vaultUp, "--settings", settingsUp, "push", storeText, "p2p/store-file.md");
|
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", diffA, "p2p/test-diff-1.md");
|
||||||
await runCliOrFail(vaultUp, "--settings", settingsUp, "push", diffB, "p2p/test-diff-2.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 large1m = workDir.join("large-1m.txt");
|
||||||
const binary100k = workDir.join("binary-100k.bin");
|
const binary100k = workDir.join("binary-100k.bin");
|
||||||
const binary5m = workDir.join("binary-5m.bin");
|
const binary5m = workDir.join("binary-5m.bin");
|
||||||
await Deno.writeTextFile(large100k, "a".repeat(100000));
|
await writeFile(large100k, "a".repeat(100000));
|
||||||
await Deno.writeTextFile(large1m, "b".repeat(1000000));
|
await writeFile(large1m, "b".repeat(1000000));
|
||||||
await writeFilledFile(binary100k, 100000, 0x5a);
|
await writeFilledFile(binary100k, 100000, 0x5a);
|
||||||
await writeFilledFile(binary5m, 5000000, 0x7c);
|
await writeFilledFile(binary5m, 5000000, 0x7c);
|
||||||
await runCliOrFail(vaultUp, "--settings", settingsUp, "push", large100k, "p2p/large-100000.md");
|
await runCliOrFail(vaultUp, "--settings", settingsUp, "push", large100k, "p2p/large-100000.md");
|
||||||
@@ -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
|
* Requires CouchDB connection details either via environment variables or a
|
||||||
* .test.env file. If neither is present the test logs a warning and the
|
* .test.env file. If neither is present the test logs a warning and the
|
||||||
* CLI will likely fail at the push step.
|
* CLI will likely fail at the push step.
|
||||||
*
|
*
|
||||||
* Run:
|
* Run:
|
||||||
* deno test -A test-push-pull.ts
|
* node --test test-push-pull.ts
|
||||||
*
|
*
|
||||||
* With explicit CouchDB:
|
* With explicit CouchDB:
|
||||||
* COUCHDB_URI=http://127.0.0.1:5984 \
|
* COUCHDB_URI=http://127.0.0.1:5984 \
|
||||||
* COUCHDB_USER=admin \
|
* COUCHDB_USER=admin \
|
||||||
* COUCHDB_PASSWORD=password \
|
* COUCHDB_PASSWORD=password \
|
||||||
* COUCHDB_DBNAME=livesync-test \
|
* COUCHDB_DBNAME=livesync-test \
|
||||||
* deno test -A test-push-pull.ts
|
* node --test test-push-pull.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { join } from "@std/path";
|
import { test } from "node:test";
|
||||||
import { assertEquals } from "@std/assert";
|
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 { TempDir } from "./helpers/temp.ts";
|
||||||
import { runCliOrFail } from "./helpers/cli.ts";
|
import { runCliOrFail } from "./helpers/cli.ts";
|
||||||
import { applyCouchdbSettings, initSettingsFile } from "./helpers/settings.ts";
|
import { applyCouchdbSettings, initSettingsFile } from "./helpers/settings.ts";
|
||||||
import { startCouchdb, stopCouchdb } from "./helpers/docker.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");
|
await using workDir = await TempDir.create("livesync-cli-push-pull");
|
||||||
|
|
||||||
const settingsFile = workDir.join("data.json");
|
const settingsFile = workDir.join("data.json");
|
||||||
const vaultDir = workDir.join("vault");
|
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 uri = process.env["COUCHDB_URI"] ?? "http://127.0.0.1:5989/";
|
||||||
const user = Deno.env.get("COUCHDB_USER") ?? "admin";
|
const user = process.env["COUCHDB_USER"] ?? "admin";
|
||||||
const password = Deno.env.get("COUCHDB_PASSWORD") ?? "testpassword";
|
const password = process.env["COUCHDB_PASSWORD"] ?? "testpassword";
|
||||||
const dbname = Deno.env.get("COUCHDB_DBNAME") ?? `push-pull-${Date.now()}`;
|
const dbname = process.env["COUCHDB_DBNAME"] ?? `push-pull-${Date.now()}`;
|
||||||
|
|
||||||
const shouldStartDocker = Deno.env.get("LIVESYNC_START_DOCKER") !== "0";
|
const shouldStartDocker = process.env["LIVESYNC_START_DOCKER"] !== "0";
|
||||||
const keepDocker = Deno.env.get("LIVESYNC_DEBUG_KEEP_DOCKER") === "1";
|
const keepDocker = process.env["LIVESYNC_DEBUG_KEEP_DOCKER"] === "1";
|
||||||
|
|
||||||
if (shouldStartDocker) {
|
if (shouldStartDocker) {
|
||||||
await startCouchdb(uri, user, password, dbname);
|
await startCouchdb(uri, user, password, dbname);
|
||||||
@@ -59,7 +62,7 @@ Deno.test("push/pull roundtrip", async () => {
|
|||||||
const srcFile = workDir.join("push-source.txt");
|
const srcFile = workDir.join("push-source.txt");
|
||||||
const pulledFile = workDir.join("pull-result.txt");
|
const pulledFile = workDir.join("pull-result.txt");
|
||||||
const content = `push-pull-test ${new Date().toISOString()}\n`;
|
const content = `push-pull-test ${new Date().toISOString()}\n`;
|
||||||
await Deno.writeTextFile(srcFile, content);
|
await writeFile(srcFile, content);
|
||||||
|
|
||||||
console.log(`[INFO] push -> ${REMOTE_PATH}`);
|
console.log(`[INFO] push -> ${REMOTE_PATH}`);
|
||||||
await runCliOrFail(vaultDir, "--settings", settingsFile, "push", srcFile, 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}`);
|
console.log(`[INFO] pull <- ${REMOTE_PATH}`);
|
||||||
await runCliOrFail(vaultDir, "--settings", settingsFile, "pull", REMOTE_PATH, pulledFile);
|
await runCliOrFail(vaultDir, "--settings", settingsFile, "pull", REMOTE_PATH, pulledFile);
|
||||||
|
|
||||||
const pulled = await Deno.readTextFile(pulledFile);
|
const pulled = await readFile(pulledFile, "utf8");
|
||||||
assertEquals(content, pulled, "push/pull roundtrip content mismatch");
|
assert.deepStrictEqual(content, pulled, "push/pull roundtrip content mismatch");
|
||||||
console.log("[PASS] push/pull roundtrip matched");
|
console.log("[PASS] push/pull roundtrip matched");
|
||||||
} finally {
|
} finally {
|
||||||
if (shouldStartDocker && !keepDocker) {
|
if (shouldStartDocker && !keepDocker) {
|
||||||
+13
-7
@@ -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 { TempDir } from "./helpers/temp.ts";
|
||||||
import { runCli } from "./helpers/cli.ts";
|
import { runCli } from "./helpers/cli.ts";
|
||||||
import { applyCouchdbSettings, applyP2pSettings, applyP2pTestTweaks, initSettingsFile } from "./helpers/settings.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) {
|
async function prepareFixture(prefix: string) {
|
||||||
const workDir = await TempDir.create(prefix);
|
const workDir = await TempDir.create(prefix);
|
||||||
const settingsFile = workDir.join("settings.json");
|
const settingsFile = workDir.join("settings.json");
|
||||||
const databaseDir = workDir.join("database");
|
const databaseDir = workDir.join("database");
|
||||||
await Deno.mkdir(databaseDir, { recursive: true });
|
await mkdir(databaseDir, { recursive: true });
|
||||||
await initSettingsFile(settingsFile);
|
await initSettingsFile(settingsFile);
|
||||||
return { workDir, settingsFile, databaseDir };
|
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");
|
const fixture = await prepareFixture("livesync-cli-remote-admin-exit");
|
||||||
await using workDir = fixture.workDir;
|
await using workDir = fixture.workDir;
|
||||||
const { settingsFile, databaseDir } = fixture;
|
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");
|
await applyP2pTestTweaks(settingsFile, "remote-admin-exit-device", "remote-admin-exit-passphrase");
|
||||||
|
|
||||||
const defaultFailure = await runCli(databaseDir, "--settings", settingsFile, "mark-resolved");
|
const defaultFailure = await runCli(databaseDir, "--settings", settingsFile, "mark-resolved");
|
||||||
assertEquals(defaultFailure.code, 1, defaultFailure.combined);
|
assert.deepStrictEqual(defaultFailure.code, 1, defaultFailure.combined);
|
||||||
assertStringIncludes(
|
assertStringIncludes(
|
||||||
defaultFailure.combined,
|
defaultFailure.combined,
|
||||||
"[Verification] Remote administration is unavailable for this provider."
|
"[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",
|
"--compat-remote-admin-exit-zero",
|
||||||
"mark-resolved"
|
"mark-resolved"
|
||||||
);
|
);
|
||||||
assertEquals(compatibilitySuccess.code, 0, compatibilitySuccess.combined);
|
assert.deepStrictEqual(compatibilitySuccess.code, 0, compatibilitySuccess.combined);
|
||||||
assertStringIncludes(
|
assertStringIncludes(
|
||||||
compatibilitySuccess.combined,
|
compatibilitySuccess.combined,
|
||||||
"[Verification] Remote administration is unavailable for this provider."
|
"[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");
|
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");
|
const fixture = await prepareFixture("livesync-cli-remote-admin-mutation");
|
||||||
await using workDir = fixture.workDir;
|
await using workDir = fixture.workDir;
|
||||||
const { settingsFile, databaseDir } = fixture;
|
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",
|
"--compat-remote-admin-exit-zero",
|
||||||
"mark-resolved"
|
"mark-resolved"
|
||||||
);
|
);
|
||||||
assertEquals(mutationFailure.code, 1, mutationFailure.combined);
|
assert.deepStrictEqual(mutationFailure.code, 1, mutationFailure.combined);
|
||||||
assertStringIncludes(mutationFailure.combined, "[Command] mark-resolved");
|
assertStringIncludes(mutationFailure.combined, "[Command] mark-resolved");
|
||||||
assertStringIncludes(mutationFailure.combined, "[Error] Failed to start:");
|
assertStringIncludes(mutationFailure.combined, "[Error] Failed to start:");
|
||||||
});
|
});
|
||||||
+14
-11
@@ -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,
|
* Tests remote management commands: remote-status, lock-remote, unlock-remote,
|
||||||
* and mark-resolved.
|
* and mark-resolved.
|
||||||
@@ -14,10 +14,13 @@
|
|||||||
* current device is accepted.
|
* current device is accepted.
|
||||||
*
|
*
|
||||||
* Run:
|
* 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 { TempDir } from "./helpers/temp.ts";
|
||||||
import { runCli, assertContains } from "./helpers/cli.ts";
|
import { runCli, assertContains } from "./helpers/cli.ts";
|
||||||
import { applyCouchdbSettings, initSettingsFile } from "./helpers/settings.ts";
|
import { applyCouchdbSettings, initSettingsFile } from "./helpers/settings.ts";
|
||||||
@@ -31,21 +34,21 @@ async function runCliCombinedOrFail(...args: string[]): Promise<string> {
|
|||||||
return res.combined;
|
return res.combined;
|
||||||
}
|
}
|
||||||
|
|
||||||
Deno.test("remote management commands", async () => {
|
test("remote management commands", async () => {
|
||||||
await using workDir = await TempDir.create("livesync-cli-remote-cmds");
|
await using workDir = await TempDir.create("livesync-cli-remote-cmds");
|
||||||
|
|
||||||
const settingsFile = workDir.join("settings.json");
|
const settingsFile = workDir.join("settings.json");
|
||||||
const vaultDir = workDir.join("vault");
|
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 uri = process.env["COUCHDB_URI"] ?? "http://127.0.0.1:5989/";
|
||||||
const user = Deno.env.get("COUCHDB_USER") ?? "admin";
|
const user = process.env["COUCHDB_USER"] ?? "admin";
|
||||||
const password = Deno.env.get("COUCHDB_PASSWORD") ?? "testpassword";
|
const password = process.env["COUCHDB_PASSWORD"] ?? "testpassword";
|
||||||
const dbSuffix = `${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
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 shouldStartDocker = process.env["LIVESYNC_START_DOCKER"] !== "0";
|
||||||
const keepDocker = Deno.env.get("LIVESYNC_DEBUG_KEEP_DOCKER") === "1";
|
const keepDocker = process.env["LIVESYNC_DEBUG_KEEP_DOCKER"] === "1";
|
||||||
|
|
||||||
if (shouldStartDocker) {
|
if (shouldStartDocker) {
|
||||||
await startCouchdb(uri, user, password, dbname);
|
await startCouchdb(uri, user, password, dbname);
|
||||||
+42
-40
@@ -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 { TempDir } from "./helpers/temp.ts";
|
||||||
import { runCli } from "./helpers/cli.ts";
|
import { runCli } from "./helpers/cli.ts";
|
||||||
import { initSettingsFile } from "./helpers/settings.ts";
|
import { initSettingsFile } from "./helpers/settings.ts";
|
||||||
@@ -7,24 +9,24 @@ async function prepareSettingsFixture(prefix: string) {
|
|||||||
const workDir = await TempDir.create(prefix);
|
const workDir = await TempDir.create(prefix);
|
||||||
const settingsFile = workDir.join("settings.json");
|
const settingsFile = workDir.join("settings.json");
|
||||||
const databaseDir = workDir.join("database");
|
const databaseDir = workDir.join("database");
|
||||||
await Deno.mkdir(databaseDir, { recursive: true });
|
await mkdir(databaseDir, { recursive: true });
|
||||||
await initSettingsFile(settingsFile);
|
await initSettingsFile(settingsFile);
|
||||||
return { workDir, settingsFile, databaseDir };
|
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");
|
const fixture = await prepareSettingsFixture("livesync-cli-settings-command");
|
||||||
await using workDir = fixture.workDir;
|
await using workDir = fixture.workDir;
|
||||||
const { settingsFile, databaseDir } = fixture;
|
const { settingsFile, databaseDir } = fixture;
|
||||||
|
|
||||||
const settings = JSON.parse(await Deno.readTextFile(settingsFile));
|
const settings = JSON.parse(await readFile(settingsFile, "utf8"));
|
||||||
settings.liveSync = true;
|
settings.liveSync = true;
|
||||||
settings.syncOnStart = true;
|
settings.syncOnStart = true;
|
||||||
settings.periodicReplication = true;
|
settings.periodicReplication = true;
|
||||||
settings.P2P_Enabled = true;
|
settings.P2P_Enabled = true;
|
||||||
settings.P2P_AutoStart = true;
|
settings.P2P_AutoStart = true;
|
||||||
settings.P2P_AutoBroadcast = 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(
|
const result = await runCli(
|
||||||
databaseDir,
|
databaseDir,
|
||||||
@@ -34,22 +36,22 @@ Deno.test("settings-changing commands persist durable settings without CLI runti
|
|||||||
"test-remote",
|
"test-remote",
|
||||||
"sls+https://user:pass@example.com/database"
|
"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];
|
const firstRemoteId = result.stdout.trim().split("\t")[0];
|
||||||
assert(firstRemoteId, `remote-add did not return an ID: ${result.combined}`);
|
assert(firstRemoteId, `remote-add did not return an ID: ${result.combined}`);
|
||||||
|
|
||||||
let persisted = JSON.parse(await Deno.readTextFile(settingsFile));
|
let persisted = JSON.parse(await readFile(settingsFile, "utf8"));
|
||||||
let remotes = Object.values(persisted.remoteConfigurations ?? {}) as Array<{ name?: string }>;
|
const remotesByName = Object.values(persisted.remoteConfigurations ?? {}) as Array<{ name?: string }>;
|
||||||
assert(
|
assert(
|
||||||
remotes.some((remote) => remote.name === "test-remote"),
|
remotesByName.some((remote) => remote.name === "test-remote"),
|
||||||
"remote-add did not persist the new profile"
|
"remote-add did not persist the new profile"
|
||||||
);
|
);
|
||||||
assertEquals(persisted.liveSync, true);
|
assert.deepStrictEqual(persisted.liveSync, true);
|
||||||
assertEquals(persisted.syncOnStart, true);
|
assert.deepStrictEqual(persisted.syncOnStart, true);
|
||||||
assertEquals(persisted.periodicReplication, true);
|
assert.deepStrictEqual(persisted.periodicReplication, true);
|
||||||
assertEquals(persisted.P2P_Enabled, true);
|
assert.deepStrictEqual(persisted.P2P_Enabled, true);
|
||||||
assertEquals(persisted.P2P_AutoStart, true);
|
assert.deepStrictEqual(persisted.P2P_AutoStart, true);
|
||||||
assertEquals(persisted.P2P_AutoBroadcast, true);
|
assert.deepStrictEqual(persisted.P2P_AutoBroadcast, true);
|
||||||
|
|
||||||
const secondAdd = await runCli(
|
const secondAdd = await runCli(
|
||||||
databaseDir,
|
databaseDir,
|
||||||
@@ -59,14 +61,14 @@ Deno.test("settings-changing commands persist durable settings without CLI runti
|
|||||||
"second-remote",
|
"second-remote",
|
||||||
"sls+https://other:secret@example.net/second"
|
"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];
|
const secondRemoteId = secondAdd.stdout.trim().split("\t")[0];
|
||||||
assert(secondRemoteId, `second remote-add did not return an ID: ${secondAdd.combined}`);
|
assert(secondRemoteId, `second remote-add did not return an ID: ${secondAdd.combined}`);
|
||||||
|
|
||||||
const activate = await runCli(databaseDir, "--settings", settingsFile, "remote-activate", secondRemoteId);
|
const activate = await runCli(databaseDir, "--settings", settingsFile, "remote-activate", secondRemoteId);
|
||||||
assertEquals(activate.code, 0, activate.combined);
|
assert.deepStrictEqual(activate.code, 0, activate.combined);
|
||||||
persisted = JSON.parse(await Deno.readTextFile(settingsFile));
|
persisted = JSON.parse(await readFile(settingsFile, "utf8"));
|
||||||
assertEquals(persisted.activeConfigurationId, secondRemoteId);
|
assert.deepStrictEqual(persisted.activeConfigurationId, secondRemoteId);
|
||||||
|
|
||||||
const set = await runCli(
|
const set = await runCli(
|
||||||
databaseDir,
|
databaseDir,
|
||||||
@@ -76,59 +78,59 @@ Deno.test("settings-changing commands persist durable settings without CLI runti
|
|||||||
secondRemoteId,
|
secondRemoteId,
|
||||||
"sls+https://replacement:secret@example.org/replaced"
|
"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);
|
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");
|
assert(exported.stdout.includes("replacement"), "remote-set did not persist the replacement URI");
|
||||||
|
|
||||||
const remove = await runCli(databaseDir, "--settings", settingsFile, "remote-rm", secondRemoteId);
|
const remove = await runCli(databaseDir, "--settings", settingsFile, "remote-rm", secondRemoteId);
|
||||||
assertEquals(remove.code, 0, remove.combined);
|
assert.deepStrictEqual(remove.code, 0, remove.combined);
|
||||||
persisted = JSON.parse(await Deno.readTextFile(settingsFile));
|
persisted = JSON.parse(await readFile(settingsFile, "utf8"));
|
||||||
remotes = Object.values(persisted.remoteConfigurations ?? {}) as Array<{ id?: string }>;
|
const remotesById = Object.values(persisted.remoteConfigurations ?? {}) as Array<{ id?: string }>;
|
||||||
assert(!remotes.some((remote) => remote.id === secondRemoteId), "remote-rm did not persist the removal");
|
assert(!remotesById.some((remote) => remote.id === secondRemoteId), "remote-rm did not persist the removal");
|
||||||
assertEquals(persisted.activeConfigurationId, firstRemoteId);
|
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");
|
const fixture = await prepareSettingsFixture("livesync-cli-settings-readonly");
|
||||||
await using workDir = fixture.workDir;
|
await using workDir = fixture.workDir;
|
||||||
const { settingsFile, databaseDir } = fixture;
|
const { settingsFile, databaseDir } = fixture;
|
||||||
|
|
||||||
const settings = JSON.parse(await Deno.readTextFile(settingsFile));
|
const settings = JSON.parse(await readFile(settingsFile, "utf8"));
|
||||||
settings.settingVersion = 9;
|
settings.settingVersion = 9;
|
||||||
const original = JSON.stringify(settings, null, 2);
|
const original = JSON.stringify(settings, null, 2);
|
||||||
await Deno.writeTextFile(settingsFile, original);
|
await writeFile(settingsFile, original);
|
||||||
|
|
||||||
const result = await runCli(databaseDir, "--settings", settingsFile, "ls");
|
const result = await runCli(databaseDir, "--settings", settingsFile, "ls");
|
||||||
assertEquals(result.code, 0, result.combined);
|
assert.deepStrictEqual(result.code, 0, result.combined);
|
||||||
assertEquals(await Deno.readTextFile(settingsFile), original);
|
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");
|
const fixture = await prepareSettingsFixture("livesync-cli-settings-explicit");
|
||||||
await using workDir = fixture.workDir;
|
await using workDir = fixture.workDir;
|
||||||
const { settingsFile, databaseDir } = fixture;
|
const { settingsFile, databaseDir } = fixture;
|
||||||
|
|
||||||
const settings = JSON.parse(await Deno.readTextFile(settingsFile));
|
const settings = JSON.parse(await readFile(settingsFile, "utf8"));
|
||||||
settings.settingVersion = 9;
|
settings.settingVersion = 9;
|
||||||
delete settings.useIndexedDBAdapter;
|
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");
|
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));
|
const persisted = JSON.parse(await readFile(settingsFile, "utf8"));
|
||||||
assertEquals(persisted.settingVersion, 10);
|
assert.deepStrictEqual(persisted.settingVersion, 10);
|
||||||
assert(!("useIndexedDBAdapter" in persisted), "the CLI-only adapter override was written to the settings file");
|
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");
|
const fixture = await prepareSettingsFixture("livesync-cli-settings-failure");
|
||||||
await using workDir = fixture.workDir;
|
await using workDir = fixture.workDir;
|
||||||
const { settingsFile, databaseDir } = fixture;
|
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");
|
const result = await runCli(databaseDir, "--settings", settingsFile, "remote-rm", "missing-remote");
|
||||||
assert(result.code !== 0, "remote-rm unexpectedly succeeded");
|
assert(result.code !== 0, "remote-rm unexpectedly succeeded");
|
||||||
assertEquals(await Deno.readTextFile(settingsFile), original);
|
assert.deepStrictEqual(await readFile(settingsFile, "utf8"), original);
|
||||||
});
|
});
|
||||||
@@ -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:
|
* Tests all local-DB file operations that require no external remote:
|
||||||
* setup /
|
* setup /
|
||||||
* push / cat / ls / info / rm / resolve / cat-rev / pull-rev
|
* push / cat / ls / info / rm / resolve / cat-rev / pull-rev
|
||||||
*
|
*
|
||||||
* Run (no external services needed):
|
* 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 { test } from "node:test";
|
||||||
import { assertEquals, assert } from "@std/assert";
|
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 { TempDir } from "./helpers/temp.ts";
|
||||||
import { runCli, runCliOrFail, runCliWithInput, sanitiseCatStdout } from "./helpers/cli.ts";
|
import { runCli, runCliOrFail, runCliWithInput, sanitiseCatStdout } from "./helpers/cli.ts";
|
||||||
import { generateSetupUriFromSettings, initSettingsFile } from "./helpers/settings.ts";
|
import { generateSetupUriFromSettings, initSettingsFile } from "./helpers/settings.ts";
|
||||||
|
|
||||||
const REMOTE_PATH = Deno.env.get("REMOTE_PATH") ?? "test/setup-put-cat.txt";
|
const REMOTE_PATH = process.env["REMOTE_PATH"] ?? "test/setup-put-cat.txt";
|
||||||
const SETUP_PASSPHRASE = Deno.env.get("SETUP_PASSPHRASE") ?? "setup-passphrase";
|
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");
|
await using workDir = await TempDir.create("livesync-cli-setup-put-cat");
|
||||||
|
|
||||||
const settingsFile = workDir.join("data.json");
|
const settingsFile = workDir.join("data.json");
|
||||||
const vaultDir = workDir.join("vault");
|
const vaultDir = workDir.join("vault");
|
||||||
await Deno.mkdir(join(vaultDir, "test"), { recursive: true });
|
await mkdir(join(vaultDir, "test"), { recursive: true });
|
||||||
|
|
||||||
await initSettingsFile(settingsFile);
|
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 ->"),
|
setupResult.combined.includes("[Command] setup ->"),
|
||||||
`setup command did not execute expected code path\n${setupResult.combined}`
|
`setup command did not execute expected code path\n${setupResult.combined}`
|
||||||
);
|
);
|
||||||
const persistedSetup = JSON.parse(await Deno.readTextFile(settingsFile));
|
const persistedSetup = JSON.parse(await readFile(settingsFile, "utf8"));
|
||||||
assertEquals(persistedSetup.isConfigured, true, "setup did not persist the configured state");
|
assert.deepStrictEqual(persistedSetup.isConfigured, true, "setup did not persist the configured state");
|
||||||
assert(
|
assert(
|
||||||
typeof persistedSetup.encryptedCouchDBConnection === "string" &&
|
typeof persistedSetup.encryptedCouchDBConnection === "string" &&
|
||||||
persistedSetup.encryptedCouchDBConnection.length > 0,
|
persistedSetup.encryptedCouchDBConnection.length > 0,
|
||||||
@@ -54,10 +57,10 @@ Deno.test("CLI file operations: push / cat / ls / info / rm / resolve / cat-rev
|
|||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// push / cat roundtrip
|
// 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 srcFile = workDir.join("put-source.txt");
|
||||||
const content = `setup-put-cat-test ${new Date().toISOString()}\nline-2\n`;
|
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}`);
|
console.log(`[INFO] push -> ${REMOTE_PATH}`);
|
||||||
await runCliWithInput(content, vaultDir, "--settings", settingsFile, "put", 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 rawOutput = await run("cat", REMOTE_PATH);
|
||||||
const catOutput = sanitiseCatStdout(rawOutput);
|
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");
|
console.log("[PASS] push/cat roundtrip matched");
|
||||||
});
|
});
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// ls: single file
|
// 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 lsOutput = await run("ls", REMOTE_PATH);
|
||||||
const line = lsOutput
|
const line = lsOutput
|
||||||
.trim()
|
.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}`);
|
assert(line, `ls output did not include ${REMOTE_PATH}`);
|
||||||
|
|
||||||
const [lsPath, lsSize, lsMtime, lsRev] = line.split("\t");
|
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(lsSize), `ls size not numeric: ${lsSize}`);
|
||||||
assert(/^\d+$/.test(lsMtime), `ls mtime not numeric: ${lsMtime}`);
|
assert(/^\d+$/.test(lsMtime), `ls mtime not numeric: ${lsMtime}`);
|
||||||
assert(lsRev?.length > 0, "ls revision column is empty");
|
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
|
// 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-a\n", vaultDir, "--settings", settingsFile, "put", "test/a-first.txt");
|
||||||
await runCliWithInput("file-z\n", vaultDir, "--settings", settingsFile, "put", "test/z-last.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
|
// 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/");
|
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");
|
console.log("[PASS] ls no-match prefix matched");
|
||||||
});
|
});
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// info: JSON output format
|
// 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);
|
const infoOut = await run("info", REMOTE_PATH);
|
||||||
let data: Record<string, unknown>;
|
let data: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
@@ -136,18 +139,18 @@ Deno.test("CLI file operations: push / cat / ls / info / rm / resolve / cat-rev
|
|||||||
} catch {
|
} catch {
|
||||||
throw new Error(`info output is not valid JSON:\n${infoOut}`);
|
throw new Error(`info output is not valid JSON:\n${infoOut}`);
|
||||||
}
|
}
|
||||||
assertEquals(data.path, REMOTE_PATH, "info .path mismatch");
|
assert.deepStrictEqual(data.path, REMOTE_PATH, "info .path mismatch");
|
||||||
assertEquals(data.filename, REMOTE_PATH.split("/").at(-1), "info .filename 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.size === "number" && data.size >= 0, `info .size invalid: ${data.size}`);
|
||||||
assert(typeof data.chunks === "number" && data.chunks >= 1, `info .chunks invalid: ${data.chunks}`);
|
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");
|
console.log("[PASS] info output format matched");
|
||||||
});
|
});
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// info: non-existent path exits non-zero
|
// 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");
|
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");
|
assert(r.code !== 0, "info on non-existent file should exit non-zero");
|
||||||
console.log("[PASS] info non-existent path returns 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
|
// 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");
|
await run("rm", "test/z-last.txt");
|
||||||
|
|
||||||
const catResult = await runCli(vaultDir, "--settings", settingsFile, "cat", "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
|
// 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];
|
const lsLine = (await run("ls", "test/a-first.txt")).trim().split("\n")[0];
|
||||||
assert(lsLine, "could not fetch revision for resolve test");
|
assert(lsLine, "could not fetch revision for resolve test");
|
||||||
const rev = lsLine.split("\t")[3];
|
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
|
// 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";
|
const revPath = "test/revision-history.txt";
|
||||||
await runCliWithInput("revision-v1\n", vaultDir, "--settings", settingsFile, "put", revPath);
|
await runCliWithInput("revision-v1\n", vaultDir, "--settings", settingsFile, "put", revPath);
|
||||||
await runCliWithInput("revision-v2\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");
|
const pullRevFile = workDir.join("rev-pull-output.txt");
|
||||||
await run("pull-rev", revPath, pullRevFile, pastRev);
|
await run("pull-rev", revPath, pullRevFile, pastRev);
|
||||||
const pullRevContent = await Deno.readTextFile(pullRevFile);
|
const pullRevContent = await readFile(pullRevFile, "utf8");
|
||||||
assert(
|
assert(
|
||||||
pullRevContent === "revision-v1\n" || pullRevContent === "revision-v2\n",
|
pullRevContent === "revision-v1\n" || pullRevContent === "revision-v2\n",
|
||||||
`pull-rev output did not match expected past revision:\n${pullRevContent}`
|
`pull-rev output did not match expected past revision:\n${pullRevContent}`
|
||||||
+14
-7
@@ -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
|
* Verifies CLI sync behaviour when the remote milestone document is unlocked
|
||||||
* versus locked.
|
* 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 { TempDir } from "./helpers/temp.ts";
|
||||||
import { runCli } from "./helpers/cli.ts";
|
import { runCli } from "./helpers/cli.ts";
|
||||||
import { applyCouchdbSettings, initSettingsFile } from "./helpers/settings.ts";
|
import { applyCouchdbSettings, initSettingsFile } from "./helpers/settings.ts";
|
||||||
import { createCouchdbDatabase, startCouchdb, stopCouchdb, updateCouchdbDoc } from "./helpers/docker.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";
|
const MILESTONE_DOC = "_local/obsydian_livesync_milestone";
|
||||||
|
|
||||||
function requireEnv(...keys: string[]): string {
|
function requireEnv(...keys: string[]): string {
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const value = Deno.env.get(key)?.trim();
|
const value = process.env[key]?.trim();
|
||||||
if (value) return value;
|
if (value) return value;
|
||||||
}
|
}
|
||||||
throw new Error(`Required env var is missing: ${keys.join(" or ")}`);
|
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 couchdbUri = requireEnv("COUCHDB_URI", "hostname").replace(/\/$/, "");
|
||||||
const couchdbUser = requireEnv("COUCHDB_USER", "username");
|
const couchdbUser = requireEnv("COUCHDB_USER", "username");
|
||||||
const couchdbPassword = requireEnv("COUCHDB_PASSWORD", "password");
|
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");
|
await using workDir = await TempDir.create("livesync-cli-locked-test");
|
||||||
const vaultDir = workDir.join("vault");
|
const vaultDir = workDir.join("vault");
|
||||||
const settingsFile = workDir.join("settings.json");
|
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 shouldStartDocker = process.env["LIVESYNC_START_DOCKER"] !== "0";
|
||||||
const keepDocker = Deno.env.get("LIVESYNC_DEBUG_KEEP_DOCKER") === "1";
|
const keepDocker = process.env["LIVESYNC_DEBUG_KEEP_DOCKER"] === "1";
|
||||||
|
|
||||||
if (shouldStartDocker) {
|
if (shouldStartDocker) {
|
||||||
console.log(`[INFO] starting CouchDB and creating test database: ${dbname}`);
|
console.log(`[INFO] starting CouchDB and creating test database: ${dbname}`);
|
||||||
+39
-38
@@ -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
|
* Tests two-vault synchronisation via CouchDB including conflict detection
|
||||||
* and resolution.
|
* and resolution.
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
* CouchDB instance instead.
|
* CouchDB instance instead.
|
||||||
*
|
*
|
||||||
* Run:
|
* Run:
|
||||||
* deno test -A test-sync-two-local-databases.ts
|
* node --test test-sync-two-local-databases.ts
|
||||||
*
|
*
|
||||||
* With an existing CouchDB:
|
* With an existing CouchDB:
|
||||||
* COUCHDB_URI=http://127.0.0.1:5984 \
|
* COUCHDB_URI=http://127.0.0.1:5984 \
|
||||||
@@ -20,10 +20,13 @@
|
|||||||
* COUCHDB_PASSWORD=password \
|
* COUCHDB_PASSWORD=password \
|
||||||
* COUCHDB_DBNAME=livesync-test \
|
* COUCHDB_DBNAME=livesync-test \
|
||||||
* LIVESYNC_START_DOCKER=0 \
|
* 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 { TempDir } from "./helpers/temp.ts";
|
||||||
import { runCliOrFail, jsonFieldIsNa } from "./helpers/cli.ts";
|
import { runCliOrFail, jsonFieldIsNa } from "./helpers/cli.ts";
|
||||||
import { applyCouchdbSettings, initSettingsFile } from "./helpers/settings.ts";
|
import { applyCouchdbSettings, initSettingsFile } from "./helpers/settings.ts";
|
||||||
@@ -39,7 +42,7 @@ async function resolveConfig(): Promise<{
|
|||||||
password: string;
|
password: string;
|
||||||
baseDbname: string;
|
baseDbname: string;
|
||||||
} | null> {
|
} | null> {
|
||||||
const env = Deno.env.toObject();
|
const env = { ...process.env };
|
||||||
|
|
||||||
const uri = (env["COUCHDB_URI"] ?? env["hostname"] ?? "").replace(/\/$/, "");
|
const uri = (env["COUCHDB_URI"] ?? env["hostname"] ?? "").replace(/\/$/, "");
|
||||||
const user = env["COUCHDB_USER"] ?? env["username"] ?? "";
|
const user = env["COUCHDB_USER"] ?? env["username"] ?? "";
|
||||||
@@ -51,25 +54,23 @@ async function resolveConfig(): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
const config = await resolveConfig();
|
const config = await resolveConfig();
|
||||||
const START_DOCKER = Deno.env.get("LIVESYNC_START_DOCKER") !== "0";
|
const START_DOCKER = process.env["LIVESYNC_START_DOCKER"] !== "0";
|
||||||
const KEEP_DOCKER = Deno.env.get("LIVESYNC_DEBUG_KEEP_DOCKER") === "1";
|
const KEEP_DOCKER = process.env["LIVESYNC_DEBUG_KEEP_DOCKER"] === "1";
|
||||||
const SYNC_RETRY = Number(Deno.env.get("LIVESYNC_SYNC_RETRY") ?? "8");
|
const SYNC_RETRY = Number(process.env["LIVESYNC_SYNC_RETRY"] ?? "8");
|
||||||
|
|
||||||
// Provide a sane default for flaky remote connectivity in Docker-on-WSL
|
// Provide a sane default for flaky remote connectivity in Docker-on-WSL
|
||||||
// environments. Users can override explicitly if needed.
|
// environments. Users can override explicitly if needed.
|
||||||
if (!Deno.env.has("LIVESYNC_CLI_RETRY")) {
|
if (!Object.hasOwn(process.env, "LIVESYNC_CLI_RETRY")) {
|
||||||
Deno.env.set("LIVESYNC_CLI_RETRY", "2");
|
(process.env["LIVESYNC_CLI_RETRY"] = "2");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Test suite
|
// Test suite
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
Deno.test(
|
test(
|
||||||
{
|
"sync two local databases: sync + conflict detection + resolution",
|
||||||
name: "sync two local databases: sync + conflict detection + resolution",
|
{ skip: config === null },
|
||||||
ignore: config === null,
|
|
||||||
},
|
|
||||||
async (t) => {
|
async (t) => {
|
||||||
if (!config) return; // narrowing for TypeScript
|
if (!config) return; // narrowing for TypeScript
|
||||||
|
|
||||||
@@ -104,7 +105,7 @@ Deno.test(
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function runSuite(
|
async function runSuite(
|
||||||
t: Deno.TestContext,
|
t: TestContext,
|
||||||
workDir: TempDir,
|
workDir: TempDir,
|
||||||
config: { uri: string; user: string; password: string },
|
config: { uri: string; user: string; password: string },
|
||||||
dbname: string
|
dbname: string
|
||||||
@@ -130,8 +131,8 @@ async function runSuite(
|
|||||||
const vaultB = workDir.join("vault-b");
|
const vaultB = workDir.join("vault-b");
|
||||||
const settingsA = workDir.join("a-settings.json");
|
const settingsA = workDir.join("a-settings.json");
|
||||||
const settingsB = workDir.join("b-settings.json");
|
const settingsB = workDir.join("b-settings.json");
|
||||||
await Deno.mkdir(vaultA, { recursive: true });
|
await mkdir(vaultA, { recursive: true });
|
||||||
await Deno.mkdir(vaultB, { recursive: true });
|
await mkdir(vaultB, { recursive: true });
|
||||||
|
|
||||||
await initSettingsFile(settingsA);
|
await initSettingsFile(settingsA);
|
||||||
await initSettingsFile(settingsB);
|
await initSettingsFile(settingsB);
|
||||||
@@ -152,45 +153,45 @@ async function runSuite(
|
|||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Case 1: A creates file, B reads after sync
|
// 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");
|
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 runA("push", srcA, "shared/from-a.txt");
|
||||||
await syncA();
|
await syncA();
|
||||||
await syncB();
|
await syncB();
|
||||||
const value = (await catB("shared/from-a.txt")).replace(/\r\n/g, "\n").trimEnd();
|
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");
|
console.log("[PASS] case 1 passed");
|
||||||
});
|
});
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Case 2: B creates file, A reads after sync
|
// 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");
|
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 runB("push", srcB, "shared/from-b.txt");
|
||||||
await syncB();
|
await syncB();
|
||||||
await syncA();
|
await syncA();
|
||||||
const value = (await catA("shared/from-b.txt")).replace(/\r\n/g, "\n").trimEnd();
|
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");
|
console.log("[PASS] case 2 passed");
|
||||||
});
|
});
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Case 3: concurrent edits create a conflict
|
// 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");
|
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 runA("push", baseSrc, "shared/conflicted.txt");
|
||||||
await syncA();
|
await syncA();
|
||||||
await syncB();
|
await syncB();
|
||||||
|
|
||||||
const aEdit = workDir.join("edit-a.txt");
|
const aEdit = workDir.join("edit-a.txt");
|
||||||
const bEdit = workDir.join("edit-b.txt");
|
const bEdit = workDir.join("edit-b.txt");
|
||||||
await Deno.writeTextFile(aEdit, "edit-from-a\n");
|
await writeFile(aEdit, "edit-from-a\n");
|
||||||
await Deno.writeTextFile(bEdit, "edit-from-b\n");
|
await writeFile(bEdit, "edit-from-b\n");
|
||||||
await runA("push", aEdit, "shared/conflicted.txt");
|
await runA("push", aEdit, "shared/conflicted.txt");
|
||||||
await runB("push", bEdit, "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) {
|
for (const side of ["a", "b"] as const) {
|
||||||
if (side === "a") await syncA();
|
if (side === "a") await syncA();
|
||||||
else await syncB();
|
else await syncB();
|
||||||
await Deno.writeTextFile(infoFileA, await runA("info", "shared/conflicted.txt"));
|
await writeFile(infoFileA, await runA("info", "shared/conflicted.txt"));
|
||||||
await Deno.writeTextFile(infoFileB, await runB("info", "shared/conflicted.txt"));
|
await writeFile(infoFileB, await runB("info", "shared/conflicted.txt"));
|
||||||
const da = JSON.parse(await Deno.readTextFile(infoFileA)) as Record<string, unknown>;
|
const da = JSON.parse(await readFile(infoFileA, "utf8")) as Record<string, unknown>;
|
||||||
const db = JSON.parse(await Deno.readTextFile(infoFileB)) as Record<string, unknown>;
|
const db = JSON.parse(await readFile(infoFileB, "utf8")) as Record<string, unknown>;
|
||||||
if (!jsonFieldIsNa(da, "conflicts") || !jsonFieldIsNa(db, "conflicts")) {
|
if (!jsonFieldIsNa(da, "conflicts") || !jsonFieldIsNa(db, "conflicts")) {
|
||||||
conflictDetected = true;
|
conflictDetected = true;
|
||||||
break;
|
break;
|
||||||
@@ -217,14 +218,14 @@ async function runSuite(
|
|||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Case 4: resolve on A, verify B has no conflict after sync
|
// 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 infoFileA = workDir.join("info-a-resolve.json");
|
||||||
const infoFileB = workDir.join("info-b-resolve.json");
|
const infoFileB = workDir.join("info-b-resolve.json");
|
||||||
|
|
||||||
// Ensure A sees the conflict
|
// Ensure A sees the conflict
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
const raw = await runA("info", "shared/conflicted.txt");
|
const raw = await runA("info", "shared/conflicted.txt");
|
||||||
await Deno.writeTextFile(infoFileA, raw);
|
await writeFile(infoFileA, raw);
|
||||||
const da = JSON.parse(raw) as Record<string, unknown>;
|
const da = JSON.parse(raw) as Record<string, unknown>;
|
||||||
if (!jsonFieldIsNa(da, "conflicts")) break;
|
if (!jsonFieldIsNa(da, "conflicts")) break;
|
||||||
await syncB();
|
await syncB();
|
||||||
@@ -232,7 +233,7 @@ async function runSuite(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rawA = await runA("info", "shared/conflicted.txt");
|
const rawA = await runA("info", "shared/conflicted.txt");
|
||||||
await Deno.writeTextFile(infoFileA, rawA);
|
await writeFile(infoFileA, rawA);
|
||||||
const dataA = JSON.parse(rawA) as Record<string, unknown>;
|
const dataA = JSON.parse(rawA) as Record<string, unknown>;
|
||||||
assert(!jsonFieldIsNa(dataA, "conflicts"), "A does not see conflict, cannot resolve from A only");
|
assert(!jsonFieldIsNa(dataA, "conflicts"), "A does not see conflict, cannot resolve from A only");
|
||||||
|
|
||||||
@@ -247,8 +248,8 @@ async function runSuite(
|
|||||||
await syncB();
|
await syncB();
|
||||||
const rawA2 = await runA("info", "shared/conflicted.txt");
|
const rawA2 = await runA("info", "shared/conflicted.txt");
|
||||||
const rawB2 = await runB("info", "shared/conflicted.txt");
|
const rawB2 = await runB("info", "shared/conflicted.txt");
|
||||||
await Deno.writeTextFile(infoFileA, rawA2);
|
await writeFile(infoFileA, rawA2);
|
||||||
await Deno.writeTextFile(infoFileB, rawB2);
|
await writeFile(infoFileB, rawB2);
|
||||||
const da2 = JSON.parse(rawA2) as Record<string, unknown>;
|
const da2 = JSON.parse(rawA2) as Record<string, unknown>;
|
||||||
const db2 = JSON.parse(rawB2) as Record<string, unknown>;
|
const db2 = JSON.parse(rawB2) as Record<string, unknown>;
|
||||||
if (jsonFieldIsNa(da2, "conflicts") && jsonFieldIsNa(db2, "conflicts")) {
|
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 contentA = (await catA("shared/conflicted.txt")).replace(/\r\n/g, "\n");
|
||||||
const contentB = (await catB("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] case 4 passed");
|
||||||
console.log("[PASS] all sync/resolve scenarios passed");
|
console.log("[PASS] all sync/resolve scenarios passed");
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## 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=<n>`: 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.
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
"src/**/*.test.ts",
|
"src/**/*.test.ts",
|
||||||
"**/_test/**",
|
"**/_test/**",
|
||||||
"test/browser-apps",
|
"test/browser-apps",
|
||||||
|
"test/e2e-cli",
|
||||||
"test/styles/*.deno.ts",
|
"test/styles/*.deno.ts",
|
||||||
"utilsdeno"
|
"utilsdeno"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ describe("CI workflow path filters", () => {
|
|||||||
"utils/ci-pages.unit.spec.ts",
|
"utils/ci-pages.unit.spec.ts",
|
||||||
"utils/cli-ci.unit.spec.ts",
|
"utils/cli-ci.unit.spec.ts",
|
||||||
".github/workflows/cli-docker.yml",
|
".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-p2p-tests.yml",
|
||||||
".github/workflows/cli-docker-validation.yml",
|
".github/workflows/cli-docker-validation.yml",
|
||||||
];
|
];
|
||||||
|
|||||||
+43
-12
@@ -19,7 +19,8 @@ type Workflow = {
|
|||||||
if?: string;
|
if?: string;
|
||||||
permissions?: Record<string, string>;
|
permissions?: Record<string, string>;
|
||||||
"runs-on"?: string;
|
"runs-on"?: string;
|
||||||
strategy?: { matrix?: { include?: Array<{ arch: string; runner: string }> } };
|
strategy?: { matrix?: { include?: Array<{ arch: string; runner: string }>; os?: string[] } };
|
||||||
|
services?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
};
|
};
|
||||||
@@ -35,12 +36,12 @@ function matches(paths: string[], file: string): boolean {
|
|||||||
return included;
|
return included;
|
||||||
}
|
}
|
||||||
function localTasks(): string[] {
|
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" }');
|
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[] {
|
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();
|
expect(select?.run).toBeDefined();
|
||||||
const directory = mkdtempSync(join(tmpdir(), "livesync-ci-matrix-"));
|
const directory = mkdtempSync(join(tmpdir(), "livesync-ci-matrix-"));
|
||||||
const output = join(directory, "output");
|
const output = join(directory, "output");
|
||||||
@@ -67,19 +68,20 @@ describe("CLI CI selection", () => {
|
|||||||
expect(selectedTasks("test:ci")).toEqual(
|
expect(selectedTasks("test:ci")).toEqual(
|
||||||
expect.arrayContaining(["test:settings-writeback", "test:remote-administration-exit-codes"])
|
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;
|
const tasks = JSON.parse(readFileSync(new URL("test/e2e-cli/tasks.json", root), "utf8"));
|
||||||
expect(localTasks().filter((task) => !(task in tasks))).toEqual([]);
|
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);
|
expect(new Set(localTasks()).size).toBe(localTasks().length);
|
||||||
});
|
});
|
||||||
it("keeps the selected manual subsets", () => {
|
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(
|
expect(selectedTasks("test:e2e-matrix")).toEqual(
|
||||||
localTasks().filter((task) => task.startsWith("test:e2e-matrix:"))
|
localTasks().filter((task) => task.startsWith("test:e2e-matrix:"))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
it.each([
|
it.each([
|
||||||
["cli-deno-tests.yml", "push"],
|
["cli-node-tests.yml", "push"],
|
||||||
["cli-deno-tests.yml", "pull_request"],
|
["cli-node-tests.yml", "pull_request"],
|
||||||
["cli-p2p-tests.yml", "push"],
|
["cli-p2p-tests.yml", "push"],
|
||||||
["cli-p2p-tests.yml", "pull_request"],
|
["cli-p2p-tests.yml", "pull_request"],
|
||||||
["cli-docker.yml", "push"],
|
["cli-docker.yml", "push"],
|
||||||
@@ -105,16 +107,45 @@ describe("CLI CI selection", () => {
|
|||||||
expect(inputs.filter((file) => !matches(paths, file))).toEqual([]);
|
expect(inputs.filter((file) => !matches(paths, file))).toEqual([]);
|
||||||
expect(matches(paths, "src/modules/features/ModuleObsidianSettingTab.ts")).toBe(false);
|
expect(matches(paths, "src/modules/features/ModuleObsidianSettingTab.ts")).toBe(false);
|
||||||
expect(matches(paths, "docs/settings.md")).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", () => {
|
it("runs Compose alone for a change to its fixture", () => {
|
||||||
const file = "test/bench-network/compose.yml";
|
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-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);
|
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("passes a single CLI build to every standard test job", () => {
|
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(test.needs).toBe("prepare");
|
||||||
expect(prepare.steps?.filter((step) => step.run === "npm run build")).toHaveLength(1);
|
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);
|
expect(test.steps?.some((step) => step.run?.includes("npm run build"))).toBe(false);
|
||||||
|
|||||||
+1
-1
@@ -95,6 +95,6 @@ Ensures that internal plug-in import statements are standardised to the `@/` ali
|
|||||||
|
|
||||||
## Safety and Exclusions
|
## 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.
|
* **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.
|
* **Verification**: Always run `npm run check` and `npm run test:unit` after performing refactoring tasks to verify that type safety and tests remain intact.
|
||||||
@@ -67,8 +67,7 @@ for (const sourceFile of project.getSourceFiles()) {
|
|||||||
if (
|
if (
|
||||||
posixFilePath.endsWith(".spec.ts") ||
|
posixFilePath.endsWith(".spec.ts") ||
|
||||||
posixFilePath.endsWith(".test.ts") ||
|
posixFilePath.endsWith(".test.ts") ||
|
||||||
posixFilePath.includes("/_test/") ||
|
posixFilePath.includes("/_test/")
|
||||||
posixFilePath.includes("/testdeno/")
|
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,8 +102,7 @@ for (const sourceFile of project.getSourceFiles()) {
|
|||||||
if (
|
if (
|
||||||
posixFilePath.endsWith(".spec.ts") ||
|
posixFilePath.endsWith(".spec.ts") ||
|
||||||
posixFilePath.endsWith(".test.ts") ||
|
posixFilePath.endsWith(".test.ts") ||
|
||||||
posixFilePath.includes("/_test/") ||
|
posixFilePath.includes("/_test/")
|
||||||
posixFilePath.includes("/testdeno/")
|
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default mergeConfig(
|
|||||||
logHeapUsage: true,
|
logHeapUsage: true,
|
||||||
name: "integration-tests",
|
name: "integration-tests",
|
||||||
include: ["**/*.integration.spec.ts", "**/*.integration.test.ts"],
|
include: ["**/*.integration.spec.ts", "**/*.integration.test.ts"],
|
||||||
exclude: ["test/**", "src/apps/**/testdeno/**"],
|
exclude: ["test/**"],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -24,7 +24,7 @@ export default mergeConfig(
|
|||||||
// maxConcurrency: 2,
|
// maxConcurrency: 2,
|
||||||
name: "unit-tests",
|
name: "unit-tests",
|
||||||
include: ["**/*unit.test.ts", "**/*.unit.spec.ts"],
|
include: ["**/*unit.test.ts", "**/*.unit.spec.ts"],
|
||||||
exclude: ["node_modules/**", ".devmemo.local/**", "src/apps/**/testdeno/**"],
|
exclude: ["node_modules/**", ".devmemo.local/**", "test/e2e-cli/**"],
|
||||||
coverage: {
|
coverage: {
|
||||||
include: ["src/**/*.ts"],
|
include: ["src/**/*.ts"],
|
||||||
exclude: [
|
exclude: [
|
||||||
@@ -33,7 +33,6 @@ export default mergeConfig(
|
|||||||
"**/*.unit.spec.ts",
|
"**/*.unit.spec.ts",
|
||||||
"test/**",
|
"test/**",
|
||||||
"**/_*",
|
"**/_*",
|
||||||
"src/apps/**/testdeno/**",
|
|
||||||
"**/*_obsolete.ts",
|
"**/*_obsolete.ts",
|
||||||
...importOnlyFiles,
|
...importOnlyFiles,
|
||||||
...obsidianRuntimeOnlyFiles,
|
...obsidianRuntimeOnlyFiles,
|
||||||
|
|||||||
Reference in new issue
Block a user