diff --git a/.github/actions/sdk-e2e-report-finalize/action.yml b/.github/actions/sdk-e2e-report-finalize/action.yml index 2470a1e521..0894a76357 100644 --- a/.github/actions/sdk-e2e-report-finalize/action.yml +++ b/.github/actions/sdk-e2e-report-finalize/action.yml @@ -9,12 +9,13 @@ description: > inputs: family: - description: "SDK e2e family: desktop | electron | android | ios" + description: "SDK e2e family: desktop | electron | snap | android | ios" required: true platforms: description: > JSON array of platform keys that participated in this run, e.g. - ["linux","windows","macos"] for desktop/electron or ["android"] / ["ios"]. + ["linux","windows","macos"] for desktop/electron, ["linux"] for snap, + or ["android"] / ["ios"]. required: true comment-ids: description: > @@ -49,7 +50,7 @@ runs: FAMILY: ${{ inputs.family }} run: | case "$FAMILY" in - desktop|electron) + desktop|electron|snap) echo "results-pattern=results-${FAMILY}-*" >> "$GITHUB_OUTPUT" ;; android) @@ -99,7 +100,8 @@ runs: `/actions/runs/${context.runId}`; const resultsRoot = '.sdk-e2e-report/current-results'; - const isNodeFamily = family === 'desktop' || family === 'electron'; + const isNodeFamily = + family === 'desktop' || family === 'electron' || family === 'snap'; const artifactDir = (platform) => isNodeFamily ? `results-${family}-${platform}` : `results-${family}`; @@ -155,9 +157,8 @@ runs: // actions/download-artifact@v8 extracts a single matched artifact directly // into `path` instead of `path//` (the per-artifact subdir is // only created when the pattern matches >=2 artifacts). For families with one - // platform (android, ios) we therefore probe both layouts. Node consumers - // (desktop/electron) usually have multiple platforms, so the per-artifact - // subdir is created and is preferred. + // platform (android, ios, or snap) may extract directly into `path`. + // Node consumers still use platform-qualified artifact names, so probe both layouts. function findResultsJson(platform) { const candidates = [ path.join(resultsRoot, artifactDir(platform)), diff --git a/.github/workflows/test-node-sdk.yml b/.github/workflows/test-node-sdk.yml index 2a7ea45f53..ce14cacd3f 100644 --- a/.github/workflows/test-node-sdk.yml +++ b/.github/workflows/test-node-sdk.yml @@ -6,7 +6,7 @@ on: workflow_call: inputs: consumer: - description: "Node-compatible consumer to run (desktop or electron)" + description: "Node-compatible consumer to run (desktop, electron, or snap)" required: false type: string default: desktop @@ -140,6 +140,7 @@ jobs: MQTT_PORT: ${{ secrets.MQTT_PORT }} MQTT_USERNAME: ${{ secrets.MQTT_USERNAME }} MQTT_PASSWORD: ${{ secrets.MQTT_PASSWORD }} + QVAC_TEST_SNAP_BUILD_MODE: ${{ startsWith(matrix.os, 'ubuntu-') && 'destructive' || 'lxd' }} steps: - name: Manual Workspace Cleanup run: rm -rf "$GITHUB_WORKSPACE" && mkdir -p "$GITHUB_WORKSPACE" @@ -263,12 +264,21 @@ jobs: working-directory: ${{ inputs.working-directory }} run: bun run build + - name: Remove consumer package credentials + if: inputs.consumer == 'snap' + working-directory: ${{ inputs.working-directory }} + shell: node {0} + run: | + const fs = require('fs'); + fs.rmSync('.npmrc', { force: true }); + - name: Bundle SDK worker (custom plugins) if: inputs.consumer == 'desktop' working-directory: ${{ inputs.working-directory }} run: npm run bundle:sdk - name: Configure SDK cache directory + if: inputs.consumer != 'snap' shell: node {0} run: | const fs = require('fs'); @@ -284,23 +294,23 @@ jobs: - name: Restore models cache id: models-cache - if: inputs.cache-models != false - uses: actions/cache/restore@v4 + if: inputs.consumer != 'snap' && inputs.cache-models != false + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # 5.0.4 with: path: ${{ inputs.working-directory }}/.qvac-cache key: qvac-models-${{ hashFiles('packages/sdk/models/registry/models.ts') }} enableCrossOsArchive: true - name: Bootstrap (download models) - if: steps.models-cache.outputs.cache-hit != 'true' + if: inputs.consumer != 'snap' && steps.models-cache.outputs.cache-hit != 'true' working-directory: ${{ inputs.working-directory }} run: npx qvac-test run:bootstrap:${{ inputs.consumer }} --config=. # Save right after bootstrap. On parallel runners, first to finish reserves the key; # others get a non-fatal "unable to reserve" warning which is expected. - name: Save models cache - if: steps.models-cache.outputs.cache-hit != 'true' && inputs.cache-models != false - uses: actions/cache/save@v4 + if: inputs.consumer != 'snap' && steps.models-cache.outputs.cache-hit != 'true' && inputs.cache-models != false + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # 5.0.4 with: path: ${{ inputs.working-directory }}/.qvac-cache key: qvac-models-${{ hashFiles('packages/sdk/models/registry/models.ts') }} @@ -311,7 +321,107 @@ jobs: working-directory: ${{ inputs.working-directory }} run: npx qvac-test build:consumer:electron --config=. --skip-install + - name: Verify Snap tooling + if: inputs.consumer == 'snap' + run: | + if ! command -v snapcraft; then + sudo snap install snapcraft --classic + fi + if [ "$QVAC_TEST_SNAP_BUILD_MODE" = "lxd" ]; then + if ! command -v lxc; then + echo "::error::LXD is required for non-destructive Snap builds on this runner" + exit 1 + fi + lxc info + fi + snap version + snapcraft --version + + - name: Build first Snap revision + if: inputs.consumer == 'snap' + working-directory: ${{ inputs.working-directory }} + env: + QVAC_TEST_SNAP_BUILD_ID: first + run: npx qvac-test build:consumer:snap --config=. --skip-install + + - name: Preserve first Snap revision + if: inputs.consumer == 'snap' + working-directory: ${{ inputs.working-directory }} + shell: node {0} + run: | + const fs = require('fs'); + fs.copyFileSync('snap/dist/qvac-sdk-e2e.snap', 'snap/dist/qvac-sdk-e2e-first.snap'); + + - name: Build second Snap revision + if: inputs.consumer == 'snap' + working-directory: ${{ inputs.working-directory }} + env: + QVAC_TEST_SNAP_BUILD_ID: second + run: npx qvac-test build:consumer:snap --config=. --skip-install + + - name: Verify Snap refresh persistence + if: inputs.consumer == 'snap' + working-directory: ${{ inputs.working-directory }} + run: >- + npm run test:snap:refresh -- + --first snap/dist/qvac-sdk-e2e-first.snap + --second snap/dist/qvac-sdk-e2e.snap + + - name: Show Snap confinement denials + if: failure() && inputs.consumer == 'snap' + continue-on-error: true + run: >- + sudo journalctl --dmesg --no-pager + --since "10 minutes ago" + --grep 'apparmor="DENIED"|audit' + + - name: Install Snap revision for e2e + if: inputs.consumer == 'snap' + working-directory: ${{ inputs.working-directory }} + run: sudo snap install --dangerous snap/dist/qvac-sdk-e2e.snap + + - name: Determine Snap model requirements + id: snap-model-requirements + if: inputs.consumer == 'snap' + shell: node {0} + env: + TEST_FILTER: ${{ inputs.filter }} + run: | + const fs = require('fs'); + const filters = (process.env.TEST_FILTER || '') + .split(',') + .map((filter) => filter.trim()) + .filter(Boolean); + const requiresModels = + filters.length === 0 || filters.some((filter) => !filter.startsWith('snap-storage')); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `required=${requiresModels}\n`); + + - name: Restore Snap models cache + id: snap-models-cache + if: inputs.consumer == 'snap' && steps.snap-model-requirements.outputs.required == 'true' && inputs.cache-models != false + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # 5.0.4 + with: + path: ~/snap/qvac-sdk-e2e/common/.qvac/models + key: qvac-snap-models-${{ hashFiles('packages/sdk/models/registry/models.ts') }} + + - name: Bootstrap models inside Snap + if: inputs.consumer == 'snap' && steps.snap-model-requirements.outputs.required == 'true' && steps.snap-models-cache.outputs.cache-hit != 'true' + working-directory: ${{ inputs.working-directory }} + run: >- + npx qvac-test run:bootstrap:snap + --config=. + --skip-build + --skip-snap-install + + - name: Save Snap models cache + if: inputs.consumer == 'snap' && steps.snap-model-requirements.outputs.required == 'true' && steps.snap-models-cache.outputs.cache-hit != 'true' && inputs.cache-models != false + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # 5.0.4 + with: + path: ~/snap/qvac-sdk-e2e/common/.qvac/models + key: qvac-snap-models-${{ hashFiles('packages/sdk/models/registry/models.ts') }} + - name: Generate runId + if: always() id: runid shell: node {0} env: @@ -411,6 +521,8 @@ jobs: ]; if (consumerType === 'electron') { consumerArgs.push('--skip-build'); + } else if (consumerType === 'snap') { + consumerArgs.push('--skip-build', '--skip-snap-install'); } const consumer = spawn('npx', consumerArgs, { detached: false, shell: true, stdio: 'inherit' }); @@ -535,6 +647,19 @@ jobs: } } + - name: Upload Snap artifacts + if: always() && inputs.consumer == 'snap' + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # 7.0.0 + with: + name: snap-artifacts-${{ steps.runid.outputs.platform }} + path: ${{ inputs.working-directory }}/snap/dist/*.snap + retention-days: 7 + + - name: Remove Snap consumer + if: always() && inputs.consumer == 'snap' + continue-on-error: true + run: sudo snap remove --purge qvac-sdk-e2e + - name: Upload consumer logs if: always() uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # 7.0.0 diff --git a/.github/workflows/test-sdk.yml b/.github/workflows/test-sdk.yml index bb838026e2..1cee9c4e53 100644 --- a/.github/workflows/test-sdk.yml +++ b/.github/workflows/test-sdk.yml @@ -1,5 +1,5 @@ # QVAC SDK Tests -# Runs desktop and/or mobile (Android/iOS) e2e tests for the SDK package +# Runs desktop, Electron, Snap, and/or mobile e2e tests for the SDK package # # Triggered via: # - workflow_dispatch: manual runs with full customizability @@ -17,6 +17,7 @@ on: - all - desktop - electron + - snap - mobile - electron + desktop - desktop + mobile @@ -65,6 +66,10 @@ on: description: "JSON array of runner labels for electron tests" type: string default: '["qvac-win25-x64-gpu", "qvac-ubuntu2204-x64-gpu", "qvac-macos26-arm64-gpu-gui"]' + snap-platforms: + description: "JSON array of Linux runner labels for strict Snap tests" + type: string + default: '["ubuntu-22.04"]' test-version: description: "Git ref to checkout for the tested project (branch, tag, or SHA). Defaults to current branch HEAD." type: string @@ -173,6 +178,25 @@ jobs: cache-models: ${{ inputs.cache-models }} secrets: inherit + snap-tests: + needs: resolve + # Snap CI is manual-only. It is intentionally excluded from workflow_call + # consumers such as on-pr-test-sdk.yml. + if: github.event_name == 'workflow_dispatch' && (inputs.targets == 'all' || inputs.targets == 'snap') + uses: ./.github/workflows/test-node-sdk.yml + with: + consumer: snap + project-directory: "packages/sdk" + working-directory: "packages/sdk/e2e" + platforms: ${{ inputs.snap-platforms || '["ubuntu-22.04"]' }} + consumer-timeout: ${{ fromJSON(inputs.desktop-consumer-timeout || '60') }} + filter: ${{ inputs.filter }} + suite: ${{ needs.resolve.outputs.suite }} + exclude-suite: ${{ inputs.exclude-suite }} + test-version: ${{ inputs.test-version || '' }} + cache-models: ${{ inputs.cache-models }} + secrets: inherit + android-tests: needs: resolve permissions: diff --git a/packages/sdk/e2e/.gitignore b/packages/sdk/e2e/.gitignore index c3e9698d3a..9e2e2ba74f 100644 --- a/packages/sdk/e2e/.gitignore +++ b/packages/sdk/e2e/.gitignore @@ -1,5 +1,11 @@ build/ out/ +snap/electron-dist/ +snap/dist/ +snap/parts/ +snap/prime/ +snap/stage/ +snap/*.snap .qvac-cache/ .qvac-worker-backup/ qvac.config.json diff --git a/packages/sdk/e2e/README.md b/packages/sdk/e2e/README.md index 0ee4dc5d62..23fc9be36b 100644 --- a/packages/sdk/e2e/README.md +++ b/packages/sdk/e2e/README.md @@ -2,7 +2,7 @@ SDK dogfooding tests built on [`@tetherto/qvac-test-suite`](https://github.com/tetherto/qvac-test-suite). A producer orchestrates a shared queue of tests over MQTT; a consumer runs them on desktop (Node), Electron -(packaged Electron main process), or mobile (Bare + React Native). +(packaged Electron main process), strict Snap, or mobile (Bare + React Native). ## Running locally @@ -13,6 +13,7 @@ cp .env.example .env # only needed if you want to point at a rem npx qvac-test run:local:desktop npx qvac-test run:local:electron --filter completion- +npx qvac-test run:local:snap --filter snap- npx qvac-test run:local:android npx qvac-test run:local:ios ``` @@ -34,6 +35,8 @@ full list. - Electron: Node 22+, Electron Forge dependencies, and a desktop runner capable of launching packaged Electron apps. The config declares `macos`, `windows`, and `linux`; local runs package the current host target unless `--platform` / `--arch` are supplied. +- Snap: Linux with Snapcraft, snapd, a working LXD setup, Xvfb or a graphical session, and permission to + install local snaps with `sudo snap install --dangerous`. ### Rebuilding after changes @@ -53,6 +56,7 @@ Which rebuild command you run depends on what changed. contains the compiled test executors and the SDK. Omit `--skip-build` to rebuild. - **Electron requires a fresh Forge package** to pick up SDK, test-code, or `fixtures/qvac.config.electron.json` changes. Omit `--skip-build` to rebuild. +- **Snap requires a fresh strict Snap** after SDK, test, Electron package, or `snap/snapcraft.yaml` changes. - **`--skip-build` is for fast iteration that doesn't touch compiled code**: re-running the same build with a different `--filter` or `--suite`, or just re-running to debug flakiness. The producer reads definitions fresh each run, so filter / suite changes are picked up without rebuilding. @@ -81,12 +85,44 @@ packaged worker lock. `classification-` runs in Electron through the shared Node executor and bundled `@qvac/classification-ggml` weights; no registry model pre-download is required. +### Strict Snap e2e + +The Snap target wraps the Linux Electron package in `snap/snapcraft.yaml` with strict confinement. Test code, +the SDK worker, addons, fixtures, and assets are included under the read-only `$SNAP` mount. Generated config, +models, Corestore state, and worker locks use writable `$SNAP_USER_COMMON`. + +Electron is deliberately used as the packaging vehicle because the affected Workbench runtime is Electron and +Electron Forge produces a self-contained application containing the SDK worker and native addons. The desktop +Node consumer runs from the host checkout and depends on the host Node.js installation, so wrapping it would +create an artificial package that does not exercise the production addon-packaging boundary. Snap remains a +separate test-framework consumer for installation, confinement, environment, and refresh behavior, while reusing +the same model/addon test surface and skip policy as the regular Electron consumer. + +```bash +npm run install:build:full +npx qvac-test run:local:snap --filter snap- +``` + +`snap-storage-common-root` verifies that strict Snap rewrites `HOME` to revision-specific +`SNAP_USER_DATA` while the SDK worker creates `.qvac/.worker.lock` under `SNAP_USER_COMMON`. Desktop, +mobile, and non-Snap Electron consumers skip this platform-only test. + +CI additionally builds two distinct local Snap artifacts and runs `scripts/run-snap-refresh-test.mjs`. +The first revision records its home/common paths and creates SDK registry Corestore data; the second revision +must have a different revision home while preserving and reopening those SDK-owned files from common storage. +After that preflight, the normal MQTT consumer runs the Electron-supported SDK model and addon tests inside the +installed Snap. + +Use `QVAC_TEST_SNAP_SUDO=0` when snap administration does not require `sudo`. Set +`QVAC_TEST_SNAP_KEEP_INSTALLED=1` to preserve the package and common data after refresh diagnostics. + ### Custom plugin bundling [`fixtures/echo-plugin/`](./fixtures/echo-plugin) is a pure-JS custom plugin (no native addon) used to exercise the SDK plugin system end-to-end: `qvac.config.*` → `bundleSdk` → worker registration → `invokePlugin` / `invokePluginStream`. It's declared as a `custom-echo-plugin` dependency (`file:./fixtures/echo-plugin`) and -listed in the `plugins` array of both `fixtures/qvac.config.e2e.json` and `fixtures/qvac.config.electron.json`, +listed in the `plugins` array of `fixtures/qvac.config.e2e.json` and +`fixtures/qvac.config.electron.json`, the same way a real app would add a third-party or in-repo custom plugin. `PluginExecutor` ([`tests/shared/executors/plugin-executor.ts`](./tests/shared/executors/plugin-executor.ts)) calls the plugin's own client wrapper (`custom-echo-plugin/client`) for the happy-path tests, mirroring how a real consumer would @@ -96,7 +132,8 @@ Both `qvac.config.*` files list built-in plugins explicitly, not just `custom-ec or missing `plugins` array bundles all built-ins by default, but as soon as it's non-empty only the listed plugins are included (see `resolvePluginSpecifiers` in `@qvac/sdk/commands/bundle`). Omitting the built-ins here would silently drop LLM/whisper/OCR/etc. plugin registration from the workers. The Electron config intentionally -omits `sdcpp-generation` and `ggml-vla` (these addons are skipped when running on Electron). +omits `sdcpp-generation` and `ggml-vla` because those addons are skipped by both the regular Electron and Snap +consumers. Each platform bundles the worker with the plugin included through its normal build path: @@ -105,7 +142,7 @@ Each platform bundles the worker with the plugin included through its normal bui `qvac/worker.entry.mjs` at the project root, which is the SDK's standard priority-3 worker resolution path. - **Electron** — `forge.config.cjs` configures `@qvac/sdk/electron-forge` with `configPath: fixtures/qvac.config.electron.json`; the Forge plugin runs `bundleSdk` automatically during - `electron-forge package`. + `electron-forge package`. Snap reuses this packaged Linux application and plugin set. - **Mobile** — `qvac-test.config.js` sets `consumers.mobile.qvacConfig` to `fixtures/qvac.config.e2e.json`. `qvac-test build:consumer:mobile` copies that file into the generated Expo project root as `qvac.config.json` before `expo prebuild`, so the SDK's `withMobileBundle` Expo plugin discovers it and bundles the same plugin @@ -122,9 +159,9 @@ other's bundle. See [`.github/workflows/on-pr-test-sdk.yml`](../../../.github/workflows/on-pr-test-sdk.yml). -- `test-e2e-smoke` — runs the `smoke` suite on all platforms. -- `test-e2e-full` — runs the full catalog on all platforms. -- Release-branch PRs with SDK changes auto-run the full suite. +- `test-e2e-smoke` — runs the `smoke` suite on desktop and mobile consumers. +- `test-e2e-full` — runs the full catalog on desktop and mobile consumers. +- Release-branch PRs with SDK changes auto-run the same desktop and mobile suite. - Success applies the `e2e-tested` label. ### Manual runs @@ -132,6 +169,9 @@ See [`.github/workflows/on-pr-test-sdk.yml`](../../../.github/workflows/on-pr-te Open [Actions → QVAC Tests (sdk) → Run workflow](https://github.com/tetherto/qvac/actions/workflows/test-sdk.yml) and submit the form. +Snap runs are manual-only. Select `snap` to run only the strict Snap consumer, or `all` to include it with +the other manually selected consumers. + Non-obvious inputs: - **"Use workflow from" (GitHub's own selector) vs `test-version`** — these are independent. The selector diff --git a/packages/sdk/e2e/forge.config.cjs b/packages/sdk/e2e/forge.config.cjs index be016b92a0..d02b2e6766 100644 --- a/packages/sdk/e2e/forge.config.cjs +++ b/packages/sdk/e2e/forge.config.cjs @@ -76,6 +76,8 @@ module.exports = { /^\/reports\//, // test run reports /^\/\.env$/, // MQTT creds — read by the CLI orchestrator, not the packaged app /^\/\.env\.bak-/, // env backups + /^\/\.npmrc$/, // registry credentials — required only while installing host dependencies + /^\/snap\//, // Snapcraft outputs — never recursively package previous revisions /^\/\.qvac-worker-backup\// // desktop worker snapshot, restored in postPackage ] }, diff --git a/packages/sdk/e2e/package.json b/packages/sdk/e2e/package.json index 3a8e610c10..f35fed1e2b 100644 --- a/packages/sdk/e2e/package.json +++ b/packages/sdk/e2e/package.json @@ -12,6 +12,8 @@ "install:build": "npm install --install-links && npm run build", "install:build:full": "npm run prepare:sdk && npm run clean:sdk-snapshot && npm run install:build && npm run bundle:sdk", "package:electron": "electron-forge package", + "package:snap": "node scripts/package-snap.mjs", + "test:snap:refresh": "node scripts/run-snap-refresh-test.mjs", "typecheck": "tsc --noEmit", "clean": "rm -rf dist", "clean:sdk-snapshot": "rm -rf node_modules/@qvac/sdk build/consumers/ios/node_modules/@qvac/sdk build/consumers/android/node_modules/@qvac/sdk", @@ -21,7 +23,7 @@ "dependencies": { "@qvac/sdk": "file:..", "custom-echo-plugin": "file:./fixtures/echo-plugin", - "@tetherto/qvac-test-suite": "^0.8.1", + "@tetherto/qvac-test-suite": "0.9.0-tmp.pr-84.runid-29494370490", "mqtt": "^5.14.1", "react-native": "0.81.5", "react-native-bare-kit": "^0.14.0" diff --git a/packages/sdk/e2e/qvac-test.config.js b/packages/sdk/e2e/qvac-test.config.js index 96fc245e6d..f14fee04e1 100644 --- a/packages/sdk/e2e/qvac-test.config.js +++ b/packages/sdk/e2e/qvac-test.config.js @@ -46,6 +46,17 @@ export default { packageManager: 'npm', packageScript: 'package:electron' }, + snap: { + runtime: 'electron', + entry: './app/resources/app/dist/tests/electron/consumer.js', + appDir: '.', + snapName: 'qvac-sdk-e2e', + appCommand: 'qvac-sdk-e2e', + artifactPath: './snap/dist/qvac-sdk-e2e.snap', + snapConfigDir: './app/resources/app', + packageManager: 'npm', + packageScript: 'package:snap' + }, mobile: { platforms: ['ios', 'android'], entry: './dist/tests/mobile/consumer.js', diff --git a/packages/sdk/e2e/scripts/package-snap.mjs b/packages/sdk/e2e/scripts/package-snap.mjs new file mode 100644 index 0000000000..a214760da8 --- /dev/null +++ b/packages/sdk/e2e/scripts/package-snap.mjs @@ -0,0 +1,81 @@ +import { chmodSync, cpSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawnSync } from 'node:child_process' +import config from '../qvac-test.config.js' + +const scriptDir = dirname(fileURLToPath(import.meta.url)) +const projectDir = resolve(scriptDir, '..') +const snapConfig = config.consumers.snap +const electronConfig = config.consumers.electron +const snapDir = join(projectDir, 'snap') +const electronDist = join(snapDir, 'electron-dist') +const packagedSnapApp = join(electronDist, 'app') +const packagedBareBinary = join( + packagedSnapApp, + 'resources', + 'app', + 'node_modules', + `bare-runtime-linux-${process.arch}`, + 'bin', + 'bare' +) +const stableArtifact = resolve(projectDir, snapConfig.artifactPath) +const artifactDir = dirname(stableArtifact) +const electronOutput = join(projectDir, 'out', `${electronConfig.appName}-linux-${process.arch}`) +const snapBuildMode = process.env.QVAC_TEST_SNAP_BUILD_MODE ?? 'lxd' + +function run(command, args, cwd) { + const result = spawnSync(command, args, { + cwd, + env: process.env, + stdio: 'inherit' + }) + if (result.error) throw result.error + if (result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} exited with status ${result.status}`) + } +} + +if (process.platform !== 'linux') { + throw new Error(`Snap packaging requires Linux; current platform is ${process.platform}`) +} + +rmSync(electronDist, { recursive: true, force: true }) + +run( + 'npm', + ['run', 'package:electron', '--', '--platform=linux', `--arch=${process.arch}`], + projectDir +) + +mkdirSync(electronDist, { recursive: true }) +cpSync(electronOutput, packagedSnapApp, { recursive: true }) +// bare-runtime normally repairs this mode at startup, but a mounted Snap is read-only. +chmodSync(packagedBareBinary, 0o755) +writeFileSync( + join(electronDist, 'qvac-snap-build-id'), + `${process.env.QVAC_TEST_SNAP_BUILD_ID ?? Date.now()}\n` +) + +for (const fileName of readdirSync(snapDir)) { + if (fileName.endsWith('.snap')) rmSync(join(snapDir, fileName), { force: true }) +} + +if (snapBuildMode === 'lxd') { + run('snapcraft', ['--use-lxd'], snapDir) +} else if (snapBuildMode === 'destructive') { + run('sudo', ['snapcraft', '--destructive-mode'], snapDir) +} else { + throw new Error(`Unsupported QVAC_TEST_SNAP_BUILD_MODE: ${snapBuildMode}`) +} + +const builtArtifact = readdirSync(snapDir).find((fileName) => fileName.endsWith('.snap')) +if (!builtArtifact) { + throw new Error(`Snapcraft did not produce a .snap artifact in ${snapDir}`) +} + +mkdirSync(artifactDir, { recursive: true }) +rmSync(stableArtifact, { force: true }) +cpSync(join(snapDir, builtArtifact), stableArtifact) +console.log(`Snap artifact: ${stableArtifact}`) diff --git a/packages/sdk/e2e/scripts/run-snap-refresh-test.mjs b/packages/sdk/e2e/scripts/run-snap-refresh-test.mjs new file mode 100644 index 0000000000..3c252e6cb7 --- /dev/null +++ b/packages/sdk/e2e/scripts/run-snap-refresh-test.mjs @@ -0,0 +1,102 @@ +import { spawnSync } from 'node:child_process' +import { posix, resolve } from 'node:path' +import config from '../qvac-test.config.js' + +const snapConfig = config.consumers.snap +const SNAP_NAME = snapConfig.snapName + +function resolveMountedPath(relativePath) { + const normalized = posix.normalize(relativePath) + if (posix.isAbsolute(relativePath) || normalized === '..' || normalized.startsWith('../')) { + throw new Error(`Path must stay inside the Snap mount: ${relativePath}`) + } + return posix.join('/snap', SNAP_NAME, 'current', normalized) +} + +const SNAP_ENTRY = resolveMountedPath(snapConfig.entry) +const SNAP_CONFIG_DIR = resolveMountedPath(snapConfig.snapConfigDir) + +function readOption(name) { + const index = process.argv.indexOf(name) + return index === -1 ? undefined : process.argv[index + 1] +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + env: options.env ?? process.env, + stdio: 'inherit' + }) + if (result.error) throw result.error + if (result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} exited with status ${result.status}`) + } +} + +function runSnapAdmin(args) { + if (process.env.QVAC_TEST_SNAP_SUDO === '0') { + run('snap', args) + return + } + run('sudo', ['snap', ...args]) +} + +function hasXvfbRun() { + const result = spawnSync('xvfb-run', ['--help'], { stdio: 'ignore' }) + return !result.error +} + +function isSnapInstalled() { + const result = spawnSync('snap', ['list', SNAP_NAME], { stdio: 'ignore' }) + return !result.error && result.status === 0 +} + +function runProbe(phase) { + const env = { + ...process.env, + QVAC_TEST_MODE: 'storage-probe', + QVAC_TEST_PLATFORM: 'snap-linux', + QVAC_TEST_RUN_ID: `snap-refresh-${phase}`, + QVAC_TEST_CONFIG_DIR: SNAP_CONFIG_DIR, + QVAC_TEST_CONSUMER_ENTRY: SNAP_ENTRY, + QVAC_TEST_SNAP_PROBE_PHASE: phase + } + delete env.QVAC_CONFIG_PATH + + if (!env.DISPLAY && hasXvfbRun()) { + run('xvfb-run', ['-a', '--server-args=-screen 0 1280x1024x24', 'snap', 'run', SNAP_NAME], { + env + }) + return + } + + const args = ['run', SNAP_NAME] + if (!env.DISPLAY) { + args.push('--headless', '--disable-gpu', '--no-sandbox', '--ozone-platform=headless') + } + run('snap', args, { env }) +} + +const firstArtifact = readOption('--first') +const secondArtifact = readOption('--second') +if (!firstArtifact || !secondArtifact) { + throw new Error('Usage: run-snap-refresh-test.mjs --first --second ') +} + +try { + if (isSnapInstalled()) { + runSnapAdmin(['remove', '--purge', SNAP_NAME]) + } + runSnapAdmin(['install', '--dangerous', resolve(firstArtifact)]) + runProbe('before') + // Installing another local revision of the same Snap is treated as a refresh. + runSnapAdmin(['install', '--dangerous', resolve(secondArtifact)]) + runProbe('after') +} finally { + if (process.env.QVAC_TEST_SNAP_KEEP_INSTALLED !== '1') { + try { + runSnapAdmin(['remove', '--purge', SNAP_NAME]) + } catch (error) { + console.warn(`Failed to remove ${SNAP_NAME}: ${String(error)}`) + } + } +} diff --git a/packages/sdk/e2e/snap/snapcraft.yaml b/packages/sdk/e2e/snap/snapcraft.yaml new file mode 100644 index 0000000000..c9ee1b6ff8 --- /dev/null +++ b/packages/sdk/e2e/snap/snapcraft.yaml @@ -0,0 +1,47 @@ +name: qvac-sdk-e2e +base: core22 +version: '0.1.0' +summary: QVAC SDK strict Snap end-to-end consumer +description: | + Packaged Electron consumer used to validate the QVAC SDK, Bare worker, + native addons, and stable storage paths under strict Snap confinement. +grade: devel +confinement: strict + +apps: + qvac-sdk-e2e: + # Strict Snap confinement provides the outer sandbox. Chromium's nested + # credential sandbox is blocked by Snap seccomp on hosted runners. + command: app/QVACSDKElectronE2E --no-sandbox + extensions: + - gnome + # Keep runtime interfaces aligned with the production Workbench Snap so + # Electron, the Bare worker, and native model backends run under the same + # confinement contract. + plugs: + - audio-playback + - audio-record + - browser-support + - hardware-observe + - home + - network + - network-bind + - opengl + - process-control + - shmem + environment: + TMPDIR: $XDG_RUNTIME_DIR + GTK_USE_PORTAL: '1' + +parts: + electron-app: + plugin: dump + source: electron-dist + stage-packages: + - libasound2 + - libnss3 + +plugs: + shmem: + interface: shared-memory + private: true diff --git a/packages/sdk/e2e/tests/desktop/consumer.ts b/packages/sdk/e2e/tests/desktop/consumer.ts index c792ef9b14..95b133dd9a 100644 --- a/packages/sdk/e2e/tests/desktop/consumer.ts +++ b/packages/sdk/e2e/tests/desktop/consumer.ts @@ -1,4 +1,4 @@ -import { createExecutor, type TestDefinition } from '@tetherto/qvac-test-suite' +import { createExecutor, SkipExecutor, type TestDefinition } from '@tetherto/qvac-test-suite' import { profiler, LLAMA_3_2_1B_INST_Q4_0, @@ -525,6 +525,10 @@ export async function bootstrap(filteredTests?: TestDefinition[]) { export const executor = createExecutor({ handlers: [ + new SkipExecutor( + /^snap-storage-/, + 'Snap storage tests require the strict-confined Snap consumer' + ), new ModelLoadingExecutor(resources), new BatchCompletionExecutor(resources, { resolveAttachmentPath: resolveBatchAttachmentPath diff --git a/packages/sdk/e2e/tests/electron/consumer.ts b/packages/sdk/e2e/tests/electron/consumer.ts index 6e1ef5be3b..593f0c4917 100644 --- a/packages/sdk/e2e/tests/electron/consumer.ts +++ b/packages/sdk/e2e/tests/electron/consumer.ts @@ -83,6 +83,10 @@ import { MultiGpuExecutor } from '../shared/executors/multi-gpu-executor.js' import { BatchCompletionExecutor } from '../shared/executors/batch-completion-executor.js' import { NodeCancellationExecutor } from '../shared/executors/node/cancellation-executor.js' import { PluginExecutor } from '../shared/executors/plugin-executor.js' +import { SnapStorageExecutor } from '../shared/executors/node/snap-storage-executor.js' +import { runSnapRefreshProbe as executeSnapRefreshProbe } from './snap-refresh-probe.js' + +const isSnapConsumer = process.env['QVAC_TEST_PLATFORM'] === 'snap-linux' const resources = new ResourceManager({ downloadTarget: 'desktop' @@ -388,6 +392,11 @@ function readJsonConfig(configPath: string) { return JSON.parse(fs.readFileSync(configPath, 'utf8')) as Record } +function resolveElectronRuntimeDir() { + const snapCommon = process.env['SNAP_USER_COMMON'] + return snapCommon ? path.join(snapCommon, 'qvac-test-runtime') : process.cwd() +} + function resolveBatchAttachmentPath(inputPath: string) { const fileName = inputPath.split('/').pop() if (!fileName) return inputPath @@ -395,18 +404,22 @@ function resolveBatchAttachmentPath(inputPath: string) { } function ensureElectronE2EConfig() { - const electronFixturePath = path.resolve(process.cwd(), 'fixtures/qvac.config.electron.json') - const e2eFixturePath = path.resolve(process.cwd(), 'fixtures/qvac.config.e2e.json') + const configDir = process.cwd() + const runtimeDir = resolveElectronRuntimeDir() + const electronFixturePath = path.resolve(configDir, 'fixtures/qvac.config.electron.json') + const e2eFixturePath = path.resolve(configDir, 'fixtures/qvac.config.e2e.json') const existingPath = process.env['QVAC_CONFIG_PATH'] const electronFixtureConfig = readJsonConfig(electronFixturePath) const e2eFixtureConfig = readJsonConfig(e2eFixturePath) - const existingConfig = existingPath ? readJsonConfig(existingPath) : {} + const existingConfig = + existingPath && fs.existsSync(existingPath) ? readJsonConfig(existingPath) : {} const mergedConfig = { ...electronFixtureConfig, ...e2eFixtureConfig, ...existingConfig } - const generatedPath = path.resolve(process.cwd(), 'qvac.config.e2e.generated.json') + fs.mkdirSync(runtimeDir, { recursive: true }) + const generatedPath = path.resolve(runtimeDir, 'qvac.config.e2e.generated.json') fs.writeFileSync(generatedPath, `${JSON.stringify(mergedConfig, null, 2)}\n`) process.env['QVAC_CONFIG_PATH'] = generatedPath @@ -427,8 +440,20 @@ export async function bootstrap(filteredTests?: TestDefinition[]) { await resources.downloadAllOnce(console.log, { allowedDeps }) } +export async function runSnapRefreshProbe() { + await executeSnapRefreshProbe(ensureElectronE2EConfig) +} + +const snapStorageHandler = isSnapConsumer + ? new SnapStorageExecutor() + : new SkipExecutor( + /^snap-storage-/, + 'Snap storage tests require the strict-confined Snap consumer' + ) + export const executor = createExecutor({ handlers: [ + snapStorageHandler, // Electron keeps the stable desktop/shared surface enabled, but excludes // suites that are resource-heavy or incompatible with the packaged // Electron worker lifecycle. diff --git a/packages/sdk/e2e/tests/electron/main.ts b/packages/sdk/e2e/tests/electron/main.ts index 7aff1cbcba..52b3cc715d 100644 --- a/packages/sdk/e2e/tests/electron/main.ts +++ b/packages/sdk/e2e/tests/electron/main.ts @@ -5,13 +5,42 @@ app.commandLine.appendSwitch('no-sandbox') async function startConfiguredConsumer() { const entryPath = process.env['QVAC_TEST_CONSUMER_ENTRY'] + const configDir = process.env['QVAC_TEST_CONFIG_DIR'] if (!entryPath) { throw new Error('QVAC_TEST_CONSUMER_ENTRY is required') } + if (!configDir) { + throw new Error('QVAC_TEST_CONFIG_DIR is required') + } + + // Strict Snap may inherit a host checkout cwd that is outside confinement. + // Use the packaged config root so existing Electron asset resolution remains unchanged. + process.chdir(configDir) const entry = (await import(pathToFileURL(entryPath).href)) as { startElectronConsumer?: () => Promise + bootstrap?: () => Promise + runSnapRefreshProbe?: () => Promise + } + + if (process.env['QVAC_TEST_MODE'] === 'storage-probe') { + if (typeof entry.runSnapRefreshProbe !== 'function') { + throw new Error(`Electron consumer entry must export runSnapRefreshProbe(): ${entryPath}`) + } + await entry.runSnapRefreshProbe() + app.quit() + return } + + if (process.env['QVAC_TEST_MODE'] === 'bootstrap') { + if (typeof entry.bootstrap !== 'function') { + throw new Error(`Electron consumer entry must export bootstrap(): ${entryPath}`) + } + await entry.bootstrap() + app.quit() + return + } + if (typeof entry.startElectronConsumer !== 'function') { throw new Error(`Electron consumer entry must export startElectronConsumer(): ${entryPath}`) } diff --git a/packages/sdk/e2e/tests/electron/snap-refresh-probe.ts b/packages/sdk/e2e/tests/electron/snap-refresh-probe.ts new file mode 100644 index 0000000000..b4d010d52b --- /dev/null +++ b/packages/sdk/e2e/tests/electron/snap-refresh-probe.ts @@ -0,0 +1,114 @@ +import { close, heartbeat, modelRegistryList } from '@qvac/sdk' +import * as fs from 'node:fs' +import * as path from 'node:path' + +interface SnapRefreshMarker { + home: string + registryFiles: string[] + revision: string + snapCommon: string +} + +function listRelativeFiles(root: string, current: string = root): string[] { + if (!fs.existsSync(current)) return [] + + const files: string[] = [] + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const entryPath = path.join(current, entry.name) + if (entry.isDirectory()) { + files.push(...listRelativeFiles(root, entryPath)) + } else if (entry.isFile()) { + files.push(path.relative(root, entryPath)) + } + } + return files.sort() +} + +function readMarker(markerPath: string): SnapRefreshMarker { + return JSON.parse(fs.readFileSync(markerPath, 'utf8')) as SnapRefreshMarker +} + +export async function runSnapRefreshProbe(prepareConfig: () => void) { + prepareConfig() + + const home = process.env['HOME'] + const revision = process.env['SNAP_REVISION'] + const snapCommon = process.env['SNAP_USER_COMMON'] + const phase = process.env['QVAC_TEST_SNAP_PROBE_PHASE'] + if (!home || !revision || !snapCommon || !phase) { + throw new Error( + 'Snap refresh probe requires HOME, SNAP_REVISION, SNAP_USER_COMMON, and QVAC_TEST_SNAP_PROBE_PHASE' + ) + } + + const runtimeDir = path.join(snapCommon, 'qvac-test-runtime') + const markerPath = path.join(runtimeDir, 'snap-refresh-marker.json') + const registryRoot = path.join(snapCommon, '.qvac', 'registry-corestore') + const workerLock = path.join(snapCommon, '.qvac', '.worker.lock') + + if (phase === 'before') { + await heartbeat() + if (!fs.existsSync(workerLock)) { + throw new Error(`SDK worker lock was not created in Snap common storage: ${workerLock}`) + } + if (fs.existsSync(path.join(home, '.qvac'))) { + throw new Error(`SDK unexpectedly created revision-scoped storage under ${home}`) + } + + await modelRegistryList() + await close() + const registryFiles = listRelativeFiles(registryRoot) + if (registryFiles.length === 0) { + throw new Error( + `SDK registry did not create persistent Corestore files under ${registryRoot}` + ) + } + + const marker: SnapRefreshMarker = { home, registryFiles, revision, snapCommon } + fs.writeFileSync(markerPath, `${JSON.stringify(marker, null, 2)}\n`) + } else if (phase === 'after') { + const marker = readMarker(markerPath) + if (marker.snapCommon !== snapCommon) { + throw new Error( + `SNAP_USER_COMMON changed across refresh: ${marker.snapCommon} -> ${snapCommon}` + ) + } + if (marker.home === home || marker.revision === revision) { + throw new Error( + `Snap revision did not change: HOME ${marker.home} -> ${home}, revision ${marker.revision} -> ${revision}` + ) + } + + const registryFiles = listRelativeFiles(registryRoot) + const missingRegistryFiles = marker.registryFiles.filter( + (fileName) => !registryFiles.includes(fileName) + ) + if (missingRegistryFiles.length > 0) { + throw new Error( + `SDK registry files disappeared across refresh: ${missingRegistryFiles.join(', ')}` + ) + } + + await modelRegistryList() + if (!fs.existsSync(workerLock)) { + throw new Error(`SDK worker did not reopen with common storage after refresh: ${workerLock}`) + } + if (fs.existsSync(path.join(home, '.qvac'))) { + throw new Error(`SDK unexpectedly created revision-scoped storage under ${home}`) + } + await close() + } else { + throw new Error(`Unknown Snap refresh probe phase: ${phase}`) + } + + console.log( + JSON.stringify({ + phase, + home, + revision, + snapCommon, + workerLock, + registryFiles: listRelativeFiles(registryRoot) + }) + ) +} diff --git a/packages/sdk/e2e/tests/mobile/consumer.ts b/packages/sdk/e2e/tests/mobile/consumer.ts index 8954129e6e..5287c85398 100644 --- a/packages/sdk/e2e/tests/mobile/consumer.ts +++ b/packages/sdk/e2e/tests/mobile/consumer.ts @@ -502,6 +502,10 @@ export async function bootstrap(filteredTests?: TestDefinition[]) { export const executor = createExecutor({ handlers: [ // Mobile platform skips (before real executors -- first match wins) + new SkipExecutor( + /^snap-storage-/, + 'Snap storage tests require the strict-confined Snap consumer' + ), new SkipExecutor(/^http-(?:sharded|archive)-embed-/, 'HTTP test disabled on mobile (OOM)'), new SkipExecutor(/^finetune-/, 'Finetune tests disabled on mobile'), new SkipExecutor( diff --git a/packages/sdk/e2e/tests/shared/executors/node/download-resilience-executor.ts b/packages/sdk/e2e/tests/shared/executors/node/download-resilience-executor.ts index 7d5015ab5d..bc50b7c715 100644 --- a/packages/sdk/e2e/tests/shared/executors/node/download-resilience-executor.ts +++ b/packages/sdk/e2e/tests/shared/executors/node/download-resilience-executor.ts @@ -48,7 +48,8 @@ function singleFileCachePath(registryPath: string): string { .update(Buffer.from(registryPath, 'utf8')) .digest('hex') .substring(0, 16) - return path.join(os.homedir(), '.qvac', 'models', `${hash}_${filename}`) + const sdkHome = process.env['SNAP_USER_COMMON'] || os.homedir() + return path.join(sdkHome, '.qvac', 'models', `${hash}_${filename}`) } function withTimeout(label: string, p: Promise, ms: number): Promise { diff --git a/packages/sdk/e2e/tests/shared/executors/node/snap-storage-executor.ts b/packages/sdk/e2e/tests/shared/executors/node/snap-storage-executor.ts new file mode 100644 index 0000000000..364e199296 --- /dev/null +++ b/packages/sdk/e2e/tests/shared/executors/node/snap-storage-executor.ts @@ -0,0 +1,69 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { heartbeat } from '@qvac/sdk' +import { BaseExecutor, type TestResult } from '@tetherto/qvac-test-suite' +import { snapStorageTests } from '../../../snap-storage-tests.js' + +export class SnapStorageExecutor extends BaseExecutor { + pattern = /^snap-storage-/ + + protected handlers = { + 'snap-storage-common-root': this.verifyCommonRoot.bind(this) + } + + private async verifyCommonRoot(): Promise { + const snapCommon = process.env['SNAP_USER_COMMON'] + const snapUserData = process.env['SNAP_USER_DATA'] + const home = process.env['HOME'] + const revision = process.env['SNAP_REVISION'] + + if (!snapCommon || !snapUserData || !home || !revision) { + return { + passed: false, + output: + 'Expected SNAP_USER_COMMON, SNAP_USER_DATA, HOME, and SNAP_REVISION inside the Snap runtime' + } + } + + if (home !== snapUserData) { + return { + passed: false, + output: `Expected HOME to equal SNAP_USER_DATA; HOME=${home}, SNAP_USER_DATA=${snapUserData}` + } + } + + if (snapCommon === home) { + return { + passed: false, + output: `Expected common storage to differ from revision HOME: ${home}` + } + } + + await heartbeat() + + const commonLock = join(snapCommon, '.qvac', '.worker.lock') + const revisionQvacDir = join(home, '.qvac') + if (!existsSync(commonLock)) { + return { + passed: false, + output: `SDK worker lock was not created in Snap common storage: ${commonLock}` + } + } + if (existsSync(revisionQvacDir)) { + return { + passed: false, + output: `SDK unexpectedly created revision-scoped storage: ${revisionQvacDir}` + } + } + + return { + passed: true, + output: JSON.stringify({ + home, + revision, + snapCommon, + workerLock: commonLock + }) + } + } +} diff --git a/packages/sdk/e2e/tests/snap-storage-tests.ts b/packages/sdk/e2e/tests/snap-storage-tests.ts new file mode 100644 index 0000000000..9543f7dbb1 --- /dev/null +++ b/packages/sdk/e2e/tests/snap-storage-tests.ts @@ -0,0 +1,15 @@ +import type { TestDefinition } from '@tetherto/qvac-test-suite' + +export const snapStorageTests: TestDefinition[] = [ + { + testId: 'snap-storage-common-root', + params: {}, + expectation: { validation: 'type', expectedType: 'string' }, + suites: ['snap'], + metadata: { + category: 'snap', + dependency: 'none', + estimatedDurationMs: 30_000 + } + } +] diff --git a/packages/sdk/e2e/tests/test-definitions.ts b/packages/sdk/e2e/tests/test-definitions.ts index d53a612d44..d0f6fcb8d9 100644 --- a/packages/sdk/e2e/tests/test-definitions.ts +++ b/packages/sdk/e2e/tests/test-definitions.ts @@ -41,6 +41,7 @@ import { multiGpuTests } from './multi-gpu-tests.js' import { cancellationTests } from './cancellation-tests.js' import { vlaTests } from './vla-tests.js' import { pluginTests } from './plugin-tests.js' +import { snapStorageTests } from './snap-storage-tests.js' // Model loading tests export const modelLoadLlm: TestDefinition = { @@ -326,6 +327,9 @@ export const tests = [ // Custom plugin system tests (custom-echo-plugin, error paths) ...pluginTests, + // Strict Snap storage-path conformance + ...snapStorageTests, + // Additional model tests modelSwitchLlm, modelReloadAfterError, diff --git a/packages/sdk/test/unit/snap-worker-storage.test.ts b/packages/sdk/test/unit/snap-worker-storage.test.ts new file mode 100644 index 0000000000..050c6ad972 --- /dev/null +++ b/packages/sdk/test/unit/snap-worker-storage.test.ts @@ -0,0 +1,52 @@ +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'brittle' +import { heartbeat } from '@/client/api/heartbeat' +import { close } from '@/client/rpc/rpc-client' + +function restoreEnv(name: string, value: string | undefined) { + if (value === undefined) { + delete process.env[name] + return + } + process.env[name] = value +} + +void test('worker storage remains in Snap common across revision homes', async function (t) { + t.timeout(30_000) + + const root = mkdtempSync(join(tmpdir(), 'qvac-snap-storage-')) + const commonDir = join(root, 'common') + const firstRevisionHome = join(root, 'revision-1') + const secondRevisionHome = join(root, 'revision-2') + const commonLock = join(commonDir, '.qvac', '.worker.lock') + const originalSnapCommon = process.env['SNAP_USER_COMMON'] + const originalHome = process.env['HOME'] + const originalWorkerPath = process.env['QVAC_WORKER_PATH'] + + t.teardown(async function () { + try { + await close() + } catch {} + restoreEnv('SNAP_USER_COMMON', originalSnapCommon) + restoreEnv('HOME', originalHome) + restoreEnv('QVAC_WORKER_PATH', originalWorkerPath) + rmSync(root, { recursive: true, force: true }) + }) + + process.env['SNAP_USER_COMMON'] = commonDir + process.env['HOME'] = firstRevisionHome + delete process.env['QVAC_WORKER_PATH'] + + await heartbeat() + t.ok(existsSync(commonLock), 'first worker lock is stored in Snap common') + t.absent(existsSync(join(firstRevisionHome, '.qvac')), 'first revision home remains unused') + + await close() + process.env['HOME'] = secondRevisionHome + + await heartbeat() + t.ok(existsSync(commonLock), 'replacement worker reuses Snap common') + t.absent(existsSync(join(secondRevisionHome, '.qvac')), 'second revision home remains unused') +})