From acc4489a21d703a830adbf74ff8a43dcdf124faa Mon Sep 17 00:00:00 2001 From: Victor Rodzko Date: Wed, 15 Jul 2026 19:06:04 +0300 Subject: [PATCH 01/12] test[skiplog]: add strict Snap SDK e2e coverage --- .github/workflows/test-node-sdk.yml | 109 ++++++++++++++++- .github/workflows/test-sdk.yml | 26 +++- packages/sdk/e2e/.gitignore | 6 + packages/sdk/e2e/README.md | 54 +++++++-- packages/sdk/e2e/forge.config.cjs | 2 + packages/sdk/e2e/package.json | 4 +- packages/sdk/e2e/qvac-test.config.js | 11 ++ packages/sdk/e2e/scripts/package-snap.mjs | 63 ++++++++++ .../sdk/e2e/scripts/run-snap-refresh-test.mjs | 101 ++++++++++++++++ packages/sdk/e2e/snap/snapcraft.yaml | 31 +++++ packages/sdk/e2e/tests/desktop/consumer.ts | 6 +- packages/sdk/e2e/tests/electron/consumer.ts | 33 ++++- packages/sdk/e2e/tests/electron/main.ts | 29 +++++ .../e2e/tests/electron/snap-refresh-probe.ts | 114 ++++++++++++++++++ packages/sdk/e2e/tests/mobile/consumer.ts | 4 + .../node/download-resilience-executor.ts | 3 +- .../executors/node/snap-storage-executor.ts | 69 +++++++++++ packages/sdk/e2e/tests/snap-storage-tests.ts | 15 +++ packages/sdk/e2e/tests/test-definitions.ts | 4 + .../sdk/test/unit/snap-worker-storage.test.ts | 52 ++++++++ 20 files changed, 715 insertions(+), 21 deletions(-) create mode 100644 packages/sdk/e2e/scripts/package-snap.mjs create mode 100644 packages/sdk/e2e/scripts/run-snap-refresh-test.mjs create mode 100644 packages/sdk/e2e/snap/snapcraft.yaml create mode 100644 packages/sdk/e2e/tests/electron/snap-refresh-probe.ts create mode 100644 packages/sdk/e2e/tests/shared/executors/node/snap-storage-executor.ts create mode 100644 packages/sdk/e2e/tests/snap-storage-tests.ts create mode 100644 packages/sdk/test/unit/snap-worker-storage.test.ts diff --git a/.github/workflows/test-node-sdk.yml b/.github/workflows/test-node-sdk.yml index 2a7ea45f53..7092ee31f6 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 @@ -263,12 +263,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 +293,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,6 +320,79 @@ 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 ! command -v lxc; then + echo "::error::LXD is required for non-destructive Snap builds on this runner" + exit 1 + fi + snap version + snapcraft --version + lxc info + + - 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: 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: Restore Snap models cache + id: snap-models-cache + if: inputs.consumer == 'snap' && 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-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-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 id: runid shell: node {0} @@ -411,6 +493,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 +619,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..b408892851 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: '["qvac-ubuntu2204-x64-gpu"]' 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 || '["qvac-ubuntu2204-x64-gpu"]' }} + 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..357a690d5b 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-29430579062", "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..37880c4a5c --- /dev/null +++ b/packages/sdk/e2e/scripts/package-snap.mjs @@ -0,0 +1,63 @@ +import { 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 stableArtifact = resolve(projectDir, snapConfig.artifactPath) +const artifactDir = dirname(stableArtifact) +const electronOutput = join(projectDir, 'out', `${electronConfig.appName}-linux-${process.arch}`) + +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 }) +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 }) +} + +run('snapcraft', ['--use-lxd'], snapDir) + +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..8b6b07645d --- /dev/null +++ b/packages/sdk/e2e/scripts/run-snap-refresh-test.mjs @@ -0,0 +1,101 @@ +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') + runSnapAdmin(['refresh', '--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..0a935bc7fd --- /dev/null +++ b/packages/sdk/e2e/snap/snapcraft.yaml @@ -0,0 +1,31 @@ +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: + command: app/QVACSDKElectronE2E + extensions: + - gnome + plugs: + - audio-playback + - network + - network-bind + - opengl + environment: + TMPDIR: $XDG_RUNTIME_DIR + GTK_USE_PORTAL: '1' + +parts: + electron-app: + plugin: dump + source: electron-dist + stage-packages: + - libasound2 + - libnss3 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') +}) From 2d4e288274bdc0728e6c99bfb1406e52e0e9d51f Mon Sep 17 00:00:00 2001 From: Victor Rodzko Date: Wed, 15 Jul 2026 20:08:41 +0300 Subject: [PATCH 02/12] fix[skiplog]: support hosted Snap validation --- .github/workflows/test-node-sdk.yml | 33 ++++++++++++++++++----- packages/sdk/e2e/scripts/package-snap.mjs | 9 ++++++- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test-node-sdk.yml b/.github/workflows/test-node-sdk.yml index 7092ee31f6..697f910b92 100644 --- a/.github/workflows/test-node-sdk.yml +++ b/.github/workflows/test-node-sdk.yml @@ -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" @@ -326,13 +327,15 @@ jobs: if ! command -v snapcraft; then sudo snap install snapcraft --classic fi - if ! command -v lxc; then - echo "::error::LXD is required for non-destructive Snap builds on this runner" - exit 1 + 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 - lxc info - name: Build first Snap revision if: inputs.consumer == 'snap' @@ -369,16 +372,32 @@ jobs: 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' && inputs.cache-models != false + 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-models-cache.outputs.cache-hit != 'true' + 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 @@ -387,7 +406,7 @@ jobs: --skip-snap-install - name: Save Snap models cache - if: inputs.consumer == 'snap' && steps.snap-models-cache.outputs.cache-hit != 'true' && inputs.cache-models != false + 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 diff --git a/packages/sdk/e2e/scripts/package-snap.mjs b/packages/sdk/e2e/scripts/package-snap.mjs index 37880c4a5c..3853fb5703 100644 --- a/packages/sdk/e2e/scripts/package-snap.mjs +++ b/packages/sdk/e2e/scripts/package-snap.mjs @@ -14,6 +14,7 @@ const packagedSnapApp = join(electronDist, 'app') 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, { @@ -50,7 +51,13 @@ for (const fileName of readdirSync(snapDir)) { if (fileName.endsWith('.snap')) rmSync(join(snapDir, fileName), { force: true }) } -run('snapcraft', ['--use-lxd'], snapDir) +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) { From a491205bb7e9b2e569096f609f5973ba1b8c6e21 Mon Sep 17 00:00:00 2001 From: Victor Rodzko Date: Wed, 15 Jul 2026 20:23:24 +0300 Subject: [PATCH 03/12] fix[skiplog]: unblock strict Snap validation --- .../actions/sdk-e2e-report-finalize/action.yml | 15 ++++++++------- packages/sdk/e2e/snap/snapcraft.yaml | 4 +++- 2 files changed, 11 insertions(+), 8 deletions(-) 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/packages/sdk/e2e/snap/snapcraft.yaml b/packages/sdk/e2e/snap/snapcraft.yaml index 0a935bc7fd..2f48652633 100644 --- a/packages/sdk/e2e/snap/snapcraft.yaml +++ b/packages/sdk/e2e/snap/snapcraft.yaml @@ -10,7 +10,9 @@ confinement: strict apps: qvac-sdk-e2e: - command: app/QVACSDKElectronE2E + # 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 plugs: From c2cf21c32f6a142c97c426fcf2f27380dea985a3 Mon Sep 17 00:00:00 2001 From: Victor Rodzko Date: Wed, 15 Jul 2026 20:37:31 +0300 Subject: [PATCH 04/12] fix[skiplog]: use writable Chromium shared memory --- packages/sdk/e2e/snap/snapcraft.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/e2e/snap/snapcraft.yaml b/packages/sdk/e2e/snap/snapcraft.yaml index 2f48652633..784173026b 100644 --- a/packages/sdk/e2e/snap/snapcraft.yaml +++ b/packages/sdk/e2e/snap/snapcraft.yaml @@ -12,7 +12,7 @@ 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 + command: app/QVACSDKElectronE2E --no-sandbox --disable-dev-shm-usage extensions: - gnome plugs: From 500829e4db0b207ec5ffadb777fb44aa33f79f64 Mon Sep 17 00:00:00 2001 From: Victor Rodzko Date: Thu, 16 Jul 2026 09:16:43 +0300 Subject: [PATCH 05/12] fix[skiplog]: preserve Bare executable mode in Snap --- packages/sdk/e2e/scripts/package-snap.mjs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/sdk/e2e/scripts/package-snap.mjs b/packages/sdk/e2e/scripts/package-snap.mjs index 3853fb5703..a214760da8 100644 --- a/packages/sdk/e2e/scripts/package-snap.mjs +++ b/packages/sdk/e2e/scripts/package-snap.mjs @@ -1,4 +1,4 @@ -import { cpSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +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' @@ -11,6 +11,15 @@ 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}`) @@ -42,6 +51,8 @@ run( 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` From 4271d6bfed5d137a4fafdbd76367696d9f3a8a86 Mon Sep 17 00:00:00 2001 From: Victor Rodzko Date: Thu, 16 Jul 2026 09:31:54 +0300 Subject: [PATCH 06/12] infra[skiplog]: capture Snap confinement denials --- .github/workflows/test-node-sdk.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/test-node-sdk.yml b/.github/workflows/test-node-sdk.yml index 697f910b92..9e969ff363 100644 --- a/.github/workflows/test-node-sdk.yml +++ b/.github/workflows/test-node-sdk.yml @@ -367,6 +367,14 @@ jobs: --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 }} From 47b785e21a8a3a84ad8c6352a8c74f39b0bb93af Mon Sep 17 00:00:00 2001 From: Victor Rodzko Date: Thu, 16 Jul 2026 09:47:20 +0300 Subject: [PATCH 07/12] fix[skiplog]: normalize packaged Snap read modes --- packages/sdk/e2e/scripts/package-snap.mjs | 27 ++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/sdk/e2e/scripts/package-snap.mjs b/packages/sdk/e2e/scripts/package-snap.mjs index a214760da8..d4d539abe9 100644 --- a/packages/sdk/e2e/scripts/package-snap.mjs +++ b/packages/sdk/e2e/scripts/package-snap.mjs @@ -1,4 +1,12 @@ -import { chmodSync, cpSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { + chmodSync, + cpSync, + lstatSync, + 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' @@ -37,6 +45,21 @@ function run(command, args, cwd) { } } +function makeSnapTreeReadable(root) { + for (const entry of readdirSync(root, { withFileTypes: true })) { + const entryPath = join(root, entry.name) + if (entry.isSymbolicLink()) continue + + const mode = lstatSync(entryPath).mode & 0o777 + if (entry.isDirectory()) { + chmodSync(entryPath, mode | 0o555) + makeSnapTreeReadable(entryPath) + } else if (entry.isFile()) { + chmodSync(entryPath, mode | 0o444) + } + } +} + if (process.platform !== 'linux') { throw new Error(`Snap packaging requires Linux; current platform is ${process.platform}`) } @@ -51,6 +74,8 @@ run( mkdirSync(electronDist, { recursive: true }) cpSync(electronOutput, packagedSnapApp, { recursive: true }) +// Snap files are installed as root but the app runs as the invoking user. +makeSnapTreeReadable(packagedSnapApp) // bare-runtime normally repairs this mode at startup, but a mounted Snap is read-only. chmodSync(packagedBareBinary, 0o755) writeFileSync( From 9f0ab7ed7b54383cb3bbd221642e834eb7e86f9a Mon Sep 17 00:00:00 2001 From: Victor Rodzko Date: Thu, 16 Jul 2026 10:24:46 +0300 Subject: [PATCH 08/12] fix[skiplog]: align Snap confinement with production Use the Workbench runtime interfaces for representative strict-confinement coverage and preserve report metadata after early failures. --- .github/workflows/test-node-sdk.yml | 1 + packages/sdk/e2e/scripts/package-snap.mjs | 27 +---------------------- packages/sdk/e2e/snap/snapcraft.yaml | 16 +++++++++++++- 3 files changed, 17 insertions(+), 27 deletions(-) diff --git a/.github/workflows/test-node-sdk.yml b/.github/workflows/test-node-sdk.yml index 9e969ff363..ce14cacd3f 100644 --- a/.github/workflows/test-node-sdk.yml +++ b/.github/workflows/test-node-sdk.yml @@ -421,6 +421,7 @@ jobs: key: qvac-snap-models-${{ hashFiles('packages/sdk/models/registry/models.ts') }} - name: Generate runId + if: always() id: runid shell: node {0} env: diff --git a/packages/sdk/e2e/scripts/package-snap.mjs b/packages/sdk/e2e/scripts/package-snap.mjs index d4d539abe9..a214760da8 100644 --- a/packages/sdk/e2e/scripts/package-snap.mjs +++ b/packages/sdk/e2e/scripts/package-snap.mjs @@ -1,12 +1,4 @@ -import { - chmodSync, - cpSync, - lstatSync, - mkdirSync, - readdirSync, - rmSync, - writeFileSync -} from 'node:fs' +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' @@ -45,21 +37,6 @@ function run(command, args, cwd) { } } -function makeSnapTreeReadable(root) { - for (const entry of readdirSync(root, { withFileTypes: true })) { - const entryPath = join(root, entry.name) - if (entry.isSymbolicLink()) continue - - const mode = lstatSync(entryPath).mode & 0o777 - if (entry.isDirectory()) { - chmodSync(entryPath, mode | 0o555) - makeSnapTreeReadable(entryPath) - } else if (entry.isFile()) { - chmodSync(entryPath, mode | 0o444) - } - } -} - if (process.platform !== 'linux') { throw new Error(`Snap packaging requires Linux; current platform is ${process.platform}`) } @@ -74,8 +51,6 @@ run( mkdirSync(electronDist, { recursive: true }) cpSync(electronOutput, packagedSnapApp, { recursive: true }) -// Snap files are installed as root but the app runs as the invoking user. -makeSnapTreeReadable(packagedSnapApp) // bare-runtime normally repairs this mode at startup, but a mounted Snap is read-only. chmodSync(packagedBareBinary, 0o755) writeFileSync( diff --git a/packages/sdk/e2e/snap/snapcraft.yaml b/packages/sdk/e2e/snap/snapcraft.yaml index 784173026b..c9ee1b6ff8 100644 --- a/packages/sdk/e2e/snap/snapcraft.yaml +++ b/packages/sdk/e2e/snap/snapcraft.yaml @@ -12,14 +12,23 @@ 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 --disable-dev-shm-usage + 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' @@ -31,3 +40,8 @@ parts: stage-packages: - libasound2 - libnss3 + +plugs: + shmem: + interface: shared-memory + private: true From 971466bee98da46123fc9a35f24be59962318db9 Mon Sep 17 00:00:00 2001 From: Victor Rodzko Date: Thu, 16 Jul 2026 10:28:38 +0300 Subject: [PATCH 09/12] fix[skiplog]: use hosted runner for Snap tests Default manual Snap validation to the runner with passwordless Snapcraft setup instead of an unprovisioned GPU host. --- .github/workflows/test-sdk.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-sdk.yml b/.github/workflows/test-sdk.yml index b408892851..1cee9c4e53 100644 --- a/.github/workflows/test-sdk.yml +++ b/.github/workflows/test-sdk.yml @@ -69,7 +69,7 @@ on: snap-platforms: description: "JSON array of Linux runner labels for strict Snap tests" type: string - default: '["qvac-ubuntu2204-x64-gpu"]' + 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 @@ -188,7 +188,7 @@ jobs: consumer: snap project-directory: "packages/sdk" working-directory: "packages/sdk/e2e" - platforms: ${{ inputs.snap-platforms || '["qvac-ubuntu2204-x64-gpu"]' }} + platforms: ${{ inputs.snap-platforms || '["ubuntu-22.04"]' }} consumer-timeout: ${{ fromJSON(inputs.desktop-consumer-timeout || '60') }} filter: ${{ inputs.filter }} suite: ${{ needs.resolve.outputs.suite }} From 3138a9c0bf4db34f91203df726bf5e35eb1c18b1 Mon Sep 17 00:00:00 2001 From: Victor Rodzko Date: Thu, 16 Jul 2026 10:44:45 +0300 Subject: [PATCH 10/12] fix[skiplog]: update local Snap revision correctly Install the second unsigned artifact over the first, which is Snap's supported local refresh flow. --- packages/sdk/e2e/scripts/run-snap-refresh-test.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/sdk/e2e/scripts/run-snap-refresh-test.mjs b/packages/sdk/e2e/scripts/run-snap-refresh-test.mjs index 8b6b07645d..3c252e6cb7 100644 --- a/packages/sdk/e2e/scripts/run-snap-refresh-test.mjs +++ b/packages/sdk/e2e/scripts/run-snap-refresh-test.mjs @@ -88,7 +88,8 @@ try { } runSnapAdmin(['install', '--dangerous', resolve(firstArtifact)]) runProbe('before') - runSnapAdmin(['refresh', '--dangerous', resolve(secondArtifact)]) + // 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') { From b3cc2476f58dac62859f66bea9101568d2b2ee86 Mon Sep 17 00:00:00 2001 From: Victor Rodzko Date: Thu, 16 Jul 2026 11:44:52 +0300 Subject: [PATCH 11/12] chore[skiplog]: update Snap test framework build Consume the published framework revision containing the corrected local Snap update flow. --- packages/sdk/e2e/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/e2e/package.json b/packages/sdk/e2e/package.json index 357a690d5b..d34badc15e 100644 --- a/packages/sdk/e2e/package.json +++ b/packages/sdk/e2e/package.json @@ -23,7 +23,7 @@ "dependencies": { "@qvac/sdk": "file:..", "custom-echo-plugin": "file:./fixtures/echo-plugin", - "@tetherto/qvac-test-suite": "0.9.0-tmp.pr-84.runid-29430579062", + "@tetherto/qvac-test-suite": "0.9.0-tmp.pr-84.runid-29480984047", "mqtt": "^5.14.1", "react-native": "0.81.5", "react-native-bare-kit": "^0.14.0" From d2aca22b25f3bd1b4177bf46e36f37025c330288 Mon Sep 17 00:00:00 2001 From: Victor Rodzko Date: Thu, 16 Jul 2026 14:28:14 +0300 Subject: [PATCH 12/12] chore[skiplog]: update Snap test framework build --- packages/sdk/e2e/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/e2e/package.json b/packages/sdk/e2e/package.json index d34badc15e..f35fed1e2b 100644 --- a/packages/sdk/e2e/package.json +++ b/packages/sdk/e2e/package.json @@ -23,7 +23,7 @@ "dependencies": { "@qvac/sdk": "file:..", "custom-echo-plugin": "file:./fixtures/echo-plugin", - "@tetherto/qvac-test-suite": "0.9.0-tmp.pr-84.runid-29480984047", + "@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"