ci: run all frontend and contract tests (#832)

Co-authored-by: sim <sim@local>
This commit is contained in:
TRIPandsim authored and GitHub committed 2026-07-15 15:55:37 +08:00
1 parent eb099c4f41
commit 4bdef5d7a3
6 files changed
+156 -16

No files matched your search

+3 -16
View File
@@ -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
+2
View File
@@ -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",
@@ -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'],
@@ -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);
}
}
@@ -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');
@@ -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-'));