Retire obsolete browser Harness

This commit is contained in:
vorotamoroz
2026-07-20 13:22:22 +00:00
parent 80d1416a81
commit 4cbe5679e1
38 changed files with 45 additions and 2689 deletions
-1
View File
@@ -23,7 +23,6 @@ on:
- "src/apps/webpeer/**" - "src/apps/webpeer/**"
- ".github/workflows/release.yml" - ".github/workflows/release.yml"
- ".github/workflows/unit-ci.yml" - ".github/workflows/unit-ci.yml"
- ".github/workflows/harness-ci.yml"
workflow_dispatch: workflow_dispatch:
inputs: inputs:
dry_run: dry_run:
-66
View File
@@ -1,66 +0,0 @@
# Run tests by Harnessed CI
name: harness-ci
on:
workflow_dispatch:
inputs:
testsuite:
description: 'Run specific test suite (leave empty to run all)'
type: choice
options:
- ''
- 'suite/'
- 'suitep2p/'
default: ''
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24.x'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install test dependencies (Playwright Chromium)
run: npm run test:install-dependencies
- name: Start test services (CouchDB)
run: npm run test:docker-couchdb:start
if: ${{ inputs.testsuite == '' || inputs.testsuite == 'suite/' }}
- name: Start test services (MinIO)
run: npm run test:docker-s3:start
if: ${{ inputs.testsuite == '' || inputs.testsuite == 'suite/' }}
- name: Start test services (Nostr Relay + WebPeer)
run: npm run test:docker-p2p:start
if: ${{ inputs.testsuite == '' || inputs.testsuite == 'suitep2p/' }}
- name: Run tests suite
if: ${{ inputs.testsuite == '' || inputs.testsuite == 'suite/' }}
env:
CI: true
run: npm run test suite/
- name: Run P2P tests suite
if: ${{ inputs.testsuite == '' || inputs.testsuite == 'suitep2p/' }}
env:
CI: true
run: npm run test:p2p
- name: Stop test services (CouchDB)
run: npm run test:docker-couchdb:stop
if: ${{ inputs.testsuite == '' || inputs.testsuite == 'suite/' }}
- name: Stop test services (MinIO)
run: npm run test:docker-s3:stop
if: ${{ inputs.testsuite == '' || inputs.testsuite == 'suite/' }}
- name: Stop test services (Nostr Relay + WebPeer)
run: npm run test:docker-p2p:stop
if: ${{ inputs.testsuite == '' || inputs.testsuite == 'suitep2p/' }}
+2 -1
View File
@@ -62,7 +62,8 @@ Before submitting code, you should run verification scripts locally to ensure co
- Run `npm run check` to perform code verification. This runs type-checking (`tsc-check`), ESLint (`lint`), and Svelte checks (`svelte-check`). - Run `npm run check` to perform code verification. This runs type-checking (`tsc-check`), ESLint (`lint`), and Svelte checks (`svelte-check`).
2. **Unit Tests**: 2. **Unit Tests**:
- Run `npm run test:unit` to execute fast local unit tests. - Run `npm run test:unit` to execute fast local unit tests.
- Run `npm run test` or `npm run test:full` for full testing suites (including dockerised services). - Run `npm run test:unit:coverage` when unit-test coverage is required.
- Run focused integration, CLI E2E, or real Obsidian E2E commands for the boundary being changed. Start only the Docker services required by that command.
3. **Build**: 3. **Build**:
- Run `npm run build` to compile the production bundle (`main.js`). - Run `npm run build` to compile the production bundle (`main.js`).
- Run `npm run dev` for the development watch/build task. - Run `npm run dev` for the development watch/build task.
+10 -10
View File
@@ -35,13 +35,14 @@ npm run check # TypeScript and svelte type checking
npm run dev # Development build with auto-rebuild (uses .env for test vault paths) npm run dev # Development build with auto-rebuild (uses .env for test vault paths)
npm run build # Production build npm run build # Production build
npm run buildDev # Development build (one-time) npm run buildDev # Development build (one-time)
npm run test:unit # Run unit tests only (no Docker services required) npm run test:integration # Run CouchDB-backed integration tests
npm test # Run Harness based vitest tests (requires Docker services), not recommended, unstable. npm run test:e2e:cli:p2p # Run canonical P2P validation in Compose
npm run test:e2e:obsidian:local-suite # Run the real Obsidian local suite
``` ```
### Tips ### Tips
Use CLI E2E tests or real Obsidian E2E scripts instead of `npm test` when the behaviour can be verified outside the browser harness. Select the narrowest unit, integration, CLI E2E, or real Obsidian E2E command that owns the behaviour being changed. The obsolete mocked browser Harness has been retired.
### Unreleased change notes ### Unreleased change notes
@@ -64,16 +65,15 @@ To facilitate development and testing, the build process can automatically copy
- **Unit Tests** (`vitest.config.unit.ts`): Unit tests run in Node.js (excluding harnesses and integration tests). Unit tests should be `*.unit.spec.ts` and placed alongside the implementation file (e.g., `ChunkFetcher.unit.spec.ts`). Executed via `npm run test:unit`. - **Unit Tests** (`vitest.config.unit.ts`): Unit tests run in Node.js (excluding harnesses and integration tests). Unit tests should be `*.unit.spec.ts` and placed alongside the implementation file (e.g., `ChunkFetcher.unit.spec.ts`). Executed via `npm run test:unit`.
- **Integration Tests** (`vitest.config.integration.ts`): Tests run in Node.js against a real CouchDB instance. Integration tests should be `*.integration.spec.ts` or `*.integration.test.ts` and placed alongside the implementation file (e.g., `StreamingFetch.integration.spec.ts`). Executed via `npm run test:integration`. - **Integration Tests** (`vitest.config.integration.ts`): Tests run in Node.js against a real CouchDB instance. Integration tests should be `*.integration.spec.ts` or `*.integration.test.ts` and placed alongside the implementation file (e.g., `StreamingFetch.integration.spec.ts`). Executed via `npm run test:integration`.
- If you add a feature that interacts with the remote database (e.g., replication changes, custom changes feed parameters, or custom HTTP queries), you strongly expected to write an integration test to verify the behaviour against a real CouchDB server. - If you add a feature that interacts with the remote database (e.g., replication changes, custom changes feed parameters, or custom HTTP queries), you strongly expected to write an integration test to verify the behaviour against a real CouchDB server.
- **Browser Harness Tests** (`vitest.config.ts`): Transitional browser-based harness tests using Playwright/Chromium. Executed via `npm run test`. This layer is no longer the preferred destination for new broad E2E coverage because `test/harness` can drift from real Obsidian behaviour.
- **P2P Tests** (`vitest.config.p2p.ts`): Browser-based Peer-to-Peer replication tests. Executed via `npm run test:p2p`.
- **Commonlib Tests**: Commonlib owns unit and package tests for shared RPC, storage, replication, and platform contracts. LiveSync CI verifies the exact packed dependency as a downstream consumer. - **Commonlib Tests**: Commonlib owns unit and package tests for shared RPC, storage, replication, and platform contracts. LiveSync CI verifies the exact packed dependency as a downstream consumer.
- **CLI E2E** (`src/apps/cli/testdeno/`): Host-independent consumer workflows, including the canonical Compose-based P2P scenarios. Run `npm run test:e2e:cli` for the ordinary suite or `npm run test:e2e:cli:p2p` for P2P validation.
- **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. Run focused scripts such as `npm run test:e2e:obsidian:two-vault-sync`, or use `npm run test:e2e:obsidian:local-suite:services` to run the broader local suite with CouchDB and MinIO 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. Run focused scripts such as `npm run test:e2e:obsidian:two-vault-sync`, or use `npm run test:e2e:obsidian:local-suite:services` to run the broader local suite with CouchDB and MinIO fixtures managed by the wrapper.
- **Docker Services**: Tests require CouchDB, MinIO (S3), and P2P services: - **Docker Services**: Service-backed tests use CouchDB and MinIO (S3). Canonical P2P validation owns its relay through the CLI Compose runner:
```bash ```bash
npm run test:docker-all:start # Start all test services npm run test:docker-all:start # Start all test services
npm run test:full # Run tests with coverage npm run test:integration # Run the relevant service-backed suite
npm run test:docker-all:stop # Stop services npm run test:docker-all:stop # Stop services
``` ```
@@ -82,9 +82,9 @@ To facilitate development and testing, the build process can automatically copy
- **Test Structure**: - **Test Structure**:
- `test/e2e-obsidian/` - Real Obsidian E2E scripts for local verification - `test/e2e-obsidian/` - Real Obsidian E2E scripts for local verification
- `test/suite/` - Transitional browser harness tests for sync operations - co-located `*.unit.spec.ts` files - Node-based unit tests
- `test/unit/` - Unit tests (via vitest, as harness is browser-based) - co-located `*.integration.spec.ts` files - service-backed integration tests
- `test/harness/` - Transitional mock implementations (e.g., `obsidian-mock.ts`). Avoid adding broad new E2E coverage here unless it is explicitly a compatibility bridge. - `src/apps/webapp/obsidianMock.ts` - Webapp-only Obsidian compatibility adapter; it is not an E2E Harness
### Import Path Normalisation ### Import Path Normalisation
+11 -10
View File
@@ -2,15 +2,15 @@
## Status ## Status
Proposed / Spike Implemented Accepted / Implemented
## Release ## Release
Not yet. Planned after the serviceFeature refactoring branch is reviewed. Not yet. Included in the 1.0.0 release preparation.
## Context ## Context
The current end-to-end tests run through Vitest browser mode and a mocked Obsidian environment in `test/harness`. This has been useful for exercising synchronisation flows without launching Obsidian, but it is no longer a reliable final signal for plug-in behaviour. When this decision was proposed, the end-to-end tests ran through Vitest browser mode and a mocked Obsidian environment in `test/harness`. This was useful for exercising synchronisation flows without launching Obsidian, but it was no longer a reliable final signal for plug-in behaviour.
The main issues are: The main issues are:
@@ -31,7 +31,7 @@ The long-term test pyramid should be:
2. Integration tests for CouchDB, Object Storage, P2P services, database operations, and replication protocols. 2. Integration tests for CouchDB, Object Storage, P2P services, database operations, and replication protocols.
3. Real Obsidian E2E tests for boot-up sequence, vault reflection, command registration, settings dialogues, restart scheduling, and user-visible workflows. 3. Real Obsidian E2E tests for boot-up sequence, vault reflection, command registration, settings dialogues, restart scheduling, and user-visible workflows.
The existing `test/harness` should be demoted to a transitional compatibility layer. It may remain temporarily while the real Obsidian runner reaches parity for critical flows, but new high-level E2E coverage should target the real runner. Retain the existing browser Harness only as a transitional compatibility layer while the real Obsidian runner reaches parity for critical flows, then remove it. New high-level E2E coverage targets the real runner.
## Non-Goals ## Non-Goals
@@ -155,7 +155,7 @@ Current verification:
Known limits: Known limits:
- The smoke runner currently proves only one-vault launch and plug-in load readiness. Broader workflows are covered by separate real Obsidian scripts, including CouchDB upload, startup scan, two-vault note synchronisation, Hidden File Sync, Customisation Sync, and setting Markdown export. - The smoke runner currently proves only one-vault launch and plug-in load readiness. Broader workflows are covered by separate real Obsidian scripts, including CouchDB upload, startup scan, two-vault note synchronisation, Hidden File Sync, Customisation Sync, and setting Markdown export.
- Cross-platform support is still discovery-level. The working path has been validated on Linux ARM64. macOS and Windows should be validated in their own environments as follow-up work. - The working path has been validated on Linux ARM64 and on macOS with Obsidian 1.12.7. Windows remains unverified.
- CI wiring is intentionally not implemented. The runner depends on a licensed desktop application and is treated as a local verification tool. - CI wiring is intentionally not implemented. The runner depends on a licensed desktop application and is treated as a local verification tool.
### Phase 2: First Real Workflow ### Phase 2: First Real Workflow
@@ -199,9 +199,10 @@ Current implementation status:
Current implementation status: Current implementation status:
- `test/harness` is now documented as a transitional compatibility layer. - The mocked Vitest browser suites, their P2P runner, their root-level relay helpers, and the manual `harness-ci` workflow have been removed after maintained suites covered the critical flows.
- New broad E2E work should target `test/e2e-obsidian/` when real Obsidian behaviour is the risk being tested. - CouchDB and Object Storage combinations, with and without encryption, are owned by the CLI two-Vault matrix. P2P validation is owned by the CLI Compose E2E suite. Plug-in lifecycle and vault integration are owned by real Obsidian E2E.
- The next high-value scenarios are RedFlag variants and Fast Setup (Simple Fetch) variants, not a line-by-line migration of `test/suite`. - The Obsidian compatibility implementation still needed by the Webapp has moved to `src/apps/webapp/obsidianMock.ts`; it is not a retained browser E2E Harness.
- Remaining high-value scenarios, including RedFlag and Fast Setup (Simple Fetch) variants, should be added according to their owning integration boundary rather than copied line by line from the retired suite.
## Local Verification Strategy ## Local Verification Strategy
@@ -240,12 +241,12 @@ Real Obsidian E2E is a local verification layer. It should not be wired into the
4. Add `test:e2e:obsidian:smoke` for one-vault plug-in load. 4. Add `test:e2e:obsidian:smoke` for one-vault plug-in load.
5. Document required local environment variables, especially `OBSIDIAN_BINARY`. 5. Document required local environment variables, especially `OBSIDIAN_BINARY`.
6. Port one CouchDB-backed workflow after the smoke test is stable. 6. Port one CouchDB-backed workflow after the smoke test is stable.
7. Mark `test/harness` as transitional and block new broad E2E work from targeting it. 7. Retire the browser Harness after critical workflows have replacement coverage.
8. Add the local suite script for broader local verification. 8. Add the local suite script for broader local verification.
## Consequences ## Consequences
- Real Obsidian E2E becomes the source of truth for plug-in lifecycle and vault integration. - Real Obsidian E2E becomes the source of truth for plug-in lifecycle and vault integration.
- Unit and integration tests remain the primary fast feedback loops. - Unit and integration tests remain the primary fast feedback loops.
- The old browser harness can be deleted once the new runner covers the critical workflows. - The old browser Harness has been deleted now that the replacement coverage owns its critical workflows.
- The project will gain slower but higher-confidence tests for the behaviours most likely to differ between mocks and Obsidian itself. - The project will gain slower but higher-confidence tests for the behaviours most likely to differ between mocks and Obsidian itself.
-109
View File
@@ -56,8 +56,6 @@
"@types/pouchdb-replication": "^6.4.7", "@types/pouchdb-replication": "^6.4.7",
"@types/transform-pouch": "^1.0.6", "@types/transform-pouch": "^1.0.6",
"@typescript-eslint/parser": "8.56.1", "@typescript-eslint/parser": "8.56.1",
"@vitest/browser": "^4.1.8",
"@vitest/browser-playwright": "^4.1.8",
"@vitest/coverage-v8": "^4.1.8", "@vitest/coverage-v8": "^4.1.8",
"@vrtmrz/obsidian-test-session": "0.2.2", "@vrtmrz/obsidian-test-session": "0.2.2",
"dotenv-cli": "^11.0.0", "dotenv-cli": "^11.0.0",
@@ -1261,13 +1259,6 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@blazediff/core": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz",
"integrity": "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==",
"dev": true,
"license": "MIT"
},
"node_modules/@codemirror/state": { "node_modules/@codemirror/state": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz",
@@ -2757,13 +2748,6 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
"dev": true,
"license": "MIT"
},
"node_modules/@promptbook/utils": { "node_modules/@promptbook/utils": {
"version": "0.69.5", "version": "0.69.5",
"resolved": "https://registry.npmjs.org/@promptbook/utils/-/utils-0.69.5.tgz", "resolved": "https://registry.npmjs.org/@promptbook/utils/-/utils-0.69.5.tgz",
@@ -4768,54 +4752,6 @@
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/@vitest/browser": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.8.tgz",
"integrity": "sha512-u21VzX07HzlJYpFgkxmjEXar/tG2UqWGgyGG/46SrrPc7rSdCTPw5vuowopO9CIqF8UCUQzDFdbVnNpw6N0BfQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@blazediff/core": "1.9.1",
"@vitest/mocker": "4.1.8",
"@vitest/utils": "4.1.8",
"magic-string": "^0.30.21",
"pngjs": "^7.0.0",
"sirv": "^3.0.2",
"tinyrainbow": "^3.1.0",
"ws": "^8.19.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"vitest": "4.1.8"
}
},
"node_modules/@vitest/browser-playwright": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.8.tgz",
"integrity": "sha512-SR7FqgegaexEg73xvf3ArtygXegagMdXnL0EZMpxrWvvhQxvicD/E8p0ib0J91riPRtQUViyh67Xjw3NqvyhVg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/browser": "4.1.8",
"@vitest/mocker": "4.1.8",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"playwright": "*",
"vitest": "4.1.8"
},
"peerDependenciesMeta": {
"playwright": {
"optional": false
}
}
},
"node_modules/@vitest/coverage-v8": { "node_modules/@vitest/coverage-v8": {
"version": "4.1.8", "version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz",
@@ -11480,16 +11416,6 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
"integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -12307,16 +12233,6 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/pngjs": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.19.0"
}
},
"node_modules/possible-typed-array-names": { "node_modules/possible-typed-array-names": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -13603,21 +13519,6 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/sirv": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
"integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@polka/url": "^1.0.0-next.24",
"mrmime": "^2.0.0",
"totalist": "^3.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/slash": { "node_modules/slash": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
@@ -14531,16 +14432,6 @@
"url": "https://github.com/sponsors/ota-meshi" "url": "https://github.com/sponsors/ota-meshi"
} }
}, },
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/tough-cookie": { "node_modules/tough-cookie": {
"version": "4.1.4", "version": "4.1.4",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
+3 -16
View File
@@ -24,7 +24,6 @@
"check:compatibility": "node utils/check-compatibility.js --file main.js --ios 15", "check:compatibility": "node utils/check-compatibility.js --file main.js --ios 15",
"check": "npm run tsc-check && npm run tsc-check:apps && npm run lint && npm run lint:community -- --quiet && npm run svelte-check && npm run check:compatibility", "check": "npm run tsc-check && npm run tsc-check:apps && npm run lint && npm run lint:community -- --quiet && npm run svelte-check && npm run check:compatibility",
"unittest": "deno test -A --no-check --coverage=cov_profile --v8-flags=--expose-gc --trace-leaks ./src/", "unittest": "deno test -A --no-check --coverage=cov_profile --v8-flags=--expose-gc --trace-leaks ./src/",
"test": "vitest run",
"test:unit": "vitest run --config vitest.config.unit.ts", "test:unit": "vitest run --config vitest.config.unit.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",
@@ -35,8 +34,6 @@
"test:e2e:cli:all": "npm run test:e2e:all --workspace self-hosted-livesync-cli", "test:e2e:cli:all": "npm run test:e2e:all --workspace self-hosted-livesync-cli",
"test:integration": "npx dotenv-cli -e .env -e .test.env -- vitest run --config vitest.config.integration.ts", "test:integration": "npx dotenv-cli -e .env -e .test.env -- vitest run --config vitest.config.integration.ts",
"test:unit:coverage": "vitest run --config vitest.config.unit.ts --coverage", "test:unit:coverage": "vitest run --config vitest.config.unit.ts --coverage",
"test:install-playwright": "npx playwright install chromium",
"test:install-dependencies": "npm run test:install-playwright",
"test:e2e:obsidian:install-appimage": "tsx test/e2e-obsidian/scripts/install-appimage.ts", "test:e2e:obsidian:install-appimage": "tsx test/e2e-obsidian/scripts/install-appimage.ts",
"test:e2e:obsidian:runner": "vitest run --config vitest.config.e2e-runner.ts", "test:e2e:obsidian:runner": "vitest run --config vitest.config.e2e-runner.ts",
"test:e2e:obsidian:discover": "tsx test/e2e-obsidian/scripts/discover.ts", "test:e2e:obsidian:discover": "tsx test/e2e-obsidian/scripts/discover.ts",
@@ -58,7 +55,6 @@
"test:e2e:obsidian:setting-markdown-export": "tsx test/e2e-obsidian/scripts/setting-markdown-export.ts", "test:e2e:obsidian:setting-markdown-export": "tsx test/e2e-obsidian/scripts/setting-markdown-export.ts",
"test:e2e:obsidian:local-suite": "tsx test/e2e-obsidian/scripts/local-suite.ts", "test:e2e:obsidian:local-suite": "tsx test/e2e-obsidian/scripts/local-suite.ts",
"test:e2e:obsidian:local-suite:services": "tsx test/e2e-obsidian/scripts/local-suite.ts --manage-services", "test:e2e:obsidian:local-suite:services": "tsx test/e2e-obsidian/scripts/local-suite.ts --manage-services",
"test:coverage": "vitest run --coverage",
"test:docker-couchdb:up": "npx dotenv-cli -e .env -e .test.env -- ./test/shell/couchdb-start.sh", "test:docker-couchdb:up": "npx dotenv-cli -e .env -e .test.env -- ./test/shell/couchdb-start.sh",
"test:docker-couchdb:init": "npx dotenv-cli -e .env -e .test.env -- ./test/shell/couchdb-init.sh", "test:docker-couchdb:init": "npx dotenv-cli -e .env -e .test.env -- ./test/shell/couchdb-init.sh",
"test:docker-couchdb:start": "npm run test:docker-couchdb:up && sleep 5 && npm run test:docker-couchdb:init", "test:docker-couchdb:start": "npm run test:docker-couchdb:up && sleep 5 && npm run test:docker-couchdb:init",
@@ -69,18 +65,11 @@
"test:docker-s3:start": "npm run test:docker-s3:up && sleep 3 && npm run test:docker-s3:init", "test:docker-s3:start": "npm run test:docker-s3:up && sleep 3 && npm run test:docker-s3:init",
"test:docker-s3:down": "npx dotenv-cli -e .env -e .test.env -- ./test/shell/minio-stop.sh", "test:docker-s3:down": "npx dotenv-cli -e .env -e .test.env -- ./test/shell/minio-stop.sh",
"test:docker-s3:stop": "npm run test:docker-s3:down", "test:docker-s3:stop": "npm run test:docker-s3:down",
"test:docker-p2p:up": "npx dotenv-cli -e .env -e .test.env -- ./test/shell/p2p-start.sh", "test:docker-all:up": "npm run test:docker-couchdb:up ; npm run test:docker-s3:up",
"test:docker-p2p:init": "npx dotenv-cli -e .env -e .test.env -- ./test/shell/p2p-init.sh", "test:docker-all:init": "npm run test:docker-couchdb:init ; npm run test:docker-s3:init",
"test:docker-p2p:start": "npm run test:docker-p2p:up && sleep 3 && npm run test:docker-p2p:init", "test:docker-all:down": "npm run test:docker-couchdb:down ; npm run test:docker-s3:down",
"test:docker-p2p:down": "npx dotenv-cli -e .env -e .test.env -- ./test/shell/p2p-stop.sh",
"test:docker-p2p:stop": "npm run test:docker-p2p:down",
"test:docker-all:up": "npm run test:docker-couchdb:up ; npm run test:docker-s3:up ; npm run test:docker-p2p:up",
"test:docker-all:init": "npm run test:docker-couchdb:init ; npm run test:docker-s3:init ; npm run test:docker-p2p:init",
"test:docker-all:down": "npm run test:docker-couchdb:down ; npm run test:docker-s3:down ; npm run test:docker-p2p:down",
"test:docker-all:start": "npm run test:docker-all:up && sleep 5 && npm run test:docker-all:init", "test:docker-all:start": "npm run test:docker-all:up && sleep 5 && npm run test:docker-all:init",
"test:docker-all:stop": "npm run test:docker-all:down", "test:docker-all:stop": "npm run test:docker-all:down",
"test:full": "npm run test:docker-all:start && vitest run --coverage && npm run test:docker-all:stop",
"test:p2p": "bash test/suitep2p/run-p2p-tests.sh",
"update-workspaces": "node update-workspaces.mjs", "update-workspaces": "node update-workspaces.mjs",
"version": "node version-bump.mjs && node update-workspaces.mjs && npm run pretty:json && git add manifest.json versions.json src/apps/cli/package.json src/apps/webpeer/package.json src/apps/webapp/package.json" "version": "node version-bump.mjs && node update-workspaces.mjs && npm run pretty:json && git add manifest.json versions.json src/apps/cli/package.json src/apps/webpeer/package.json src/apps/webapp/package.json"
}, },
@@ -109,8 +98,6 @@
"@types/pouchdb-replication": "^6.4.7", "@types/pouchdb-replication": "^6.4.7",
"@types/transform-pouch": "^1.0.6", "@types/transform-pouch": "^1.0.6",
"@typescript-eslint/parser": "8.56.1", "@typescript-eslint/parser": "8.56.1",
"@vitest/browser": "^4.1.8",
"@vitest/browser-playwright": "^4.1.8",
"@vitest/coverage-v8": "^4.1.8", "@vitest/coverage-v8": "^4.1.8",
"@vrtmrz/obsidian-test-session": "0.2.2", "@vrtmrz/obsidian-test-session": "0.2.2",
"dotenv-cli": "^11.0.0", "dotenv-cli": "^11.0.0",
@@ -1,12 +1,16 @@
/* eslint-disable @typescript-eslint/no-unsafe-function-type */ /**
* Legacy Obsidian API compatibility implementation used only by the Webapp build.
*
* This file moved from the retired browser test Harness to make its current ownership explicit. It is not a faithful
* Obsidian mock, and must not become a general test environment for the plug-in. When the Webapp compatibility boundary
* is redesigned, replace this implementation here rather than extending it as a shared Obsidian simulation.
*/
export const SettingCache = new Map<any, any>(); export const SettingCache = new Map<any, any>();
//@ts-ignore obsidian global //@ts-ignore obsidian global
globalThis.activeDocument = document; globalThis.activeDocument = document;
declare const hostPlatform: string | undefined; declare const hostPlatform: string | undefined;
// import { interceptFetchForLogging } from "../harness/utils/intercept";
// interceptFetchForLogging();
globalThis.process = { globalThis.process = {
platform: (hostPlatform || "win32") as any, platform: (hostPlatform || "win32") as any,
} as any; } as any;
@@ -110,7 +114,7 @@ export class Vault {
private files: Map<string, TAbstractFile> = new Map(); private files: Map<string, TAbstractFile> = new Map();
private contents: Map<string, string | ArrayBuffer> = new Map(); private contents: Map<string, string | ArrayBuffer> = new Map();
private root: TFolder; private root: TFolder;
private listeners: Map<string, Set<Function>> = new Map(); private listeners: Map<string, Set<(...args: any[]) => any>> = new Map();
constructor(vaultName?: string) { constructor(vaultName?: string) {
if (vaultName) { if (vaultName) {
@@ -392,10 +396,10 @@ class Events {
} }
class Workspace extends Events { class Workspace extends Events {
getActiveFile() { getActiveFile(): null {
return null; return null;
} }
getMostRecentLeaf() { getMostRecentLeaf(): null {
return null; return null;
} }
@@ -409,7 +413,7 @@ class Workspace extends Events {
}, 200); }, 200);
// }); // });
} }
getLeavesOfType() { getLeavesOfType(): never[] {
return []; return [];
} }
getLeaf() { getLeaf() {
@@ -432,7 +436,7 @@ export class App {
workspace: Workspace = new Workspace(); workspace: Workspace = new Workspace();
metadataCache: any = { metadataCache: any = {
on: (name: string, cb: any, ctx?: any) => {}, on: (name: string, cb: any, ctx?: any) => {},
getFileCache: () => null, getFileCache: (): null => null,
}; };
} }
@@ -894,6 +898,7 @@ export class FuzzySuggestModal<T> {
} }
export class MarkdownRenderer { export class MarkdownRenderer {
static render(app: App, md: string, el: HTMLElement, path: string, component: Component) { static render(app: App, md: string, el: HTMLElement, path: string, component: Component) {
// eslint-disable-next-line no-unsanitized/property -- This compatibility method mirrors Obsidian's trusted Markdown renderer boundary.
el.innerHTML = md; el.innerHTML = md;
return Promise.resolve(); return Promise.resolve();
} }
@@ -905,6 +910,7 @@ export class WorkspaceLeaf {}
export function sanitizeHTMLToDom(html: string) { export function sanitizeHTMLToDom(html: string) {
const div = document.createElement("div"); const div = document.createElement("div");
// eslint-disable-next-line no-unsanitized/property -- This compatibility method mirrors Obsidian's sanitised-HTML API contract.
div.innerHTML = html; div.innerHTML = html;
return div; return div;
} }
@@ -4,7 +4,7 @@ import * as ts from "typescript";
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
const dependencyFacadePath = path.resolve(repositoryRoot, "src/deps.ts"); const dependencyFacadePath = path.resolve(repositoryRoot, "src/deps.ts");
const obsidianMockPath = path.resolve(repositoryRoot, "test/harness/obsidian-mock.ts"); const obsidianMockPath = path.resolve(repositoryRoot, "src/apps/webapp/obsidianMock.ts");
function parseSourceFile(filePath: string): ts.SourceFile { function parseSourceFile(filePath: string): ts.SourceFile {
const source = ts.sys.readFile(filePath); const source = ts.sys.readFile(filePath);
+1 -1
View File
@@ -38,7 +38,7 @@ export default defineConfig({
resolve: { resolve: {
alias: { alias: {
"@": path.resolve(__dirname, "../../"), "@": path.resolve(__dirname, "../../"),
obsidian: path.resolve(__dirname, "../../../test/harness/obsidian-mock.ts"), obsidian: path.resolve(__dirname, "./obsidianMock.ts"),
}, },
}, },
base: "./", base: "./",
-148
View File
@@ -1,148 +0,0 @@
import { App } from "@/deps.ts";
import ObsidianLiveSyncPlugin from "@/main";
import { DEFAULT_SETTINGS, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { LOG_LEVEL_VERBOSE, setGlobalLogFunction } from "@vrtmrz/livesync-commonlib/compat/common/logger";
import { SettingCache } from "./obsidian-mock";
import { delay, fireAndForget, promiseWithResolvers } from "octagonal-wheels/promises";
import { EVENT_PLATFORM_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import { EVENT_LAYOUT_READY, eventHub } from "@/common/events";
import { env } from "../suite/variables";
export type LiveSyncHarness = {
app: App;
plugin: ObsidianLiveSyncPlugin;
dispose: () => Promise<void>;
disposalPromise: Promise<void>;
isDisposed: () => boolean;
};
const isLiveSyncLogEnabled = env?.PRINT_LIVESYNC_LOGS === "true";
function overrideLogFunction(vaultName: string) {
setGlobalLogFunction((msg, level, key) => {
if (!isLiveSyncLogEnabled) {
return;
}
if (level && level < LOG_LEVEL_VERBOSE) {
return;
}
if (msg instanceof Error) {
console.error(msg.stack);
} else {
console.log(
`[${vaultName}] :: [${key ?? "Global"}][${level ?? 1}]: ${msg instanceof Error ? msg.stack : msg}`
);
}
});
}
export async function generateHarness(
paramVaultName?: string,
settings?: Partial<ObsidianLiveSyncSettings>
): Promise<LiveSyncHarness> {
// return await serialized("harness-generation-lock", async () => {
// Dispose previous harness to avoid multiple harness running at the same time
// if (previousHarness && !previousHarness.isDisposed()) {
// console.log(`Previous harness detected, waiting for disposal...`);
// await previousHarness.disposalPromise;
// previousHarness = null;
// await delay(100);
// }
const vaultName = paramVaultName ?? "TestVault" + Date.now();
const setting = {
...DEFAULT_SETTINGS,
...settings,
};
overrideLogFunction(vaultName);
//@ts-ignore Mocked in harness
const app = new App(vaultName);
// setting and vault name
SettingCache.set(app, setting);
SettingCache.set(app.vault, vaultName);
//@ts-ignore
const manifest_version = `${MANIFEST_VERSION || "0.0.0-harness"}`;
overrideLogFunction(vaultName);
const manifest = {
id: "obsidian-livesync",
name: "Self-hosted LiveSync (Harnessed)",
version: manifest_version,
minAppVersion: "0.15.0",
description: "Testing",
author: "vrtmrz",
authorUrl: "",
isDesktopOnly: false,
};
const plugin = new ObsidianLiveSyncPlugin(app, manifest);
overrideLogFunction(vaultName);
// Initial load
await delay(100);
await plugin.onload();
let isDisposed = false;
const waitPromise = promiseWithResolvers<void>();
eventHub.once(EVENT_PLATFORM_UNLOADED, () => {
fireAndForget(async () => {
console.log(`Harness for vault '${vaultName}' disposed.`);
await delay(100);
eventHub.offAll();
isDisposed = true;
waitPromise.resolve();
});
});
eventHub.once(EVENT_LAYOUT_READY, () => {
plugin.app.vault.trigger("layout-ready");
});
const harness: LiveSyncHarness = {
app,
plugin,
dispose: async () => {
await plugin.onunload();
return waitPromise.promise;
},
disposalPromise: waitPromise.promise,
isDisposed: () => isDisposed,
};
await delay(100);
console.log(`Harness for vault '${vaultName}' is ready.`);
// previousHarness = harness;
return harness;
}
export async function waitForReady(harness: LiveSyncHarness): Promise<void> {
for (let i = 0; i < 10; i++) {
if (harness.plugin.core.services.appLifecycle.isReady()) {
console.log("App Lifecycle is ready");
return;
}
await delay(100);
}
throw new Error(`Initialisation Timed out!`);
}
export async function waitForIdle(harness: LiveSyncHarness): Promise<void> {
for (let i = 0; i < 20; i++) {
await delay(25);
const processing =
harness.plugin.core.services.replication.databaseQueueCount.value +
harness.plugin.core.services.fileProcessing.totalQueued.value +
harness.plugin.core.services.fileProcessing.batched.value +
harness.plugin.core.services.fileProcessing.processing.value +
harness.plugin.core.services.replication.storageApplyingCount.value;
if (processing === 0) {
if (i > 0) {
console.log(`Idle after ${i} loops`);
}
return;
}
}
}
export async function waitForClosed(harness: LiveSyncHarness): Promise<void> {
await delay(100);
for (let i = 0; i < 10; i++) {
if (harness.plugin.core.services.control.hasUnloaded()) {
console.log("App has unloaded");
return;
}
await delay(100);
}
}
-51
View File
@@ -1,51 +0,0 @@
export function interceptFetchForLogging() {
const originalFetch = globalThis.fetch;
globalThis.fetch = async (...params: any[]) => {
const paramObj = params[0];
const initObj = params[1];
const url = typeof paramObj === "string" ? paramObj : paramObj.url;
const method = initObj?.method || "GET";
const headers = initObj?.headers || {};
const body = initObj?.body || null;
const headersObj: Record<string, string> = {};
if (headers instanceof Headers) {
headers.forEach((value, key) => {
headersObj[key] = value;
});
}
console.dir({
mockedFetch: {
url,
method,
headers: headersObj,
},
});
try {
const res = await originalFetch.apply(globalThis, params as any);
console.log(`[Obsidian Mock] Fetch response: ${res.status} ${res.statusText} for ${method} ${url}`);
const resClone = res.clone();
const contentType = resClone.headers.get("content-type") || "";
const isJson = contentType.includes("application/json");
if (isJson) {
const data = await resClone.json();
console.dir({ mockedFetchResponseJson: data });
} else {
const ab = await resClone.arrayBuffer();
const text = new TextDecoder().decode(ab);
const isText = /^text\//.test(contentType);
if (isText) {
console.dir({
mockedFetchResponseText: ab.byteLength < 1000 ? text : text.slice(0, 1000) + "...(truncated)",
});
} else {
console.log(`[Obsidian Mock] Fetch response is of content-type ${contentType}, not logging body.`);
}
}
return res;
} catch (e) {
// console.error("[Obsidian Mock] Fetch error:", e);
console.error(`[Obsidian Mock] Fetch failed for ${method} ${url}, error:`, e);
throw e;
}
};
}
-165
View File
@@ -1,165 +0,0 @@
import type { P2PSyncSetting } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { delay } from "octagonal-wheels/promises";
import type { BrowserContext, Page } from "playwright";
import type { Plugin } from "vitest/config";
import type { BrowserCommand } from "vitest/node";
import { serialized } from "octagonal-wheels/concurrency/lock";
export const grantClipboardPermissions: BrowserCommand = async (ctx) => {
if (ctx.provider.name === "playwright") {
await ctx.context.grantPermissions(["clipboard-read", "clipboard-write"]);
console.log("Granted clipboard permissions");
return;
}
};
let peerPage: Page | undefined;
let peerPageContext: BrowserContext | undefined;
let previousName = "";
async function setValue(page: Page, selector: string, value: string) {
const e = await page.waitForSelector(selector);
await e.fill(value);
}
async function closePeerContexts() {
const peerPageLocal = peerPage;
const peerPageContextLocal = peerPageContext;
if (peerPageLocal) {
await peerPageLocal.close();
}
if (peerPageContextLocal) {
await peerPageContextLocal.close();
}
}
export const openWebPeer: BrowserCommand<[P2PSyncSetting, serverPeerName: string]> = async (
ctx,
setting: P2PSyncSetting,
serverPeerName: string = "p2p-livesync-web-peer"
) => {
if (ctx.provider.name === "playwright") {
const previousPage = ctx.page;
if (peerPage !== undefined) {
if (previousName === serverPeerName) {
console.log(`WebPeer for ${serverPeerName} already opened`);
return;
}
console.log(`Closing previous WebPeer for ${previousName}`);
await closePeerContexts();
}
console.log(`Opening webPeer`);
return serialized("webpeer", async () => {
const browser = ctx.context.browser()!;
const context = await browser.newContext();
peerPageContext = context;
peerPage = await context.newPage();
previousName = serverPeerName;
console.log(`Navigating...`);
await peerPage.goto("http://localhost:8081");
await peerPage.waitForLoadState();
console.log(`Navigated!`);
await setValue(peerPage, "#app > main [placeholder*=wss]", setting.P2P_relays);
await setValue(peerPage, "#app > main [placeholder*=anything]", setting.P2P_roomID);
await setValue(peerPage, "#app > main [placeholder*=password]", setting.P2P_passphrase);
await setValue(peerPage, "#app > main [placeholder*=iphone]", serverPeerName);
// await peerPage.getByTitle("Enable P2P Replicator").setChecked(true);
await peerPage.getByRole("checkbox").first().setChecked(true);
// (await peerPage.waitForSelector("Save and Apply")).click();
await peerPage.getByText("Save and Apply").click();
await delay(100);
await peerPage.reload();
await delay(500);
for (let i = 0; i < 10; i++) {
await delay(100);
const btn = peerPage.getByRole("button").filter({ hasText: /^connect/i });
if ((await peerPage.getByText(/disconnect/i).count()) > 0) {
break;
}
await btn.click();
}
await previousPage.bringToFront();
ctx.context.on("close", async () => {
console.log("Browser context is closing, closing peer page if exists");
await closePeerContexts();
});
console.log("Web peer page opened");
});
}
};
export const closeWebPeer: BrowserCommand = async (ctx) => {
if (ctx.provider.name === "playwright") {
return serialized("webpeer", async () => {
await closePeerContexts();
peerPage = undefined;
peerPageContext = undefined;
previousName = "";
console.log("Web peer page closed");
});
}
};
export const acceptWebPeer: BrowserCommand = async (ctx) => {
if (peerPage) {
// Detect dialogue
const buttonsOnDialogs = await peerPage.$$("popup .buttons button");
for (const b of buttonsOnDialogs) {
const text = (await b.innerText()).toLowerCase();
// console.log(`Dialog button found: ${text}`);
if (text === "accept") {
console.log("Accepting dialog");
await b.click({ timeout: 300 });
await delay(500);
}
}
const buttons = peerPage.getByRole("button").filter({ hasText: /^accept$/i });
const a = await buttons.all();
for (const b of a) {
await b.click({ timeout: 300 });
}
}
return false;
};
/** Write arbitrary text to a file on the Node.js host (used for phase handoff). */
export const writeHandoffFile: BrowserCommand<[filePath: string, content: string]> = async (
_ctx,
filePath: string,
content: string
) => {
const fs = await import("node:fs/promises");
await fs.writeFile(filePath, content, "utf-8");
};
/** Read a file from the Node.js host (used for phase handoff). */
export const readHandoffFile: BrowserCommand<[filePath: string]> = async (_ctx, filePath: string): Promise<string> => {
const fs = await import("node:fs/promises");
return fs.readFile(filePath, "utf-8");
};
export default function BrowserCommands(): Plugin {
return {
name: "vitest:custom-commands",
config() {
return {
test: {
browser: {
commands: {
grantClipboardPermissions,
openWebPeer,
closeWebPeer,
acceptWebPeer,
writeHandoffFile,
readHandoffFile,
},
},
},
};
},
};
}
declare module "vitest/browser" {
interface BrowserCommands {
grantClipboardPermissions: () => Promise<void>;
openWebPeer: (setting: P2PSyncSetting, serverPeerName: string) => Promise<void>;
closeWebPeer: () => Promise<void>;
acceptWebPeer: () => Promise<boolean>;
writeHandoffFile: (filePath: string, content: string) => Promise<void>;
readHandoffFile: (filePath: string) => Promise<string>;
}
}
-70
View File
@@ -1,70 +0,0 @@
import { page } from "vitest/browser";
import { delay } from "@vrtmrz/livesync-commonlib/compat/common/utils";
export async function waitForDialogShown(dialogText: string, timeout = 500) {
const ttl = Date.now() + timeout;
while (Date.now() < ttl) {
try {
await delay(50);
const dialog = page
.getByText(dialogText)
.elements()
.filter((e) => e.classList.contains("modal-title"))
.filter((e) => e.checkVisibility());
if (dialog.length === 0) {
continue;
}
return true;
} catch (e) {
// Ignore
}
}
return false;
}
export async function waitForDialogHidden(dialogText: string | RegExp, timeout = 500) {
const ttl = Date.now() + timeout;
while (Date.now() < ttl) {
try {
await delay(50);
const dialog = page
.getByText(dialogText)
.elements()
.filter((e) => e.classList.contains("modal-title"))
.filter((e) => e.checkVisibility());
if (dialog.length > 0) {
// console.log(`Still exist ${dialogText.toString()}`);
continue;
}
return true;
} catch (e) {
// Ignore
}
}
return false;
}
export async function waitForButtonClick(buttonText: string | RegExp, timeout = 500) {
const ttl = Date.now() + timeout;
while (Date.now() < ttl) {
try {
await delay(100);
const buttons = page
.getByText(buttonText)
.elements()
.filter((e) => e.checkVisibility() && e.tagName.toLowerCase() == "button");
if (buttons.length == 0) {
// console.log(`Could not found ${buttonText.toString()}`);
continue;
}
console.log(`Button detected: ${buttonText.toString()}`);
// console.dir(buttons[0])
await page.elementLocator(buttons[0]).click();
await delay(100);
return true;
} catch (e) {
console.error(e);
// Ignore
}
}
return false;
}
-21
View File
@@ -1,21 +0,0 @@
import { delay } from "@vrtmrz/livesync-commonlib/compat/common/utils";
export async function waitTaskWithFollowups<T>(
task: Promise<T>,
followup: () => Promise<void>,
timeout: number = 10000,
interval: number = 1000
): Promise<T> {
const symbolNotCompleted = Symbol("notCompleted");
const isCompleted = () => Promise.race([task, Promise.resolve(symbolNotCompleted)]);
const ttl = Date.now() + timeout;
do {
const state = await isCompleted();
if (state !== symbolNotCompleted) {
return state;
}
await followup();
await delay(interval);
} while (Date.now() < ttl);
throw new Error("Task did not complete in time");
}
-2
View File
@@ -1,2 +0,0 @@
#!/bin/bash
echo "P2P Init - No additional initialization required."
-33
View File
@@ -1,33 +0,0 @@
#!/bin/bash
set -e
script_dir=$(dirname "$0")
webpeer_dir=$script_dir/../../src/apps/webpeer
docker run -d --name relay-test -p 4000:7777 \
--tmpfs /app/strfry-db:rw,size=256m \
--entrypoint sh \
ghcr.io/hoytech/strfry:latest \
-lc 'cat > /tmp/strfry.conf <<"EOF"
db = "./strfry-db/"
relay {
bind = "0.0.0.0"
port = 7777
nofiles = 100000
info {
name = "livesync test relay"
description = "local relay for livesync p2p tests"
}
maxWebsocketPayloadSize = 131072
autoPingSeconds = 55
writePolicy {
plugin = ""
}
}
EOF
exec /app/strfry --config /tmp/strfry.conf relay'
npm run --prefix $webpeer_dir build
docker run -d --name webpeer-test -p 8081:8043 -v $webpeer_dir/dist:/srv/http pierrezemb/gostatic
-5
View File
@@ -1,5 +0,0 @@
#!/bin/bash
docker stop relay-test
docker rm relay-test
docker stop webpeer-test
docker rm webpeer-test
-129
View File
@@ -1,129 +0,0 @@
import { compareMTime, EVEN } from "@/common/utils";
import { TFile, type DataWriteOptions } from "@/deps";
import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { isDocContentSame, readContent } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { waitForIdle, type LiveSyncHarness } from "../harness/harness";
import { expect } from "vitest";
export const defaultFileOption = {
mtime: new Date(2026, 0, 1, 0, 1, 2, 3).getTime(),
} as const satisfies DataWriteOptions;
export async function storeFile(
harness: LiveSyncHarness,
path: string,
content: string | Blob,
deleteBeforeSend = false,
fileOptions = defaultFileOption
) {
if (deleteBeforeSend && harness.app.vault.getAbstractFileByPath(path)) {
console.log(`Deleting existing file ${path}`);
await harness.app.vault.delete(harness.app.vault.getAbstractFileByPath(path) as TFile);
}
// Create file via vault
if (content instanceof Blob) {
console.log(`Creating binary file ${path}`);
await harness.app.vault.createBinary(path, await content.arrayBuffer(), fileOptions);
} else {
await harness.app.vault.create(path, content, fileOptions);
}
// Ensure file is created
const file = harness.app.vault.getAbstractFileByPath(path);
expect(file).toBeInstanceOf(TFile);
if (file instanceof TFile) {
expect(compareMTime(file.stat.mtime, fileOptions?.mtime ?? defaultFileOption.mtime)).toBe(EVEN);
if (content instanceof Blob) {
const readContent = await harness.app.vault.readBinary(file);
expect(await isDocContentSame(readContent, content)).toBe(true);
} else {
const readContent = await harness.app.vault.read(file);
expect(readContent).toBe(content);
}
}
await harness.plugin.core.services.fileProcessing.commitPendingFileEvents();
await waitForIdle(harness);
return file;
}
export async function readFromLocalDB(harness: LiveSyncHarness, path: string) {
const entry = await harness.plugin.core.localDatabase.getDBEntry(path as FilePath);
expect(entry).not.toBe(false);
return entry;
}
export async function readFromVault(
harness: LiveSyncHarness,
path: string,
isBinary: boolean = false,
fileOptions = defaultFileOption
): Promise<string | ArrayBuffer> {
const file = harness.app.vault.getAbstractFileByPath(path);
expect(file).toBeInstanceOf(TFile);
if (file instanceof TFile) {
// console.log(`MTime: ${file.stat.mtime}, Expected: ${fileOptions.mtime}`);
if (fileOptions.mtime !== undefined) {
expect(compareMTime(file.stat.mtime, fileOptions.mtime)).toBe(EVEN);
}
const content = isBinary ? await harness.app.vault.readBinary(file) : await harness.app.vault.read(file);
return content;
}
throw new Error("File not found in vault");
}
export async function checkStoredFileInDB(
harness: LiveSyncHarness,
path: string,
content: string | Blob,
fileOptions = defaultFileOption
) {
const entry = await readFromLocalDB(harness, path);
if (entry === false) {
throw new Error("DB Content not found");
}
const contentToCheck = content instanceof Blob ? await content.arrayBuffer() : content;
const isDocSame = await isDocContentSame(readContent(entry), contentToCheck);
if (fileOptions.mtime !== undefined) {
expect(compareMTime(entry.mtime, fileOptions.mtime)).toBe(EVEN);
}
expect(isDocSame).toBe(true);
return Promise.resolve();
}
export async function testFileWrite(
harness: LiveSyncHarness,
path: string,
content: string | Blob,
skipCheckToBeWritten = false,
fileOptions = defaultFileOption
) {
const file = await storeFile(harness, path, content, false, fileOptions);
expect(file).toBeInstanceOf(TFile);
await harness.plugin.core.services.fileProcessing.commitPendingFileEvents();
await waitForIdle(harness);
const vaultFile = await readFromVault(harness, path, content instanceof Blob, fileOptions);
expect(await isDocContentSame(vaultFile, content)).toBe(true);
await harness.plugin.core.services.fileProcessing.commitPendingFileEvents();
await waitForIdle(harness);
if (skipCheckToBeWritten) {
return Promise.resolve();
}
await checkStoredFileInDB(harness, path, content);
return Promise.resolve();
}
export async function testFileRead(
harness: LiveSyncHarness,
path: string,
expectedContent: string | Blob,
fileOptions = defaultFileOption
) {
await waitForIdle(harness);
const file = await readFromVault(harness, path, expectedContent instanceof Blob, fileOptions);
const isDocSame = await isDocContentSame(file, expectedContent);
expect(isDocSame).toBe(true);
// Check local database entry
const entry = await readFromLocalDB(harness, path);
expect(entry).not.toBe(false);
if (entry === false) {
throw new Error("DB Content not found");
}
const isDBDocSame = await isDocContentSame(readContent(entry), expectedContent);
expect(isDBDocSame).toBe(true);
return await Promise.resolve();
}
-125
View File
@@ -1,125 +0,0 @@
import { beforeAll, describe, expect, it, test } from "vitest";
import { generateHarness, waitForIdle, waitForReady, type LiveSyncHarness } from "../harness/harness";
import { TFile } from "@/deps.ts";
import { DEFAULT_SETTINGS, type FilePath, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { isDocContentSame, readContent } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { DummyFileSourceInisialised, generateBinaryFile, generateFile, init } from "../utils/dummyfile";
const localdb_test_setting = {
...DEFAULT_SETTINGS,
isConfigured: true,
handleFilenameCaseSensitive: false,
} as ObsidianLiveSyncSettings;
describe.skip("Plugin Integration Test (Local Database)", async () => {
let harness: LiveSyncHarness;
const vaultName = "TestVault" + Date.now();
beforeAll(async () => {
await DummyFileSourceInisialised;
harness = await generateHarness(vaultName, localdb_test_setting);
await waitForReady(harness);
});
it("should be instantiated and defined", async () => {
expect(harness.plugin).toBeDefined();
expect(harness.plugin.app).toBe(harness.app);
return await Promise.resolve();
});
it("should have services initialized", async () => {
expect(harness.plugin.core.services).toBeDefined();
return await Promise.resolve();
});
it("should have local database initialized", async () => {
expect(harness.plugin.core.localDatabase).toBeDefined();
expect(harness.plugin.core.localDatabase.isReady).toBe(true);
return await Promise.resolve();
});
it("should store the changes into the local database", async () => {
const path = "test-store6.md";
const content = "Hello, World!";
if (harness.app.vault.getAbstractFileByPath(path)) {
console.log(`Deleting existing file ${path}`);
await harness.app.vault.delete(harness.app.vault.getAbstractFileByPath(path) as TFile);
}
// Create file via vault
await harness.app.vault.create(path, content);
const file = harness.app.vault.getAbstractFileByPath(path);
expect(file).toBeInstanceOf(TFile);
if (file instanceof TFile) {
const readContent = await harness.app.vault.read(file);
expect(readContent).toBe(content);
}
await harness.plugin.core.services.fileProcessing.commitPendingFileEvents();
await waitForIdle(harness);
// await delay(100); // Wait a bit for the local database to process
const entry = await harness.plugin.core.localDatabase.getDBEntry(path as FilePath);
expect(entry).not.toBe(false);
if (entry) {
expect(readContent(entry)).toBe(content);
}
return await Promise.resolve();
});
test.each([10, 100, 1000, 10000, 50000, 100000])("should handle large file of size %i bytes", async (size) => {
const path = `test-large-file-${size}.md`;
const content = Array.from(generateFile(size)).join("");
if (harness.app.vault.getAbstractFileByPath(path)) {
console.log(`Deleting existing file ${path}`);
await harness.app.vault.delete(harness.app.vault.getAbstractFileByPath(path) as TFile);
}
// Create file via vault
await harness.app.vault.create(path, content);
const file = harness.app.vault.getAbstractFileByPath(path);
expect(file).toBeInstanceOf(TFile);
if (file instanceof TFile) {
const readContent = await harness.app.vault.read(file);
expect(readContent).toBe(content);
}
await harness.plugin.core.services.fileProcessing.commitPendingFileEvents();
await waitForIdle(harness);
const entry = await harness.plugin.core.localDatabase.getDBEntry(path as FilePath);
expect(entry).not.toBe(false);
if (entry) {
expect(readContent(entry)).toBe(content);
}
return await Promise.resolve();
});
const binaryMap = Array.from({ length: 7 }, (_, i) => Math.pow(2, i * 4));
test.each(binaryMap)("should handle binary file of size %i bytes", async (size) => {
const path = `test-binary-file-${size}.bin`;
const content = new Blob([...generateBinaryFile(size)], { type: "application/octet-stream" });
if (harness.app.vault.getAbstractFileByPath(path)) {
console.log(`Deleting existing file ${path}`);
await harness.app.vault.delete(harness.app.vault.getAbstractFileByPath(path) as TFile);
}
// Create file via vault
await harness.app.vault.createBinary(path, await content.arrayBuffer());
const file = harness.app.vault.getAbstractFileByPath(path);
expect(file).toBeInstanceOf(TFile);
if (file instanceof TFile) {
const readContent = await harness.app.vault.readBinary(file);
expect(await isDocContentSame(readContent, content)).toBe(true);
}
await harness.plugin.core.services.fileProcessing.commitPendingFileEvents();
await waitForIdle(harness);
const entry = await harness.plugin.core.localDatabase.getDBEntry(path as FilePath);
expect(entry).not.toBe(false);
if (entry) {
const entryContent = await readContent(entry);
if (!(entryContent instanceof ArrayBuffer)) {
throw new Error("Entry content is not an ArrayBuffer");
}
// const expectedContent = await content.arrayBuffer();
expect(await isDocContentSame(entryContent, content)).toBe(true);
}
return await Promise.resolve();
});
});
-275
View File
@@ -1,275 +0,0 @@
// Functional Test on Main Cases
// This test suite only covers main functional cases of synchronisation. Event handling, error cases,
// and edge, resolving conflicts, etc. will be covered in separate test suites.
import { afterAll, beforeAll, describe, expect, it, test } from "vitest";
import { generateHarness, waitForIdle, waitForReady, type LiveSyncHarness } from "../harness/harness";
import { RemoteTypes, type FilePath, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
DummyFileSourceInisialised,
FILE_SIZE_BINS,
FILE_SIZE_MD,
generateBinaryFile,
generateFile,
} from "../utils/dummyfile";
import { checkStoredFileInDB, testFileRead, testFileWrite } from "./db_common";
import { delay } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { commands } from "vitest/browser";
import { closeReplication, performReplication, prepareRemote } from "./sync_common";
import type { DataWriteOptions } from "@/deps.ts";
type MTimedDataWriteOptions = DataWriteOptions & { mtime: number };
export type TestOptions = {
setting: ObsidianLiveSyncSettings;
fileOptions: MTimedDataWriteOptions;
};
function generateName(prefix: string, type: string, ext: string, size: number) {
return `${prefix}-${type}-file-${size}.${ext}`;
}
export function syncBasicCase(label: string, { setting, fileOptions }: TestOptions) {
describe("Replication Suite Tests - " + label, () => {
const nameFile = (type: string, ext: string, size: number) => generateName("sync-test", type, ext, size);
let serverPeerName = "";
// TODO: Harness disposal may broke the event loop of P2P replication
// so we keep the harnesses alive until all tests are done.
// It may trystero's somethong, or not.
let harnessUpload: LiveSyncHarness;
let harnessDownload: LiveSyncHarness;
beforeAll(async () => {
await DummyFileSourceInisialised;
if (setting.remoteType === RemoteTypes.REMOTE_P2P) {
// await commands.closeWebPeer();
serverPeerName = "t-" + Date.now();
setting.P2P_AutoAcceptingPeers = serverPeerName;
setting.P2P_AutoSyncPeers = serverPeerName;
setting.P2P_DevicePeerName = "client-" + Date.now();
await commands.openWebPeer(setting, serverPeerName);
}
});
afterAll(async () => {
if (setting.remoteType === RemoteTypes.REMOTE_P2P) {
await commands.closeWebPeer();
// await closeP2PReplicatorConnections(harnessUpload);
}
});
describe("Remote Database Initialization", () => {
let harnessInit: LiveSyncHarness;
const sync_test_setting_init = {
...setting,
} as ObsidianLiveSyncSettings;
beforeAll(async () => {
const vaultName = "TestVault" + Date.now();
console.log(`BeforeAll - Remote Database Initialization - Vault: ${vaultName}`);
harnessInit = await generateHarness(vaultName, sync_test_setting_init);
await waitForReady(harnessInit);
expect(harnessInit.plugin).toBeDefined();
expect(harnessInit.plugin.app).toBe(harnessInit.app);
await waitForIdle(harnessInit);
});
afterAll(async () => {
await harnessInit.plugin.core.services.replicator.getActiveReplicator()?.closeReplication();
await harnessInit.dispose();
await delay(1000);
});
it("should reset remote database", async () => {
// harnessInit = await generateHarness(vaultName, sync_test_setting_init);
await waitForReady(harnessInit);
await prepareRemote(harnessInit, sync_test_setting_init, true);
});
it("should be prepared for replication", async () => {
await waitForReady(harnessInit);
if (setting.remoteType !== RemoteTypes.REMOTE_P2P) {
const status = await harnessInit.plugin.core.services.replicator
.getActiveReplicator()
?.getRemoteStatus(sync_test_setting_init);
console.log("Connected devices after reset:", status);
expect(status).not.toBeFalsy();
}
});
});
describe("Replication - Upload", () => {
const sync_test_setting_upload = {
...setting,
} as ObsidianLiveSyncSettings;
beforeAll(async () => {
const vaultName = "TestVault" + Date.now();
console.log(`BeforeAll - Replication Upload - Vault: ${vaultName}`);
if (setting.remoteType === RemoteTypes.REMOTE_P2P) {
sync_test_setting_upload.P2P_AutoAcceptingPeers = serverPeerName;
sync_test_setting_upload.P2P_AutoSyncPeers = serverPeerName;
sync_test_setting_upload.P2P_DevicePeerName = "up-" + Date.now();
}
harnessUpload = await generateHarness(vaultName, sync_test_setting_upload);
await waitForReady(harnessUpload);
expect(harnessUpload.plugin).toBeDefined();
expect(harnessUpload.plugin.app).toBe(harnessUpload.app);
await waitForIdle(harnessUpload);
});
afterAll(async () => {
await closeReplication(harnessUpload);
});
it("should be instantiated and defined", () => {
expect(harnessUpload.plugin).toBeDefined();
expect(harnessUpload.plugin.app).toBe(harnessUpload.app);
});
it("should have services initialized", () => {
expect(harnessUpload.plugin.core.services).toBeDefined();
});
it("should have local database initialized", () => {
expect(harnessUpload.plugin.core.localDatabase).toBeDefined();
expect(harnessUpload.plugin.core.localDatabase.isReady).toBe(true);
});
it("should prepare remote database", async () => {
await prepareRemote(harnessUpload, sync_test_setting_upload, false);
});
// describe("File Creation", async () => {
it("should a file has been created", async () => {
const content = "Hello, World!";
const path = nameFile("store", "md", 0);
await testFileWrite(harnessUpload, path, content, false, fileOptions);
// Perform replication
// await harness.plugin.core.services.replication.replicate(true);
});
it("should different content of several files have been created correctly", async () => {
await testFileWrite(harnessUpload, nameFile("test-diff-1", "md", 0), "Content A", false, fileOptions);
await testFileWrite(harnessUpload, nameFile("test-diff-2", "md", 0), "Content B", false, fileOptions);
await testFileWrite(harnessUpload, nameFile("test-diff-3", "md", 0), "Content C", false, fileOptions);
});
test.each(FILE_SIZE_MD)("should large file of size %i bytes has been created", async (size) => {
const content = Array.from(generateFile(size)).join("");
const path = nameFile("large", "md", size);
const isTooLarge = harnessUpload.plugin.core.services.vault.isFileSizeTooLarge(size);
if (isTooLarge) {
console.log(`Skipping file of size ${size} bytes as it is too large to sync.`);
expect(true).toBe(true);
} else {
await testFileWrite(harnessUpload, path, content, false, fileOptions);
}
});
test.each(FILE_SIZE_BINS)("should binary file of size %i bytes has been created", async (size) => {
const content = new Blob([...generateBinaryFile(size)], { type: "application/octet-stream" });
const path = nameFile("binary", "bin", size);
await testFileWrite(harnessUpload, path, content, true, fileOptions);
const isTooLarge = harnessUpload.plugin.core.services.vault.isFileSizeTooLarge(size);
if (isTooLarge) {
console.log(`Skipping file of size ${size} bytes as it is too large to sync.`);
expect(true).toBe(true);
} else {
await checkStoredFileInDB(harnessUpload, path, content, fileOptions);
}
});
it("Replication after uploads", async () => {
await performReplication(harnessUpload);
await performReplication(harnessUpload);
});
});
describe("Replication - Download", () => {
// Download into a new vault
const sync_test_setting_download = {
...setting,
} as ObsidianLiveSyncSettings;
beforeAll(async () => {
const vaultName = "TestVault" + Date.now();
console.log(`BeforeAll - Replication Download - Vault: ${vaultName}`);
if (setting.remoteType === RemoteTypes.REMOTE_P2P) {
sync_test_setting_download.P2P_AutoAcceptingPeers = serverPeerName;
sync_test_setting_download.P2P_AutoSyncPeers = serverPeerName;
sync_test_setting_download.P2P_DevicePeerName = "down-" + Date.now();
}
harnessDownload = await generateHarness(vaultName, sync_test_setting_download);
await waitForReady(harnessDownload);
await prepareRemote(harnessDownload, sync_test_setting_download, false);
await performReplication(harnessDownload);
await waitForIdle(harnessDownload);
await delay(1000);
await performReplication(harnessDownload);
await waitForIdle(harnessDownload);
});
afterAll(async () => {
await closeReplication(harnessDownload);
});
it("should be instantiated and defined", () => {
expect(harnessDownload.plugin).toBeDefined();
expect(harnessDownload.plugin.app).toBe(harnessDownload.app);
});
it("should have services initialized", () => {
expect(harnessDownload.plugin.core.services).toBeDefined();
});
it("should have local database initialized", () => {
expect(harnessDownload.plugin.core.localDatabase).toBeDefined();
expect(harnessDownload.plugin.core.localDatabase.isReady).toBe(true);
});
it("should a file has been synchronised", async () => {
const expectedContent = "Hello, World!";
const path = nameFile("store", "md", 0);
await testFileRead(harnessDownload, path, expectedContent, fileOptions);
});
it("should different content of several files have been synchronised", async () => {
await testFileRead(harnessDownload, nameFile("test-diff-1", "md", 0), "Content A", fileOptions);
await testFileRead(harnessDownload, nameFile("test-diff-2", "md", 0), "Content B", fileOptions);
await testFileRead(harnessDownload, nameFile("test-diff-3", "md", 0), "Content C", fileOptions);
});
test.each(FILE_SIZE_MD)("should the file %i bytes had been synchronised", async (size) => {
const content = Array.from(generateFile(size)).join("");
const path = nameFile("large", "md", size);
const isTooLarge = harnessDownload.plugin.core.services.vault.isFileSizeTooLarge(size);
if (isTooLarge) {
const entry = await harnessDownload.plugin.core.localDatabase.getDBEntry(path as FilePath);
console.log(`Skipping file of size ${size} bytes as it is too large to sync.`);
expect(entry).toBe(false);
} else {
await testFileRead(harnessDownload, path, content, fileOptions);
}
});
test.each(FILE_SIZE_BINS)("should binary file of size %i bytes had been synchronised", async (size) => {
const path = nameFile("binary", "bin", size);
const isTooLarge = harnessDownload.plugin.core.services.vault.isFileSizeTooLarge(size);
if (isTooLarge) {
const entry = await harnessDownload.plugin.core.localDatabase.getDBEntry(path as FilePath);
console.log(`Skipping file of size ${size} bytes as it is too large to sync.`);
expect(entry).toBe(false);
} else {
const content = new Blob([...generateBinaryFile(size)], { type: "application/octet-stream" });
await testFileRead(harnessDownload, path, content, fileOptions);
}
});
});
afterAll(async () => {
if (harnessDownload) {
await closeReplication(harnessDownload);
await harnessDownload.dispose();
await delay(1000);
}
if (harnessUpload) {
await closeReplication(harnessUpload);
await harnessUpload.dispose();
await delay(1000);
}
});
it("Wait for idle state", async () => {
await delay(100);
});
});
}
-50
View File
@@ -1,50 +0,0 @@
// Functional Test on Main Cases
// This test suite only covers main functional cases of synchronisation. Event handling, error cases,
// and edge, resolving conflicts, etc. will be covered in separate test suites.
import { describe } from "vitest";
import {
PREFERRED_JOURNAL_SYNC,
PREFERRED_SETTING_SELF_HOSTED,
RemoteTypes,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { defaultFileOption } from "./db_common";
import { syncBasicCase } from "./sync.senario.basic.ts";
import { settingBase } from "./variables.ts";
const sync_test_setting_base = settingBase;
export const env = (import.meta as any).env;
function* generateCase() {
const passpharse = "thetest-Passphrase3+9-for-e2ee!";
const REMOTE_RECOMMENDED = {
[RemoteTypes.REMOTE_COUCHDB]: PREFERRED_SETTING_SELF_HOSTED,
[RemoteTypes.REMOTE_MINIO]: PREFERRED_JOURNAL_SYNC,
[RemoteTypes.REMOTE_P2P]: PREFERRED_SETTING_SELF_HOSTED,
};
const remoteTypes = [RemoteTypes.REMOTE_COUCHDB];
// const remoteTypes = [RemoteTypes.REMOTE_P2P];
const e2eeOptions = [false];
// const e2eeOptions = [true];
for (const remoteType of remoteTypes) {
for (const useE2EE of e2eeOptions) {
yield {
setting: {
...sync_test_setting_base,
...REMOTE_RECOMMENDED[remoteType],
remoteType,
encrypt: useE2EE,
passphrase: useE2EE ? passpharse : "",
usePathObfuscation: useE2EE,
} as ObsidianLiveSyncSettings,
};
}
}
}
describe.skip("Replication Suite Tests (Single)", async () => {
const cases = Array.from(generateCase());
const fileOptions = defaultFileOption;
describe.each(cases)("Replication Tests - Remote: $setting.remoteType, E2EE: $setting.encrypt", ({ setting }) => {
syncBasicCase(`Remote: ${setting.remoteType}, E2EE: ${setting.encrypt}`, { setting, fileOptions });
});
});
-50
View File
@@ -1,50 +0,0 @@
// Functional Test on Main Cases
// This test suite only covers main functional cases of synchronisation. Event handling, error cases,
// and edge, resolving conflicts, etc. will be covered in separate test suites.
import { describe } from "vitest";
import {
PREFERRED_JOURNAL_SYNC,
PREFERRED_SETTING_SELF_HOSTED,
RemoteTypes,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { defaultFileOption } from "./db_common";
import { syncBasicCase } from "./sync.senario.basic.ts";
import { settingBase } from "./variables.ts";
const sync_test_setting_base = settingBase;
export const env = (import.meta as any).env;
function* generateCase() {
const passpharse = "thetest-Passphrase3+9-for-e2ee!";
const REMOTE_RECOMMENDED = {
[RemoteTypes.REMOTE_COUCHDB]: PREFERRED_SETTING_SELF_HOSTED,
[RemoteTypes.REMOTE_MINIO]: PREFERRED_JOURNAL_SYNC,
[RemoteTypes.REMOTE_P2P]: PREFERRED_SETTING_SELF_HOSTED,
};
const remoteTypes = [RemoteTypes.REMOTE_COUCHDB, RemoteTypes.REMOTE_MINIO];
// const remoteTypes = [RemoteTypes.REMOTE_P2P];
const e2eeOptions = [false, true];
// const e2eeOptions = [true];
for (const remoteType of remoteTypes) {
for (const useE2EE of e2eeOptions) {
yield {
setting: {
...sync_test_setting_base,
...REMOTE_RECOMMENDED[remoteType],
remoteType,
encrypt: useE2EE,
passphrase: useE2EE ? passpharse : "",
usePathObfuscation: useE2EE,
} as ObsidianLiveSyncSettings,
};
}
}
}
describe("Replication Suite Tests (Normal)", async () => {
const cases = Array.from(generateCase());
const fileOptions = defaultFileOption;
describe.each(cases)("Replication Tests - Remote: $setting.remoteType, E2EE: $setting.encrypt", ({ setting }) => {
syncBasicCase(`Remote: ${setting.remoteType}, E2EE: ${setting.encrypt}`, { setting, fileOptions });
});
});
-115
View File
@@ -1,115 +0,0 @@
import { expect } from "vitest";
import { waitForIdle, type LiveSyncHarness } from "../harness/harness";
import { RemoteTypes, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { delay, fireAndForget } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { commands } from "vitest/browser";
import { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator";
import { waitTaskWithFollowups } from "../lib/util";
async function waitForP2PPeers(harness: LiveSyncHarness) {
if (harness.plugin.core.settings.remoteType === RemoteTypes.REMOTE_P2P) {
// Wait for peers to connect
const maxRetries = 20;
let retries = maxRetries;
const replicator = await harness.plugin.core.services.replicator.getActiveReplicator();
if (!(replicator instanceof LiveSyncTrysteroReplicator)) {
throw new Error("Replicator is not an instance of LiveSyncTrysteroReplicator");
}
while (retries-- > 0) {
fireAndForget(() => commands.acceptWebPeer());
await delay(1000);
const peers = replicator.knownAdvertisements;
if (peers && peers.length > 0) {
console.log("P2P peers connected:", peers);
return;
}
fireAndForget(() => commands.acceptWebPeer());
console.log(`Waiting for any P2P peers to be connected... ${maxRetries - retries}/${maxRetries}`);
console.dir(peers);
await delay(1000);
}
console.log("Failed to connect P2P peers after retries");
throw new Error("P2P peers did not connect in time.");
}
}
export async function closeP2PReplicatorConnections(harness: LiveSyncHarness) {
if (harness.plugin.core.settings.remoteType === RemoteTypes.REMOTE_P2P) {
const replicator = await harness.plugin.core.services.replicator.getActiveReplicator();
if (!(replicator instanceof LiveSyncTrysteroReplicator)) {
throw new Error("Replicator is not an instance of LiveSyncTrysteroReplicator");
}
replicator.closeReplication();
await delay(30);
replicator.closeReplication();
await delay(1000);
console.log("P2P replicator connections closed");
// if (replicator instanceof LiveSyncTrysteroReplicator) {
// replicator.closeReplication();
// await delay(1000);
// }
}
}
export async function performReplication(harness: LiveSyncHarness) {
await waitForP2PPeers(harness);
await delay(500);
const p = harness.plugin.core.services.replication.replicate(true);
const task =
harness.plugin.core.settings.remoteType === RemoteTypes.REMOTE_P2P
? waitTaskWithFollowups(
p,
() => {
// Accept any peer dialogs during replication (fire and forget)
fireAndForget(() => commands.acceptWebPeer());
return Promise.resolve();
},
30000,
500
)
: p;
const result = await task;
// await waitForIdle(harness);
// if (harness.plugin.core.settings.remoteType === RemoteTypes.REMOTE_P2P) {
// await closeP2PReplicatorConnections(harness);
// }
return result;
}
export async function closeReplication(harness: LiveSyncHarness) {
if (harness.plugin.core.settings.remoteType === RemoteTypes.REMOTE_P2P) {
return await closeP2PReplicatorConnections(harness);
}
const replicator = await harness.plugin.core.services.replicator.getActiveReplicator();
if (!replicator) {
console.log("No active replicator to close");
return;
}
await replicator.closeReplication();
await waitForIdle(harness);
console.log("Replication closed");
}
export async function prepareRemote(harness: LiveSyncHarness, setting: ObsidianLiveSyncSettings, shouldReset = false) {
if (setting.remoteType !== RemoteTypes.REMOTE_P2P) {
if (shouldReset) {
await delay(1000);
await harness.plugin.core.services.replicator
.getActiveReplicator()
?.tryResetRemoteDatabase(harness.plugin.core.settings);
} else {
await harness.plugin.core.services.replicator
.getActiveReplicator()
?.tryCreateRemoteDatabase(harness.plugin.core.settings);
}
await harness.plugin.core.services.replicator
.getActiveReplicator()
?.markRemoteResolved(harness.plugin.core.settings);
// No exceptions should be thrown
const status = await harness.plugin.core.services.replicator
.getActiveReplicator()
?.getRemoteStatus(harness.plugin.core.settings);
console.log("Remote status:", status);
expect(status).not.toBeFalsy();
}
}
-39
View File
@@ -1,39 +0,0 @@
import { DoctorRegulation } from "@vrtmrz/livesync-commonlib/compat/common/configForDoc";
import {
DEFAULT_SETTINGS,
ChunkAlgorithms,
AutoAccepting,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
export const env = (import.meta as any).env;
export const settingBase = {
...DEFAULT_SETTINGS,
isConfigured: true,
handleFilenameCaseSensitive: false,
couchDB_URI: `${env.hostname}`,
couchDB_DBNAME: `${env.dbname}`,
couchDB_USER: `${env.username}`,
couchDB_PASSWORD: `${env.password}`,
bucket: `${env.bucketName}`,
region: "us-east-1",
endpoint: `${env.minioEndpoint}`,
accessKey: `${env.accessKey}`,
secretKey: `${env.secretKey}`,
useCustomRequestHandler: true,
forcePathStyle: true,
bucketPrefix: "",
usePluginSyncV2: true,
chunkSplitterVersion: ChunkAlgorithms.RabinKarp,
doctorProcessedVersion: DoctorRegulation.version,
notifyThresholdOfRemoteStorageSize: 800,
P2P_AutoAccepting: AutoAccepting.ALL,
P2P_AutoBroadcast: true,
P2P_AutoStart: true,
P2P_Enabled: true,
P2P_passphrase: "p2psync-test",
P2P_roomID: "p2psync-test",
P2P_DevicePeerName: "p2psync-test",
P2P_relays: "ws://localhost:4000/",
P2P_AutoAcceptingPeers: "p2p-livesync-web-peer",
P2P_SyncOnReplication: "p2p-livesync-web-peer",
} as ObsidianLiveSyncSettings;
-194
View File
@@ -1,194 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../.." && pwd)"
CLI_DIR="$REPO_ROOT/src/apps/cli"
CLI_TEST_HELPERS="$CLI_DIR/test/test-helpers.sh"
source "$CLI_TEST_HELPERS"
RUN_BUILD="${RUN_BUILD:-1}"
KEEP_TEST_DATA="${KEEP_TEST_DATA:-1}"
VERBOSE_TEST_LOGGING="${VERBOSE_TEST_LOGGING:-1}"
RELAY="${RELAY:-ws://localhost:4000/}"
USE_INTERNAL_RELAY="${USE_INTERNAL_RELAY:-1}"
APP_ID="${APP_ID:-self-hosted-livesync-vitest-p2p}"
HOST_PEER_NAME="${HOST_PEER_NAME:-p2p-cli-host}"
ROOM_ID="p2p-room-$(date +%s)-$RANDOM-$RANDOM"
PASSPHRASE="p2p-pass-$(date +%s)-$RANDOM-$RANDOM"
UPLOAD_PEER_NAME="p2p-upload-$(date +%s)-$RANDOM"
DOWNLOAD_PEER_NAME="p2p-download-$(date +%s)-$RANDOM"
UPLOAD_VAULT_NAME="TestVaultUpload-$(date +%s)-$RANDOM"
DOWNLOAD_VAULT_NAME="TestVaultDownload-$(date +%s)-$RANDOM"
# ---- Build CLI ----
if [[ "$RUN_BUILD" == "1" ]]; then
echo "[INFO] building CLI"
(cd "$CLI_DIR" && npm run build)
fi
# ---- Temp directory ----
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/livesync-vitest-p2p.XXXXXX")"
VAULT_HOST="$WORK_DIR/vault-host"
SETTINGS_HOST="$WORK_DIR/settings-host.json"
HOST_LOG="$WORK_DIR/p2p-host.log"
# Handoff file: upload phase writes this; download phase reads it.
HANDOFF_FILE="$WORK_DIR/p2p-test-handoff.json"
mkdir -p "$VAULT_HOST"
# ---- Setup CLI command (uses npm run cli from CLI_DIR) ----
# Override run_cli to invoke the built binary directly from CLI_DIR
run_cli() {
(cd "$CLI_DIR" && node dist/index.cjs "$@")
}
# ---- Create host settings ----
echo "[INFO] relay=$RELAY room=$ROOM_ID app=$APP_ID host=$HOST_PEER_NAME"
cli_test_init_settings_file "$SETTINGS_HOST"
cli_test_apply_p2p_settings "$SETTINGS_HOST" "$ROOM_ID" "$PASSPHRASE" "$APP_ID" "$RELAY" "~.*"
# Set host peer name
SETTINGS_HOST_FILE="$SETTINGS_HOST" HOST_PEER_NAME_VAL="$HOST_PEER_NAME" HOST_PASSPHRASE_VAL="$PASSPHRASE" node <<'NODE'
const fs = require("node:fs");
const data = JSON.parse(fs.readFileSync(process.env.SETTINGS_HOST_FILE, "utf-8"));
// Keep tweak values aligned with browser-side P2P test settings.
data.remoteType = "ONLY_P2P";
data.encrypt = true;
data.passphrase = process.env.HOST_PASSPHRASE_VAL;
data.usePathObfuscation = true;
data.handleFilenameCaseSensitive = false;
data.customChunkSize = 50;
data.usePluginSyncV2 = true;
data.doNotUseFixedRevisionForChunks = false;
data.P2P_DevicePeerName = process.env.HOST_PEER_NAME_VAL;
fs.writeFileSync(process.env.SETTINGS_HOST_FILE, JSON.stringify(data, null, 2), "utf-8");
NODE
# ---- Cleanup trap ----
cleanup() {
local exit_code=$?
if [[ -n "${HOST_PID:-}" ]] && kill -0 "$HOST_PID" >/dev/null 2>&1; then
echo "[INFO] stopping CLI host (PID=$HOST_PID)"
kill -TERM "$HOST_PID" >/dev/null 2>&1 || true
wait "$HOST_PID" >/dev/null 2>&1 || true
fi
if [[ "${P2P_RELAY_STARTED:-0}" == "1" ]]; then
cli_test_stop_p2p_relay
fi
if [[ "$KEEP_TEST_DATA" != "1" ]]; then
rm -rf "$WORK_DIR"
else
echo "[INFO] KEEP_TEST_DATA=1, preserving artefacts at $WORK_DIR"
fi
exit "$exit_code"
}
trap cleanup EXIT
start_host() {
local attempt=0
while [[ "$attempt" -lt 5 ]]; do
attempt=$((attempt + 1))
echo "[INFO] starting CLI p2p-host (attempt $attempt/5)"
: >"$HOST_LOG"
(cd "$CLI_DIR" && node dist/index.cjs "$VAULT_HOST" --settings "$SETTINGS_HOST" -d p2p-host) >"$HOST_LOG" 2>&1 &
HOST_PID=$!
local host_ready=0
local exited_early=0
for i in $(seq 1 30); do
if grep -qF "P2P host is running" "$HOST_LOG" 2>/dev/null; then
host_ready=1
break
fi
if ! kill -0 "$HOST_PID" >/dev/null 2>&1; then
exited_early=1
break
fi
echo "[INFO] waiting for p2p-host to be ready... ($i/30)"
sleep 1
done
if [[ "$host_ready" == "1" ]]; then
echo "[INFO] p2p-host is ready (PID=$HOST_PID)"
return 0
fi
wait "$HOST_PID" >/dev/null 2>&1 || true
HOST_PID=
if grep -qF "Resource temporarily unavailable" "$HOST_LOG" 2>/dev/null; then
echo "[INFO] p2p-host database lock is still being released, retrying..."
sleep 2
continue
fi
if [[ "$exited_early" == "1" ]]; then
echo "[FAIL] CLI host process exited unexpectedly" >&2
else
echo "[FAIL] p2p-host did not become ready within 30 seconds" >&2
fi
cat "$HOST_LOG" >&2
exit 1
done
echo "[FAIL] p2p-host could not be restarted after multiple attempts" >&2
cat "$HOST_LOG" >&2
exit 1
}
# ---- Start local relay if needed ----
if [[ "$USE_INTERNAL_RELAY" == "1" ]]; then
if cli_test_is_local_p2p_relay "$RELAY"; then
cli_test_start_p2p_relay
P2P_RELAY_STARTED=1
else
echo "[INFO] USE_INTERNAL_RELAY=1 but RELAY is not local ($RELAY), skipping"
fi
fi
start_host
# Common env vars passed to both vitest runs
P2P_ENV=(
P2P_TEST_ROOM_ID="$ROOM_ID"
P2P_TEST_PASSPHRASE="$PASSPHRASE"
P2P_TEST_HOST_PEER_NAME="$HOST_PEER_NAME"
P2P_TEST_RELAY="$RELAY"
P2P_TEST_APP_ID="$APP_ID"
P2P_TEST_HANDOFF_FILE="$HANDOFF_FILE"
P2P_TEST_UPLOAD_PEER_NAME="$UPLOAD_PEER_NAME"
P2P_TEST_DOWNLOAD_PEER_NAME="$DOWNLOAD_PEER_NAME"
P2P_TEST_UPLOAD_VAULT_NAME="$UPLOAD_VAULT_NAME"
P2P_TEST_DOWNLOAD_VAULT_NAME="$DOWNLOAD_VAULT_NAME"
)
cd "$REPO_ROOT"
# ---- Phase 1: Upload ----
# Each vitest run gets a fresh browser process, so Trystero's module-level
# global state (occupiedRooms, didInit, etc.) is clean for every phase.
echo "[INFO] running P2P vitest — upload phase"
env "${P2P_ENV[@]}" \
npx dotenv-cli -e .env -e .test.env -- \
vitest run --config vitest.config.p2p.ts test/suitep2p/syncp2p.p2p-up.test.ts
echo "[INFO] upload phase completed"
# ---- Phase 2: Download ----
# Keep the same host process alive so its database handle and relay presence stay stable.
echo "[INFO] waiting 5s before download phase..."
sleep 5
echo "[INFO] running P2P vitest — download phase"
env "${P2P_ENV[@]}" \
npx dotenv-cli -e .env -e .test.env -- \
vitest run --config vitest.config.p2p.ts test/suitep2p/syncp2p.p2p-down.test.ts
echo "[INFO] download phase completed"
echo "[INFO] P2P vitest suite completed"
-175
View File
@@ -1,175 +0,0 @@
/**
* P2P-specific sync helpers.
*
* Derived from test/suite/sync_common.ts but with all acceptWebPeer() calls
* removed. When using a CLI p2p-host with P2P_AutoAcceptingPeers="~.*", peer
* acceptance is automatic and no Playwright dialog interaction is needed.
*/
import { expect } from "vitest";
import { waitForIdle, type LiveSyncHarness } from "../harness/harness";
import { RemoteTypes, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { delay } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator";
import { waitTaskWithFollowups } from "../lib/util";
const P2P_REPLICATION_TIMEOUT_MS = 180000;
async function testWebSocketConnection(relayUrl: string): Promise<void> {
return new Promise((resolve, reject) => {
console.log(`[P2P Debug] Testing WebSocket connection to ${relayUrl}`);
try {
const ws = new WebSocket(relayUrl);
const timer = setTimeout(() => {
ws.close();
reject(new Error(`WebSocket connection to ${relayUrl} timed out`));
}, 5000);
ws.onopen = () => {
clearTimeout(timer);
console.log(`[P2P Debug] WebSocket connected to ${relayUrl} successfully`);
ws.close();
resolve();
};
ws.onerror = (e) => {
clearTimeout(timer);
console.error(`[P2P Debug] WebSocket error connecting to ${relayUrl}:`, e);
reject(new Error(`WebSocket connection to ${relayUrl} failed`));
};
} catch (e) {
console.error(`[P2P Debug] WebSocket constructor threw:`, e);
reject(e);
}
});
}
async function waitForP2PPeers(harness: LiveSyncHarness) {
if (harness.plugin.core.settings.remoteType === RemoteTypes.REMOTE_P2P) {
const maxRetries = 20;
let retries = maxRetries;
const replicator = await harness.plugin.core.services.replicator.getActiveReplicator();
console.log("[P2P Debug] replicator type:", replicator?.constructor?.name);
if (!(replicator instanceof LiveSyncTrysteroReplicator)) {
throw new Error("Replicator is not an instance of LiveSyncTrysteroReplicator");
}
// Ensure P2P is open (getActiveReplicator returns a fresh instance that may not be open yet)
if (!replicator.server?.isServing) {
console.log("[P2P Debug] P2P not yet serving, calling open()");
// Test WebSocket connectivity first
const relay = harness.plugin.core.settings.P2P_relays?.split(",")[0]?.trim();
if (relay) {
try {
await testWebSocketConnection(relay);
} catch (e) {
console.error("[P2P Debug] WebSocket connectivity test failed:", e);
}
}
try {
await replicator.open();
console.log("[P2P Debug] open() completed, isServing:", replicator.server?.isServing);
} catch (e) {
console.error("[P2P Debug] open() threw:", e);
}
}
// Wait for P2P server to actually start (room joined)
for (let i = 0; i < 30; i++) {
const serving = replicator.server?.isServing;
console.log(`[P2P Debug] isServing: ${serving} (${i}/30)`);
if (serving) break;
await delay(500);
if (i === 29) throw new Error("P2P server did not start in time.");
}
while (retries-- > 0) {
await delay(1000);
const peers = replicator.knownAdvertisements;
if (peers && peers.length > 0) {
console.log("P2P peers connected:", peers);
return;
}
console.log(`Waiting for any P2P peers to be connected... ${maxRetries - retries}/${maxRetries}`);
console.dir(peers);
await delay(1000);
}
console.log("Failed to connect P2P peers after retries");
throw new Error("P2P peers did not connect in time.");
}
}
export async function closeP2PReplicatorConnections(harness: LiveSyncHarness) {
if (harness.plugin.core.settings.remoteType === RemoteTypes.REMOTE_P2P) {
const replicator = await harness.plugin.core.services.replicator.getActiveReplicator();
if (!(replicator instanceof LiveSyncTrysteroReplicator)) {
throw new Error("Replicator is not an instance of LiveSyncTrysteroReplicator");
}
replicator.closeReplication();
await delay(30);
replicator.closeReplication();
await delay(1000);
console.log("P2P replicator connections closed");
}
}
export async function performReplication(harness: LiveSyncHarness) {
await waitForP2PPeers(harness);
await delay(500);
if (harness.plugin.core.settings.remoteType === RemoteTypes.REMOTE_P2P) {
const replicator = await harness.plugin.core.services.replicator.getActiveReplicator();
if (!(replicator instanceof LiveSyncTrysteroReplicator)) {
throw new Error("Replicator is not an instance of LiveSyncTrysteroReplicator");
}
const knownPeers = replicator.knownAdvertisements;
const targetPeer = knownPeers.find((peer) => peer.name.startsWith("vault-host")) ?? knownPeers[0] ?? undefined;
if (!targetPeer) {
throw new Error("No connected P2P peer to synchronise with");
}
const p = replicator.sync(targetPeer.peerId, true);
const result = await waitTaskWithFollowups(p, () => Promise.resolve(), P2P_REPLICATION_TIMEOUT_MS, 500);
if (result && typeof result === "object" && "error" in result && result.error) {
throw result.error;
}
return result;
}
return await harness.plugin.core.services.replication.replicate(true);
}
export async function closeReplication(harness: LiveSyncHarness) {
if (harness.plugin.core.settings.remoteType === RemoteTypes.REMOTE_P2P) {
return await closeP2PReplicatorConnections(harness);
}
const replicator = await harness.plugin.core.services.replicator.getActiveReplicator();
if (!replicator) {
console.log("No active replicator to close");
return;
}
await replicator.closeReplication();
await waitForIdle(harness);
console.log("Replication closed");
}
export async function prepareRemote(harness: LiveSyncHarness, setting: ObsidianLiveSyncSettings, shouldReset = false) {
// P2P has no remote database to initialise — skip
if (setting.remoteType === RemoteTypes.REMOTE_P2P) return;
if (shouldReset) {
await delay(1000);
await harness.plugin.core.services.replicator
.getActiveReplicator()
?.tryResetRemoteDatabase(harness.plugin.core.settings);
} else {
await harness.plugin.core.services.replicator
.getActiveReplicator()
?.tryCreateRemoteDatabase(harness.plugin.core.settings);
}
await harness.plugin.core.services.replicator
.getActiveReplicator()
?.markRemoteResolved(harness.plugin.core.settings);
const status = await harness.plugin.core.services.replicator
.getActiveReplicator()
?.getRemoteStatus(harness.plugin.core.settings);
console.log("Remote status:", status);
expect(status).not.toBeFalsy();
}
-165
View File
@@ -1,165 +0,0 @@
/**
* P2P Replication Tests — Download phase (process 2 of 2)
*
* Executed by run-p2p-tests.sh as the second vitest process, after the
* upload phase has completed and the CLI host holds all the data.
*
* Reads the handoff JSON written by the upload phase to know which files
* to verify, then replicates from the CLI host and checks every file.
*/
import { afterAll, beforeAll, beforeEach, describe, expect, it, test } from "vitest";
import { generateHarness, waitForIdle, waitForReady, type LiveSyncHarness } from "../harness/harness";
import {
PREFERRED_SETTING_SELF_HOSTED,
RemoteTypes,
type FilePath,
type ObsidianLiveSyncSettings,
AutoAccepting,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { DummyFileSourceInisialised, generateBinaryFile, generateFile } from "../utils/dummyfile";
import { defaultFileOption, testFileRead } from "../suite/db_common";
import { delay } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { closeReplication, performReplication } from "./sync_common_p2p";
import { settingBase } from "../suite/variables";
const env = (import.meta as any).env;
const ROOM_ID: string = env.P2P_TEST_ROOM_ID ?? "p2p-test-room";
const PASSPHRASE: string = env.P2P_TEST_PASSPHRASE ?? "p2p-test-pass";
const HOST_PEER_NAME: string = env.P2P_TEST_HOST_PEER_NAME ?? "p2p-cli-host";
const RELAY: string = env.P2P_TEST_RELAY ?? "ws://localhost:4000/";
const APP_ID: string = env.P2P_TEST_APP_ID ?? "self-hosted-livesync-vitest-p2p";
const DOWNLOAD_PEER_NAME: string = env.P2P_TEST_DOWNLOAD_PEER_NAME ?? `p2p-download-${Date.now()}`;
const DOWNLOAD_VAULT_NAME: string = env.P2P_TEST_DOWNLOAD_VAULT_NAME ?? `TestVaultDownload-${Date.now()}`;
const HANDOFF_FILE: string = env.P2P_TEST_HANDOFF_FILE ?? "/tmp/p2p-test-handoff.json";
console.log("[P2P Down] ROOM_ID:", ROOM_ID, "HOST:", HOST_PEER_NAME, "RELAY:", RELAY, "APP_ID:", APP_ID);
console.log("[P2P Down] HANDOFF_FILE:", HANDOFF_FILE);
const p2pSetting: ObsidianLiveSyncSettings = {
...settingBase,
...PREFERRED_SETTING_SELF_HOSTED,
showVerboseLog: true,
remoteType: RemoteTypes.REMOTE_P2P,
encrypt: true,
passphrase: PASSPHRASE,
usePathObfuscation: true,
P2P_Enabled: true,
P2P_AppID: APP_ID,
handleFilenameCaseSensitive: false,
P2P_AutoAccepting: AutoAccepting.ALL,
P2P_AutoBroadcast: true,
P2P_AutoStart: true,
P2P_passphrase: PASSPHRASE,
P2P_roomID: ROOM_ID,
P2P_relays: RELAY,
P2P_AutoAcceptingPeers: "~.*",
P2P_SyncOnReplication: HOST_PEER_NAME,
};
const fileOptions = defaultFileOption;
const nameFile = (type: string, ext: string, size: number) => `p2p-cli-test-${type}-file-${size}.${ext}`;
/** Read the handoff JSON produced by the upload phase. */
async function readHandoff(): Promise<{ fileSizeMd: number[]; fileSizeBins: number[] }> {
const { commands } = await import("@vitest/browser/context");
const raw = await commands.readHandoffFile(HANDOFF_FILE);
return JSON.parse(raw);
}
describe("P2P Replication — Download", () => {
let harnessDownload: LiveSyncHarness;
let fileSizeMd: number[] = [];
let fileSizeBins: number[] = [];
const downloadSetting: ObsidianLiveSyncSettings = {
...p2pSetting,
P2P_DevicePeerName: DOWNLOAD_PEER_NAME,
};
beforeAll(async () => {
await DummyFileSourceInisialised;
const handoff = await readHandoff();
fileSizeMd = handoff.fileSizeMd;
fileSizeBins = handoff.fileSizeBins;
console.log("[P2P Down] handoff loaded — md sizes:", fileSizeMd, "bin sizes:", fileSizeBins);
const vaultName = DOWNLOAD_VAULT_NAME;
console.log(`[P2P Down] BeforeAll - Vault: ${vaultName}`);
console.log(`[P2P Down] Peer name: ${DOWNLOAD_PEER_NAME}`);
harnessDownload = await generateHarness(vaultName, downloadSetting);
await waitForReady(harnessDownload);
await performReplication(harnessDownload);
await waitForIdle(harnessDownload);
await delay(1000);
await performReplication(harnessDownload);
await waitForIdle(harnessDownload);
await delay(3000);
});
beforeEach(async () => {
await performReplication(harnessDownload);
await waitForIdle(harnessDownload);
});
afterAll(async () => {
await closeReplication(harnessDownload);
await harnessDownload.dispose();
await delay(1000);
});
it("should be instantiated and defined", () => {
expect(harnessDownload.plugin).toBeDefined();
expect(harnessDownload.plugin.app).toBe(harnessDownload.app);
});
it("should have services initialized", () => {
expect(harnessDownload.plugin.core.services).toBeDefined();
});
it("should have local database initialized", () => {
expect(harnessDownload.plugin.core.localDatabase).toBeDefined();
expect(harnessDownload.plugin.core.localDatabase.isReady).toBe(true);
});
it("should have synchronised the stored file", async () => {
await testFileRead(harnessDownload, nameFile("store", "md", 0), "Hello, World!", fileOptions);
});
it("should have synchronised files with different content", async () => {
await testFileRead(harnessDownload, nameFile("test-diff-1", "md", 0), "Content A", fileOptions);
await testFileRead(harnessDownload, nameFile("test-diff-2", "md", 0), "Content B", fileOptions);
await testFileRead(harnessDownload, nameFile("test-diff-3", "md", 0), "Content C", fileOptions);
});
// NOTE: test.each cannot use variables populated in beforeAll, so we use
// a single it() that iterates over the sizes loaded from the handoff file.
it("should have synchronised all large md files", async () => {
for (const size of fileSizeMd) {
const content = Array.from(generateFile(size)).join("");
const path = nameFile("large", "md", size);
const isTooLarge = harnessDownload.plugin.core.services.vault.isFileSizeTooLarge(size);
if (isTooLarge) {
const entry = await harnessDownload.plugin.core.localDatabase.getDBEntry(path as FilePath);
expect(entry).toBe(false);
} else {
await testFileRead(harnessDownload, path, content, fileOptions);
}
}
});
it("should have synchronised all binary files", async () => {
for (const size of fileSizeBins) {
const path = nameFile("binary", "bin", size);
const isTooLarge = harnessDownload.plugin.core.services.vault.isFileSizeTooLarge(size);
if (isTooLarge) {
const entry = await harnessDownload.plugin.core.localDatabase.getDBEntry(path as FilePath);
expect(entry).toBe(false);
} else {
const content = new Blob([...generateBinaryFile(size)], { type: "application/octet-stream" });
await testFileRead(harnessDownload, path, content, fileOptions);
}
}
});
});
-161
View File
@@ -1,161 +0,0 @@
/**
* P2P Replication Tests — Upload phase (process 1 of 2)
*
* Executed by run-p2p-tests.sh as the first vitest process.
* Writes files into the local DB, replicates them to the CLI host,
* then writes a handoff JSON so the download process knows what to verify.
*
* Trystero has module-level global state (occupiedRooms, didInit, etc.)
* that cannot be safely reused across upload→download within the same
* browser process. Running upload and download as separate vitest
* invocations gives each phase a fresh browser context.
*/
import { afterAll, beforeAll, describe, expect, it, test } from "vitest";
import { generateHarness, waitForIdle, waitForReady, type LiveSyncHarness } from "../harness/harness";
import {
PREFERRED_SETTING_SELF_HOSTED,
RemoteTypes,
type ObsidianLiveSyncSettings,
AutoAccepting,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
DummyFileSourceInisialised,
FILE_SIZE_BINS,
FILE_SIZE_MD,
generateBinaryFile,
generateFile,
} from "../utils/dummyfile";
import { checkStoredFileInDB, defaultFileOption, testFileWrite } from "../suite/db_common";
import { delay } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { closeReplication, performReplication } from "./sync_common_p2p";
import { settingBase } from "../suite/variables";
const env = (import.meta as any).env;
const ROOM_ID: string = env.P2P_TEST_ROOM_ID ?? "p2p-test-room";
const PASSPHRASE: string = env.P2P_TEST_PASSPHRASE ?? "p2p-test-pass";
const HOST_PEER_NAME: string = env.P2P_TEST_HOST_PEER_NAME ?? "p2p-cli-host";
const RELAY: string = env.P2P_TEST_RELAY ?? "ws://localhost:4000/";
const APP_ID: string = env.P2P_TEST_APP_ID ?? "self-hosted-livesync-vitest-p2p";
const UPLOAD_PEER_NAME: string = env.P2P_TEST_UPLOAD_PEER_NAME ?? `p2p-upload-${Date.now()}`;
const UPLOAD_VAULT_NAME: string = env.P2P_TEST_UPLOAD_VAULT_NAME ?? `TestVaultUpload-${Date.now()}`;
// Path written by run-p2p-tests.sh; the download phase reads it back.
const HANDOFF_FILE: string = env.P2P_TEST_HANDOFF_FILE ?? "/tmp/p2p-test-handoff.json";
console.log("[P2P Up] ROOM_ID:", ROOM_ID, "HOST:", HOST_PEER_NAME, "RELAY:", RELAY, "APP_ID:", APP_ID);
console.log("[P2P Up] HANDOFF_FILE:", HANDOFF_FILE);
const p2pSetting: ObsidianLiveSyncSettings = {
...settingBase,
...PREFERRED_SETTING_SELF_HOSTED,
showVerboseLog: true,
remoteType: RemoteTypes.REMOTE_P2P,
encrypt: true,
passphrase: PASSPHRASE,
usePathObfuscation: true,
P2P_Enabled: true,
P2P_AppID: APP_ID,
handleFilenameCaseSensitive: false,
P2P_AutoAccepting: AutoAccepting.ALL,
P2P_AutoBroadcast: true,
P2P_AutoStart: true,
P2P_passphrase: PASSPHRASE,
P2P_roomID: ROOM_ID,
P2P_relays: RELAY,
P2P_AutoAcceptingPeers: "~.*",
P2P_SyncOnReplication: HOST_PEER_NAME,
};
const fileOptions = defaultFileOption;
const nameFile = (type: string, ext: string, size: number) => `p2p-cli-test-${type}-file-${size}.${ext}`;
/** Write the handoff JSON so the download phase knows which files to verify. */
async function writeHandoff() {
const handoff = {
fileSizeMd: FILE_SIZE_MD,
fileSizeBins: FILE_SIZE_BINS,
};
const { commands } = await import("@vitest/browser/context");
await commands.writeHandoffFile(HANDOFF_FILE, JSON.stringify(handoff));
console.log("[P2P Up] handoff written to", HANDOFF_FILE);
}
describe("P2P Replication — Upload", () => {
let harnessUpload: LiveSyncHarness;
const uploadSetting: ObsidianLiveSyncSettings = {
...p2pSetting,
P2P_DevicePeerName: UPLOAD_PEER_NAME,
};
beforeAll(async () => {
await DummyFileSourceInisialised;
const vaultName = UPLOAD_VAULT_NAME;
console.log(`[P2P Up] BeforeAll - Vault: ${vaultName}`);
console.log(`[P2P Up] Peer name: ${UPLOAD_PEER_NAME}`);
harnessUpload = await generateHarness(vaultName, uploadSetting);
await waitForReady(harnessUpload);
expect(harnessUpload.plugin).toBeDefined();
await waitForIdle(harnessUpload);
});
afterAll(async () => {
await closeReplication(harnessUpload);
await harnessUpload.dispose();
await delay(1000);
});
it("should be instantiated and defined", () => {
expect(harnessUpload.plugin).toBeDefined();
expect(harnessUpload.plugin.app).toBe(harnessUpload.app);
});
it("should have services initialized", () => {
expect(harnessUpload.plugin.core.services).toBeDefined();
});
it("should have local database initialized", () => {
expect(harnessUpload.plugin.core.localDatabase).toBeDefined();
expect(harnessUpload.plugin.core.localDatabase.isReady).toBe(true);
});
it("should create a file", async () => {
await testFileWrite(harnessUpload, nameFile("store", "md", 0), "Hello, World!", false, fileOptions);
});
it("should create several files with different content", async () => {
await testFileWrite(harnessUpload, nameFile("test-diff-1", "md", 0), "Content A", false, fileOptions);
await testFileWrite(harnessUpload, nameFile("test-diff-2", "md", 0), "Content B", false, fileOptions);
await testFileWrite(harnessUpload, nameFile("test-diff-3", "md", 0), "Content C", false, fileOptions);
});
test.each(FILE_SIZE_MD)("should create large md file of size %i bytes", async (size) => {
const content = Array.from(generateFile(size)).join("");
const path = nameFile("large", "md", size);
const isTooLarge = harnessUpload.plugin.core.services.vault.isFileSizeTooLarge(size);
if (isTooLarge) {
expect(true).toBe(true);
} else {
await testFileWrite(harnessUpload, path, content, false, fileOptions);
}
});
test.each(FILE_SIZE_BINS)("should create binary file of size %i bytes", async (size) => {
const content = new Blob([...generateBinaryFile(size)], { type: "application/octet-stream" });
const path = nameFile("binary", "bin", size);
await testFileWrite(harnessUpload, path, content, true, fileOptions);
const isTooLarge = harnessUpload.plugin.core.services.vault.isFileSizeTooLarge(size);
if (!isTooLarge) {
await checkStoredFileInDB(harnessUpload, path, content, fileOptions);
}
});
it("should replicate uploads to CLI host", async () => {
await performReplication(harnessUpload);
await performReplication(harnessUpload);
});
it("should write handoff file for download phase", async () => {
await writeHandoff();
});
});
-51
View File
@@ -1,51 +0,0 @@
// Functional Test on Main Cases
// This test suite only covers main functional cases of synchronisation. Event handling, error cases,
// and edge, resolving conflicts, etc. will be covered in separate test suites.
import { describe } from "vitest";
import {
PREFERRED_JOURNAL_SYNC,
PREFERRED_SETTING_SELF_HOSTED,
RemoteTypes,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { settingBase } from "../suite/variables.ts";
import { defaultFileOption } from "../suite/db_common";
import { syncBasicCase } from "../suite/sync.senario.basic.ts";
export const env = (import.meta as any).env;
function* generateCase() {
const sync_test_setting_base = settingBase;
const passpharse = "thetest-Passphrase3+9-for-e2ee!";
const REMOTE_RECOMMENDED = {
[RemoteTypes.REMOTE_COUCHDB]: PREFERRED_SETTING_SELF_HOSTED,
[RemoteTypes.REMOTE_MINIO]: PREFERRED_JOURNAL_SYNC,
[RemoteTypes.REMOTE_P2P]: PREFERRED_SETTING_SELF_HOSTED,
};
// const remoteTypes = [RemoteTypes.REMOTE_COUCHDB, RemoteTypes.REMOTE_MINIO, RemoteTypes.REMOTE_P2P];
const remoteTypes = [RemoteTypes.REMOTE_P2P];
// const e2eeOptions = [false, true];
const e2eeOptions = [true];
for (const remoteType of remoteTypes) {
for (const useE2EE of e2eeOptions) {
yield {
setting: {
...sync_test_setting_base,
...REMOTE_RECOMMENDED[remoteType],
remoteType,
encrypt: useE2EE,
passphrase: useE2EE ? passpharse : "",
usePathObfuscation: useE2EE,
} as ObsidianLiveSyncSettings,
};
}
}
}
describe("Replication Suite Tests (P2P)", async () => {
const cases = Array.from(generateCase());
const fileOptions = defaultFileOption;
describe.each(cases)("Replication Tests - Remote: $setting.remoteType, E2EE: $setting.encrypt", ({ setting }) => {
syncBasicCase(`Remote: ${setting.remoteType}, E2EE: ${setting.encrypt}`, { setting, fileOptions });
});
});
-53
View File
@@ -1,53 +0,0 @@
import { writeFile } from "../utils/fileapi.vite";
import { DummyFileSourceInisialised, generateBinaryFile, generateFile } from "../utils/dummyfile";
import { describe, expect, it } from "vitest";
describe("Test File Teet", async () => {
await DummyFileSourceInisialised;
it("should generate binary file correctly", async () => {
const size = 5000;
let generatedSize = 0;
const chunks: Uint8Array[] = [];
const generator = generateBinaryFile(size);
const blob = new Blob([...generator], { type: "application/octet-stream" });
const buf = await blob.arrayBuffer();
const hexDump = new Uint8Array(buf)
//@ts-ignore
.toHex()
.match(/.{1,32}/g)
?.join("\n");
const secondDummy = generateBinaryFile(size);
const secondBlob = new Blob([...secondDummy], { type: "application/octet-stream" });
const secondBuf = await secondBlob.arrayBuffer();
const secondHexDump = new Uint8Array(secondBuf)
//@ts-ignore
.toHex()
.match(/.{1,32}/g)
?.join("\n");
if (hexDump !== secondHexDump) {
throw new Error("Generated binary files do not match");
}
expect(hexDump).toBe(secondHexDump);
// await writeFile("test/testtest/dummyfile.test.bin", buf);
// await writeFile("test/testtest/dummyfile.test.bin.hexdump.txt", hexDump || "");
});
it("should generate text file correctly", async () => {
const size = 25000;
let generatedSize = 0;
let content = "";
const generator = generateFile(size);
const out = [...generator];
// const blob = new Blob(out, { type: "text/plain" });
content = out.join("");
const secondDummy = generateFile(size);
const secondOut = [...secondDummy];
const secondContent = secondOut.join("");
if (content !== secondContent) {
throw new Error("Generated text files do not match");
}
expect(content).toBe(secondContent);
// await writeFile("test/testtest/dummyfile.test.txt", await blob.text());
});
});
-94
View File
@@ -1,94 +0,0 @@
// Dialog Unit Tests
import { beforeAll, describe, expect, it } from "vitest";
import { commands } from "vitest/browser";
import { generateHarness, waitForIdle, waitForReady, type LiveSyncHarness } from "../harness/harness";
import { ChunkAlgorithms, DEFAULT_SETTINGS, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { DummyFileSourceInisialised } from "../utils/dummyfile";
import { page } from "vitest/browser";
import { DoctorRegulation } from "@vrtmrz/livesync-commonlib/compat/common/configForDoc";
import { waitForDialogHidden, waitForDialogShown } from "../lib/ui";
const env = (import.meta as any).env;
const dialog_setting_base = {
...DEFAULT_SETTINGS,
isConfigured: true,
handleFilenameCaseSensitive: false,
couchDB_URI: `${env.hostname}`,
couchDB_DBNAME: `${env.dbname}`,
couchDB_USER: `${env.username}`,
couchDB_PASSWORD: `${env.password}`,
bucket: `${env.bucketName}`,
region: "us-east-1",
endpoint: `${env.minioEndpoint}`,
accessKey: `${env.accessKey}`,
secretKey: `${env.secretKey}`,
useCustomRequestHandler: true,
forcePathStyle: true,
bucketPrefix: "",
usePluginSyncV2: true,
chunkSplitterVersion: ChunkAlgorithms.RabinKarp,
doctorProcessedVersion: DoctorRegulation.version,
notifyThresholdOfRemoteStorageSize: 800,
} as ObsidianLiveSyncSettings;
function checkDialogVisibility(dialogText: string, shouldBeVisible: boolean): void {
const dialog = page.getByText(dialogText);
expect(dialog).toHaveClass(/modal-title/);
if (!shouldBeVisible) {
expect(dialog).not.toBeVisible();
} else {
expect(dialog).toBeVisible();
}
return;
}
function checkDialogShown(dialogText: string) {
checkDialogVisibility(dialogText, true);
}
function checkDialogHidden(dialogText: string) {
checkDialogVisibility(dialogText, false);
}
describe("Dialog Tests", async () => {
// describe.each(cases)("Replication Tests - Remote: $setting.remoteType, E2EE: $setting.encrypt", ({ setting }) => {
const setting = dialog_setting_base;
beforeAll(async () => {
await DummyFileSourceInisialised;
await commands.grantClipboardPermissions();
});
let harness: LiveSyncHarness;
const vaultName = "TestVault" + Date.now();
beforeAll(async () => {
harness = await generateHarness(vaultName, setting);
await waitForReady(harness);
expect(harness.plugin).toBeDefined();
expect(harness.plugin.app).toBe(harness.app);
await waitForIdle(harness);
});
it("should show copy to clipboard dialog and confirm", async () => {
const testString = "This is a test string to copy to clipboard.";
const title = "Copy Test";
const result = harness.plugin.core.services.UI.promptCopyToClipboard(title, testString);
const isDialogShown = await waitForDialogShown(title, 500);
expect(isDialogShown).toBe(true);
const copyButton = page.getByText("📋");
expect(copyButton).toBeDefined();
expect(copyButton).toBeVisible();
await copyButton.click();
const copyResultButton = page.getByText("✔️");
expect(copyResultButton).toBeDefined();
expect(copyResultButton).toBeVisible();
const clipboardText = await navigator.clipboard.readText();
expect(clipboardText).toBe(testString);
const okButton = page.getByText("OK");
expect(okButton).toBeDefined();
expect(okButton).toBeVisible();
await okButton.click();
const resultValue = await result;
expect(resultValue).toBe(true);
// Check that the dialog is closed
const isDialogHidden = await waitForDialogHidden(title, 500);
expect(isDialogHidden).toBe(true);
});
});
-77
View File
@@ -1,77 +0,0 @@
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { readFile } from "../utils/fileapi.vite.ts";
let charset = "";
export async function init() {
console.log("Initializing dummyfile utils...");
charset = (await readFile("test/utils/testcharvariants.txt")).toString();
console.log(`Loaded charset of length ${charset.length}`);
console.log(charset);
}
export const DummyFileSourceInisialised = init();
function* indexer(range: number = 1000, seed: number = 0): Generator<number, number, number> {
let t = seed | 0;
while (true) {
t = (t + 0x6d2b79f5) | 0;
let z = t;
z = Math.imul(z ^ (z >>> 15), z | 1);
z ^= z + Math.imul(z ^ (z >>> 7), z | 61);
const float = ((z ^ (z >>> 14)) >>> 0) / 4294967296;
yield Math.floor(float * range);
}
}
export function* generateFile(size: number): Generator<string> {
const chunkSourceStr = charset;
const chunkStore = [...chunkSourceStr]; // To support indexing avoiding multi-byte issues
const bufSize = 1024;
let buf = "";
let generated = 0;
const indexGen = indexer(chunkStore.length);
while (generated < size) {
const f = indexGen.next().value;
buf += chunkStore[f];
generated += 1;
if (buf.length >= bufSize) {
yield buf;
buf = "";
}
}
if (buf.length > 0) {
yield buf;
}
}
export function* generateBinaryFile(size: number): Generator<Uint8Array<ArrayBuffer>> {
let generated = 0;
const pattern = Array.from({ length: 256 }, (_, i) => i);
const indexGen = indexer(pattern.length);
const bufSize = 1024;
const buf = new Uint8Array(bufSize);
let bufIdx = 0;
while (generated < size) {
const f = indexGen.next().value;
buf[bufIdx] = pattern[f];
bufIdx += 1;
generated += 1;
if (bufIdx >= bufSize) {
yield buf;
bufIdx = 0;
}
}
if (bufIdx > 0) {
yield buf.subarray(0, bufIdx);
}
}
// File size for markdown test files (10B to 1MB, roughly logarithmic scale)
export const FILE_SIZE_MD = [10, 100, 1000, 10000, 100000, 1000000];
// File size for test files (10B to 40MB, roughly logarithmic scale)
export const FILE_SIZE_BINS = [
10,
100,
1000,
50000,
100000,
5000000,
DEFAULT_SETTINGS.syncMaxSizeInMB * 1024 * 1024 + 1,
];
-3
View File
@@ -1,3 +0,0 @@
import { server } from "vitest/browser";
const { readFile, writeFile } = server.commands;
export { readFile, writeFile };
-17
View File
@@ -1,17 +0,0 @@
國破山河在,城春草木深。
感時花濺淚,恨別鳥驚心。
烽火連三月,家書抵萬金。
白頭搔更短,渾欲不勝簪。
«Nel mezzo del cammin di nostra vita
mi ritrovai per una selva oscura,
ché la diritta via era smarrita.»
Духовной жаждою томим,
В пустыне мрачной я влачился, —
И шестикрылый серафим
На перепутье мне явился.
Shall I compare thee to a summers day?
Thou art more lovely and more temperate:
Rough winds do shake the darling buds of May,
And summers lease hath all too short a date:
📜🖋️ 🏺 🏛️ 春望𠮷‌ché🇷🇺Аa‮RTLO🏳️‍🌈👨‍👩‍👧‍👦lʼanatraアイウエオ
+3
View File
@@ -19,6 +19,8 @@ Earlier releases remain available in the [0.25 release history](docs/releases/0.
### Improved ### Improved
- Removed the ineffective 'Use the trash bin' toggle from the settings interface. Remote deletions continue to follow Obsidian's deletion preference, while the legacy setting key remains accepted for compatibility. - Removed the ineffective 'Use the trash bin' toggle from the settings interface. Remote deletions continue to follow Obsidian's deletion preference, while the legacy setting key remains accepted for compatibility.
- Kept content-derived chunk revisions permanently enabled, as they have been since 0.25.6, and removed the obsolete stored key from recommendations, database-maintenance prerequisites, and review tooling.
- Aligned new-Vault, full-reset, and CLI-generated settings with the 1.0 recommendations. New Vaults use cross-platform case-insensitive file-name handling, while an existing Vault with no saved case policy remains paused until case-sensitive legacy behaviour is explicitly retained or a case-insensitive database rebuild is planned.
- Improved P2P restart and settings-reapplication handling by serialising transport start and stop, keeping event handlers bound to the active replicator, and using one package-owned Trystero transport generation. - Improved P2P restart and settings-reapplication handling by serialising transport start and stop, keeping event handlers bound to the active replicator, and using one package-owned Trystero transport generation.
- Kept the release history available in the settings while removing automatic unread-version tracking and redirection. Release versions are no longer treated as a data-compatibility signal. - Kept the release history available in the settings while removing automatic unread-version tracking and redirection. Release versions are no longer treated as a data-compatibility signal.
- Internal database and settings compatibility reviews now use a dedicated explanation and an explicit, case-specific resume action. They block replication without rewriting the user's automatic synchronisation choices, and older installations cannot dismiss state created by a newer version. - Internal database and settings compatibility reviews now use a dedicated explanation and an explicit, case-specific resume action. They block replication without rewriting the user's automatic synchronisation choices, and older installations cannot dismiss state created by a newer version.
@@ -37,6 +39,7 @@ Earlier releases remain available in the [0.25 release history](docs/releases/0.
### Testing ### Testing
- Retired the obsolete mocked browser Harness, its root-level P2P relay helpers, and its manually dispatched `harness-ci` workflow. Unit and integration suites, the CLI two-Vault and Compose P2P scenarios, and real Obsidian E2E now own the maintained verification paths.
- Added packed-package and downstream checks for Commonlib entry points, including isolated Node and browser File System Access API storage implementations. - Added packed-package and downstream checks for Commonlib entry points, including isolated Node and browser File System Access API storage implementations.
- Added reusable Context result contracts for Obsidian, CLI, and Webapp compositions, including a real-Obsidian smoke assertion that every service retains the host-provided Context. - Added reusable Context result contracts for Obsidian, CLI, and Webapp compositions, including a real-Obsidian smoke assertion that every service retains the host-provided Context.
- Added Commonlib stream-contract tests and downstream CLI unit and Deno E2E coverage for injected text, binary, prompt, and error channels. - Added Commonlib stream-contract tests and downstream CLI unit and Deno E2E coverage for injected text, binary, prompt, and error channels.
-107
View File
@@ -1,107 +0,0 @@
/**
* @file vitest.config.p2p.ts
* @description Configuration for running browser-based Peer-to-Peer (P2P) replication tests
* in Playwright (Chromium) using Trystero and Nostr relays.
* This is executed via the `npm run test:p2p` command (which runs `test/suitep2p/run-p2p-tests.sh` internally).
*/
import { defineConfig, mergeConfig } from "vitest/config";
import { playwright } from "@vitest/browser-playwright";
import viteConfig from "./vitest.config.common";
import path from "path";
import { existsSync, readFileSync } from "node:fs";
import { parseEnv } from "node:util";
import { grantClipboardPermissions, writeHandoffFile, readHandoffFile } from "./test/lib/commands";
// P2P test environment variables
// Configure these in .env or .test.env, or inject via shell before running tests.
// Shell-injected values take precedence over dotenv files.
//
// Required:
// P2P_TEST_ROOM_ID - Shared room identifier for peers to discover each other
// P2P_TEST_PASSPHRASE - Encryption passphrase shared between test peers
//
// Optional:
// P2P_TEST_HOST_PEER_NAME - Name used to identify the host peer (default varies)
// P2P_TEST_RELAY - Nostr relay server URL used for peer signalling/discovery
// P2P_TEST_APP_ID - Application ID scoping the P2P session
// P2P_TEST_HANDOFF_FILE - File path used to pass state between up/down test phases
//
// General test options (also read from env):
// ENABLE_DEBUGGER - Set to "true" to attach a debugger and pause before tests
// ENABLE_UI - Set to "true" to open a visible browser window during tests
const loadEnvFile = (path: string) => (existsSync(path) ? parseEnv(readFileSync(path, "utf-8")) : undefined);
const defEnv = loadEnvFile(".env");
const testEnv = loadEnvFile(".test.env");
// Merge: dotenv files < process.env (so shell-injected vars like P2P_TEST_* take precedence)
const p2pEnv: Record<string, string> = {};
if (process.env.P2P_TEST_ROOM_ID) p2pEnv.P2P_TEST_ROOM_ID = process.env.P2P_TEST_ROOM_ID;
if (process.env.P2P_TEST_PASSPHRASE) p2pEnv.P2P_TEST_PASSPHRASE = process.env.P2P_TEST_PASSPHRASE;
if (process.env.P2P_TEST_HOST_PEER_NAME) p2pEnv.P2P_TEST_HOST_PEER_NAME = process.env.P2P_TEST_HOST_PEER_NAME;
if (process.env.P2P_TEST_RELAY) p2pEnv.P2P_TEST_RELAY = process.env.P2P_TEST_RELAY;
if (process.env.P2P_TEST_APP_ID) p2pEnv.P2P_TEST_APP_ID = process.env.P2P_TEST_APP_ID;
if (process.env.P2P_TEST_HANDOFF_FILE) p2pEnv.P2P_TEST_HANDOFF_FILE = process.env.P2P_TEST_HANDOFF_FILE;
const env = Object.assign({}, defEnv, testEnv, p2pEnv);
const debuggerEnabled = env?.ENABLE_DEBUGGER === "true";
const enableUI = env?.ENABLE_UI === "true";
const headless = !debuggerEnabled && !enableUI;
export default mergeConfig(
viteConfig,
defineConfig({
resolve: {
alias: {
obsidian: path.resolve(__dirname, "./test/harness/obsidian-mock.ts"),
},
},
test: {
env: env,
testTimeout: 240000,
hookTimeout: 240000,
fileParallelism: false,
isolate: true,
watch: false,
// Run all CLI-host P2P test files (*.p2p.test.ts, *.p2p-up.test.ts, *.p2p-down.test.ts)
include: ["test/suitep2p/**/*.p2p*.test.ts"],
browser: {
isolate: true,
// Only grantClipboardPermissions is needed; no openWebPeer/acceptWebPeer
commands: {
grantClipboardPermissions,
writeHandoffFile,
readHandoffFile,
},
provider: playwright({
launchOptions: {
args: [
"--js-flags=--expose-gc",
"--allow-insecure-localhost",
"--disable-web-security",
"--ignore-certificate-errors",
],
},
}),
enabled: true,
screenshotFailures: false,
instances: [
{
execArgv: ["--js-flags=--expose-gc"],
browser: "chromium",
headless,
isolate: true,
inspector: debuggerEnabled ? { waitForDebugger: true, enabled: true } : undefined,
printConsoleTrace: true,
onUnhandledError(error) {
const msg = error.message || "";
if (msg.includes("Cannot create so many PeerConnections")) {
return false;
}
},
},
],
headless,
fileParallelism: false,
ui: debuggerEnabled || enableUI ? true : false,
},
},
})
);
-91
View File
@@ -1,91 +0,0 @@
/**
* @file vitest.config.ts
* @description Configuration for running browser-based end-to-end (E2E) integration tests
* using Playwright (Chromium) to test replication and synchronisation scenarios.
* This is executed when running the full test suite via `npm run test` or `npm run test:full`.
*/
import { defineConfig, mergeConfig } from "vitest/config";
import { playwright } from "@vitest/browser-playwright";
import viteConfig from "./vitest.config.common";
import path from "path";
import { existsSync, readFileSync } from "node:fs";
import { parseEnv } from "node:util";
import { grantClipboardPermissions, openWebPeer, closeWebPeer, acceptWebPeer } from "./test/lib/commands";
const loadEnvFile = (path: string) => (existsSync(path) ? parseEnv(readFileSync(path, "utf-8")) : undefined);
const defEnv = loadEnvFile(".env");
const testEnv = loadEnvFile(".test.env");
const env = Object.assign({}, defEnv, testEnv);
const debuggerEnabled = env?.ENABLE_DEBUGGER === "true";
const enableUI = env?.ENABLE_UI === "true";
const headless = !debuggerEnabled && !enableUI;
export default mergeConfig(
viteConfig,
defineConfig({
resolve: {
alias: {
obsidian: path.resolve(__dirname, "./test/harness/obsidian-mock.ts"),
},
},
test: {
env: env,
testTimeout: 40000,
hookTimeout: 50000,
fileParallelism: false,
isolate: true,
watch: false,
// environment: "browser",
include: ["test/**/*.test.ts"],
coverage: {
include: ["src/**/*.ts", "src/**/*.svelte"],
exclude: ["**/*.test.ts"],
provider: "v8",
reporter: ["text", "json", "html"],
// ignoreEmptyLines: true,
},
browser: {
isolate: true,
commands: {
grantClipboardPermissions,
openWebPeer,
closeWebPeer,
acceptWebPeer,
},
provider: playwright({
launchOptions: {
args: ["--js-flags=--expose-gc"],
// chromiumSandbox: true,
},
}),
enabled: true,
screenshotFailures: false,
instances: [
{
execArgv: ["--js-flags=--expose-gc"],
browser: "chromium",
headless,
isolate: true,
inspector: debuggerEnabled
? {
waitForDebugger: true,
enabled: true,
}
: undefined,
printConsoleTrace: debuggerEnabled,
onUnhandledError(error) {
// Ignore certain errors
const msg = error.message || "";
if (msg.includes("Cannot create so many PeerConnections")) {
return false;
}
},
},
],
headless,
fileParallelism: false,
ui: debuggerEnabled || enableUI ? true : false,
},
},
})
);