ci: run all frontend and contract tests (#832)
Co-authored-by: sim <sim@local>
This commit is contained in:
1 parent
eb099c4f41
commit
4bdef5d7a3
6 files changed
+156
-16
No files matched your search
@@ -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
|
||||
|
||||
@@ -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-'));
|
||||
|
||||
Reference in new issue
Block a user