From c43b1d96e48650bac1583b8902cfc3c326d92cd2 Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 15 Jul 2026 14:06:44 +0800 Subject: [PATCH] ci: run all frontend and contract tests --- .github/workflows/ci.yml | 19 +--- openless-all/app/package.json | 2 + .../app/scripts/check-hotkey-injection.mjs | 8 ++ .../app/scripts/frontend-test-runner.mjs | 87 +++++++++++++++++++ .../app/scripts/frontend-test-runner.test.mjs | 51 +++++++++++ ...speech-usage-description-contract.test.mjs | 5 ++ 6 files changed, 156 insertions(+), 16 deletions(-) create mode 100644 openless-all/app/scripts/frontend-test-runner.mjs create mode 100644 openless-all/app/scripts/frontend-test-runner.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1387c2a85..9eb50c9d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI # 多平台跨语言质量门禁。release-tauri.yml 是发版流水线(仅打包构建),这里负责 # 在合并前快速验证两件事: -# 1. 前端 typecheck + vite bundle 通过(tsc + vite build,捕获跨 locale 类型 drift) +# 1. 全部前端/契约测试与 vite bundle 通过(捕获行为、合同及跨 locale 类型 drift) # 2. Rust 后端在 macOS / Windows / Linux 都能 cargo check 过(捕获 cfg 漏分支 / 平台 API 误用) # 跑 build-mac.sh / windows-package-msvc.ps1 / Tauri bundle 太重;只跑轻量 cargo check + vite build。 @@ -152,21 +152,8 @@ jobs: } } - - name: Check macOS capsule Spaces contract - run: npm run check:macos-capsule-spaces - - - name: Check Windows UI configuration contract - run: npm run check:windows-ui-config - - - name: Check PIN persistence security contract - run: npm run check:pin-persistence-security - - - name: Check macOS speech usage description contract - if: runner.os == 'macOS' - run: npm run check:macos-speech-usage-description - - - name: Build frontend (tsc + vite) - run: npm run build + - name: Build frontend and run frontend/contract tests + run: npm test - name: Check Tauri backend (cargo check) run: cargo check --manifest-path src-tauri/Cargo.toml diff --git a/openless-all/app/package.json b/openless-all/app/package.json index 57e379595..9a352ba03 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -4,6 +4,8 @@ "version": "1.3.14", "type": "module", "scripts": { + "pretest": "npm run build", + "test": "node scripts/frontend-test-runner.mjs", "dev": "vite", "prebuild": "node scripts/android-ipc-import-boundary.test.mjs", "build": "tsc && vite build", diff --git a/openless-all/app/scripts/check-hotkey-injection.mjs b/openless-all/app/scripts/check-hotkey-injection.mjs index 430c40dbd..46344c7bf 100644 --- a/openless-all/app/scripts/check-hotkey-injection.mjs +++ b/openless-all/app/scripts/check-hotkey-injection.mjs @@ -1,5 +1,13 @@ import { spawnSync } from 'node:child_process'; +if (process.platform === 'win32') { + // The full Tauri lib-test binary is compile-only on clean Windows runners because an + // optional native runtime DLL is unavailable there. CI executes this behavioral gate on + // macOS/Linux and still compiles the same Rust test on Windows via `cargo test --no-run`. + console.log('Hotkey injection runtime gate skipped on Windows (covered by lib-test compilation).'); + process.exit(0); +} + const result = spawnSync( 'cargo', ['test', '--manifest-path', 'src-tauri/Cargo.toml', 'hotkey_injection_gate_logs_pressed_and_cancels', '--', '--nocapture'], diff --git a/openless-all/app/scripts/frontend-test-runner.mjs b/openless-all/app/scripts/frontend-test-runner.mjs new file mode 100644 index 000000000..d0715586d --- /dev/null +++ b/openless-all/app/scripts/frontend-test-runner.mjs @@ -0,0 +1,87 @@ +import { readdirSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { dirname, join, relative, resolve, sep } from 'node:path'; +import process from 'node:process'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const DEFAULT_APP_ROOT = dirname(dirname(fileURLToPath(import.meta.url))); + +function collectFiles(directory, predicate, files = []) { + for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => + a.name.localeCompare(b.name), + )) { + const entryPath = join(directory, entry.name); + if (entry.isDirectory()) { + collectFiles(entryPath, predicate, files); + } else if (entry.isFile() && predicate(entry.name)) { + files.push(entryPath); + } + } + return files; +} + +export function discoverTestFiles(appRoot = DEFAULT_APP_ROOT) { + return [ + ...collectFiles(join(appRoot, 'src'), (name) => name.endsWith('.test.ts')), + ...collectFiles( + join(appRoot, 'scripts'), + (name) => name.endsWith('.test.mjs') || (name.startsWith('check-') && name.endsWith('.mjs')), + ), + ] + .map((file) => relative(appRoot, file).split(sep).join('/')) + .sort(); +} + +export function runTestFiles( + testFiles, + { + appRoot = DEFAULT_APP_ROOT, + log = console.log, + spawn = spawnSync, + tsxCli, + } = {}, +) { + let resolvedTsxCli = tsxCli; + + for (const testFile of testFiles) { + const absoluteTestFile = resolve(appRoot, testFile); + let args; + if (testFile.endsWith('.test.ts')) { + resolvedTsxCli ??= fileURLToPath(import.meta.resolve('tsx/cli')); + args = [resolvedTsxCli, absoluteTestFile]; + } else if (testFile.endsWith('.mjs')) { + args = [absoluteTestFile]; + } else { + log(`[frontend-tests] unsupported test file: ${testFile}`); + return 1; + } + + log(`[frontend-tests] ${testFile}`); + const result = spawn(process.execPath, args, { + cwd: appRoot, + env: process.env, + stdio: 'inherit', + }); + if (result.error) { + log(`[frontend-tests] failed to start ${testFile}: ${result.error.message}`); + return 1; + } + if (result.status !== 0) { + return result.status ?? 1; + } + } + + return 0; +} + +const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : ''; +if (invokedPath === import.meta.url) { + const testFiles = discoverTestFiles(); + if (testFiles.length === 0) { + console.error('[frontend-tests] no tests discovered'); + process.exitCode = 1; + } else { + console.log(`[frontend-tests] discovered ${testFiles.length} tests`); + process.exitCode = runTestFiles(testFiles); + } +} diff --git a/openless-all/app/scripts/frontend-test-runner.test.mjs b/openless-all/app/scripts/frontend-test-runner.test.mjs new file mode 100644 index 000000000..957957152 --- /dev/null +++ b/openless-all/app/scripts/frontend-test-runner.test.mjs @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import { resolve } from 'node:path'; +import process from 'node:process'; + +import { discoverTestFiles, runTestFiles } from './frontend-test-runner.mjs'; + +const discovered = discoverTestFiles(); +for (const expected of [ + 'scripts/check-android-updater-pubkey.mjs', + 'scripts/check-hotkey-injection.mjs', + 'scripts/macos-capsule-spaces-contract.test.mjs', + 'scripts/macos-speech-usage-description-contract.test.mjs', + 'scripts/windows-ui-config.test.mjs', + 'src/lib/hotkeyRecorder.test.ts', + 'src/lib/windowHotkeyFallback.test.ts', +]) { + assert(discovered.includes(expected), `aggregate discovery omitted ${expected}`); +} +assert.equal(new Set(discovered).size, discovered.length, 'aggregate discovery must not duplicate tests'); + +const invocations = []; +const statuses = [0, 7, 0]; +const exitCode = runTestFiles( + [ + 'src/lib/passes.test.ts', + 'scripts/fails.test.mjs', + 'scripts/must-not-run.test.mjs', + ], + { + appRoot: '/app', + log: () => {}, + spawn(command, args, options) { + invocations.push({ command, args, options }); + return { status: statuses[invocations.length - 1] }; + }, + tsxCli: '/tools/tsx-cli.mjs', + }, +); + +assert.equal(exitCode, 7, 'the aggregate runner must propagate a failing child status'); +assert.equal(invocations.length, 2, 'the aggregate runner must stop after the first failure'); +assert.deepEqual(invocations[0].args, [ + '/tools/tsx-cli.mjs', + resolve('/app', 'src/lib/passes.test.ts'), +]); +assert.deepEqual(invocations[1].args, [resolve('/app', 'scripts/fails.test.mjs')]); +assert.equal(invocations[0].command, process.execPath); +assert.equal(invocations[1].command, process.execPath); +assert.equal(invocations[0].options.stdio, 'inherit'); + +console.log('frontend-test-runner.test.mjs passed'); diff --git a/openless-all/app/scripts/macos-speech-usage-description-contract.test.mjs b/openless-all/app/scripts/macos-speech-usage-description-contract.test.mjs index a6925d472..c62e7c8e4 100644 --- a/openless-all/app/scripts/macos-speech-usage-description-contract.test.mjs +++ b/openless-all/app/scripts/macos-speech-usage-description-contract.test.mjs @@ -4,6 +4,11 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; +if (process.platform !== 'darwin') { + console.log('macOS speech usage description contract skipped on non-macOS'); + process.exit(0); +} + const scriptsDir = fileURLToPath(new URL('.', import.meta.url)); const checker = join(scriptsDir, 'check-macos-speech-usage-description.sh'); const fixtureDir = mkdtempSync(join(tmpdir(), 'openless-speech-usage-'));