Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 3 additions & 16 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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。

Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions openless-all/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions openless-all/app/scripts/check-hotkey-injection.mjs
Original file line number Diff line number Diff line change
@@ -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'],
Expand Down
87 changes: 87 additions & 0 deletions openless-all/app/scripts/frontend-test-runner.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
}
51 changes: 51 additions & 0 deletions openless-all/app/scripts/frontend-test-runner.test.mjs
Original file line number Diff line number Diff line change
@@ -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');
Original file line number Diff line number Diff line change
Expand Up @@ -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-'));
Expand Down
Loading