add gateway backend communication harness (#949)
This commit is contained in:
12
.github/workflows/check.yml
vendored
12
.github/workflows/check.yml
vendored
@@ -40,6 +40,18 @@ jobs:
|
||||
- name: Run tests
|
||||
run: pnpm run test
|
||||
|
||||
- name: Run harness checks
|
||||
run: pnpm run harness:ci
|
||||
|
||||
- name: Upload harness artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: harness-artifacts
|
||||
path: artifacts/harness
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
env:
|
||||
|
||||
49
.github/workflows/harness.yml
vendored
Normal file
49
.github/workflows/harness.yml
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
name: Harness
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'harness/**'
|
||||
- 'tests/unit/harness-specs.test.ts'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- '.github/workflows/harness.yml'
|
||||
|
||||
jobs:
|
||||
harness:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: '1'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run harness CI checks
|
||||
run: pnpm run harness:ci
|
||||
|
||||
- name: Upload harness artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: harness-artifacts
|
||||
path: artifacts/harness
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
@@ -44,3 +44,6 @@ Standard dev commands are in `package.json` scripts and `README.md`. Key ones:
|
||||
- Transport policy is Main-owned and fixed as `WS -> HTTP -> IPC fallback`; renderer should not implement protocol switching UI/business logic.
|
||||
- **Comms-change checklist**: If your change touches communication paths (gateway events, runtime send/receive, delivery, or fallback), run `pnpm run comms:replay` and `pnpm run comms:compare` before pushing.
|
||||
- **Doc sync rule**: After any functional or architecture change, review `README.md`, `README.zh-CN.md`, and `README.ja-JP.md` for required updates; if behavior/flows/interfaces changed, update docs in the same PR/commit.
|
||||
- **Spec-driven harness rule**: AI Coding tasks that touch backend communication must start from a task spec under `harness/specs/tasks/` and reference `gateway-backend-communication` when the change involves renderer/Main/host-api/api-client/Gateway/OpenClaw runtime paths. Run `pnpm harness validate --spec <task-spec>` before implementation review, and `pnpm harness run --spec <task-spec>` or `--dry-run` when checking the selected validation flow.
|
||||
- **Spec/rule growth rule**: When adding a new feature, user-visible OpenClaw scenario, or recurring AI Coding constraint, add or update the relevant harness scenario spec and rule spec in the same PR so future AI work can validate the behavior instead of relying on tribal knowledge.
|
||||
- **Harness CI/local parity**: Run `pnpm run harness:ci` to exercise the same baseline harness checks used by GitHub Actions. Real task specs should be validated without `--no-diff`; `--no-diff` is only for structural checks of checked-in examples.
|
||||
|
||||
12
harness/package.json
Normal file
12
harness/package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@clawx/harness",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"clawx-harness": "./src/cli.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node src/cli.mjs"
|
||||
}
|
||||
}
|
||||
15
harness/specs/rules/api-client-transport-policy.md
Normal file
15
harness/specs/rules/api-client-transport-policy.md
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
id: api-client-transport-policy
|
||||
title: API Client Transport Policy
|
||||
type: ai-coding-rule
|
||||
appliesTo:
|
||||
- gateway-backend-communication
|
||||
requiredTests:
|
||||
- tests/unit/api-client.test.ts
|
||||
---
|
||||
|
||||
Gateway RPC transport is IPC-only by default. Renderer code must not enable WebSocket or HTTP transport unless `src/lib/api-client.ts` explicitly gates it behind `clawx:gateway-ws-diagnostic`.
|
||||
|
||||
When diagnostics are enabled, the allowed order is `WS -> HTTP -> IPC`; otherwise the allowed order is `IPC`.
|
||||
|
||||
Failed non-IPC transports must use backoff before retry. `gateway:httpProxy` remains a Main-owned proxy path and must not become direct renderer Gateway HTTP access.
|
||||
21
harness/specs/rules/backend-communication-boundary.md
Normal file
21
harness/specs/rules/backend-communication-boundary.md
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
id: backend-communication-boundary
|
||||
title: Backend Communication Boundary
|
||||
type: ai-coding-rule
|
||||
appliesTo:
|
||||
- gateway-backend-communication
|
||||
requiredProfiles:
|
||||
- comms
|
||||
---
|
||||
|
||||
Renderer backend calls must go through `src/lib/host-api.ts` and `src/lib/api-client.ts`.
|
||||
|
||||
Pages and components must not add direct `window.electron.ipcRenderer.invoke(...)` calls.
|
||||
|
||||
Renderer code must not call Gateway HTTP endpoints directly, including `127.0.0.1:18789` and `localhost:18789`.
|
||||
|
||||
Gateway transport policy remains owned by Electron Main. Renderer code must not implement `WS -> HTTP -> IPC` protocol switching.
|
||||
|
||||
New backend interfaces must be exposed through Electron Main or the host API layer before renderer code consumes them.
|
||||
|
||||
Any communication path change must run the `comms` profile. If the communication result changes visible UI state, the task must run or add the relevant Electron E2E coverage.
|
||||
11
harness/specs/rules/comms-regression.md
Normal file
11
harness/specs/rules/comms-regression.md
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
id: comms-regression
|
||||
title: Comms Regression
|
||||
type: ai-coding-rule
|
||||
appliesTo:
|
||||
- gateway-backend-communication
|
||||
requiredProfiles:
|
||||
- comms
|
||||
---
|
||||
|
||||
Changes to gateway events, runtime send/receive, channel delivery, or fallback behavior must run `pnpm run comms:replay` and `pnpm run comms:compare`.
|
||||
9
harness/specs/rules/docs-sync.md
Normal file
9
harness/specs/rules/docs-sync.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
id: docs-sync
|
||||
title: Docs Sync
|
||||
type: ai-coding-rule
|
||||
appliesTo:
|
||||
- gateway-backend-communication
|
||||
---
|
||||
|
||||
Functional or architecture changes must explicitly declare whether README translation updates are required.
|
||||
16
harness/specs/rules/gateway-readiness-policy.md
Normal file
16
harness/specs/rules/gateway-readiness-policy.md
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
id: gateway-readiness-policy
|
||||
title: Gateway Readiness Policy
|
||||
type: ai-coding-rule
|
||||
appliesTo:
|
||||
- gateway-backend-communication
|
||||
requiredTests:
|
||||
- tests/unit/gateway-events.test.ts
|
||||
- tests/unit/gateway-ready-fallback.test.ts
|
||||
---
|
||||
|
||||
Gateway status handling must preserve `gatewayReady` semantics.
|
||||
|
||||
`gatewayReady: false` means runtime-dependent refreshes should wait. `gatewayReady: true` means the Gateway reported readiness. `gatewayReady: undefined` is backward-compatible with older Gateway versions and must be treated as ready when the Gateway state is running.
|
||||
|
||||
Gateway manager fallback may mark readiness true after its timeout, but it must not emit duplicate ready transitions.
|
||||
15
harness/specs/rules/host-api-fallback-policy.md
Normal file
15
harness/specs/rules/host-api-fallback-policy.md
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
id: host-api-fallback-policy
|
||||
title: Host API Fallback Policy
|
||||
type: ai-coding-rule
|
||||
appliesTo:
|
||||
- gateway-backend-communication
|
||||
requiredTests:
|
||||
- tests/unit/host-api.test.ts
|
||||
---
|
||||
|
||||
Renderer Host API requests must use `hostapi:fetch` IPC proxy by default.
|
||||
|
||||
Browser fallback to `http://127.0.0.1:13210` is allowed only inside `src/lib/host-api.ts`, and only when `clawx:allow-localhost-fallback` is explicitly enabled.
|
||||
|
||||
The Host API token must be obtained through `hostapi:token`; pages and components must not construct Host API localhost requests directly.
|
||||
15
harness/specs/rules/host-events-fallback-policy.md
Normal file
15
harness/specs/rules/host-events-fallback-policy.md
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
id: host-events-fallback-policy
|
||||
title: Host Events Fallback Policy
|
||||
type: ai-coding-rule
|
||||
appliesTo:
|
||||
- gateway-backend-communication
|
||||
requiredTests:
|
||||
- tests/unit/host-events.test.ts
|
||||
---
|
||||
|
||||
Host event subscriptions must use IPC mappings by default.
|
||||
|
||||
Unknown host events must not fall back to SSE unless `clawx:allow-sse-fallback` is explicitly enabled inside `src/lib/host-events.ts`.
|
||||
|
||||
New user-visible gateway, channel, OAuth, or QR events should be added to the host event IPC mapping instead of relying on EventSource fallback.
|
||||
9
harness/specs/rules/renderer-main-boundary.md
Normal file
9
harness/specs/rules/renderer-main-boundary.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
id: renderer-main-boundary
|
||||
title: Renderer Main Boundary
|
||||
type: ai-coding-rule
|
||||
appliesTo:
|
||||
- gateway-backend-communication
|
||||
---
|
||||
|
||||
Renderer pages and components use the existing host API and API client modules as their only backend entrypoints. Main-process IPC details stay behind those modules.
|
||||
54
harness/specs/scenarios/gateway-backend-communication.md
Normal file
54
harness/specs/scenarios/gateway-backend-communication.md
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
id: gateway-backend-communication
|
||||
title: Gateway Backend Communication
|
||||
type: runtime-bridge
|
||||
ownedPaths:
|
||||
- src/lib/api-client.ts
|
||||
- src/lib/host-api.ts
|
||||
- src/stores/gateway.ts
|
||||
- src/stores/chat.ts
|
||||
- src/stores/chat/**
|
||||
- electron/api/**
|
||||
- electron/main/ipc/**
|
||||
- electron/gateway/**
|
||||
- electron/preload/**
|
||||
- electron/utils/**
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
conditionalProfiles:
|
||||
e2e:
|
||||
when:
|
||||
- user-visible gateway status changes
|
||||
- user-visible chat send/receive behavior changes
|
||||
- channels/agents/settings UI depends on new backend response shape
|
||||
requiredRules:
|
||||
- renderer-main-boundary
|
||||
- backend-communication-boundary
|
||||
- api-client-transport-policy
|
||||
- host-api-fallback-policy
|
||||
- host-events-fallback-policy
|
||||
- gateway-readiness-policy
|
||||
- comms-regression
|
||||
- docs-sync
|
||||
forbiddenPatterns:
|
||||
- window.electron.ipcRenderer.invoke in src/pages/**
|
||||
- window.electron.ipcRenderer.invoke in src/components/**
|
||||
- fetch('http://127.0.0.1:18789 in src/**
|
||||
- fetch("http://127.0.0.1:18789 in src/**
|
||||
- fetch('http://localhost:18789 in src/**
|
||||
- fetch("http://localhost:18789 in src/**
|
||||
- clawx:allow-localhost-fallback outside src/lib/host-api.ts and tests
|
||||
- clawx:allow-sse-fallback outside src/lib/host-events.ts and tests
|
||||
- clawx:gateway-ws-diagnostic outside src/lib/api-client.ts and tests
|
||||
---
|
||||
|
||||
Gateway backend communication covers all ClawX paths that move data between the visual desktop UI and OpenClaw runtime/backend services.
|
||||
|
||||
Allowed flow:
|
||||
Renderer page/component -> `src/lib/host-api.ts` or `src/lib/api-client.ts` -> Electron Main host route or IPC handler -> gateway proxy / OpenClaw Gateway -> runtime result -> store/UI.
|
||||
|
||||
Renderer code must not own transport selection, direct IPC channels, direct Gateway HTTP calls, retry policy, or protocol fallback.
|
||||
|
||||
Explicit local fallback flags are narrow exceptions:
|
||||
`clawx:allow-localhost-fallback` belongs to Host API browser fallback only, `clawx:allow-sse-fallback` belongs to host event SSE fallback only, and `clawx:gateway-ws-diagnostic` belongs to API client transport diagnostics only.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
id: fix-chat-history-gateway-timeout
|
||||
title: Fix chat history timeout through gateway backend communication
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Adjust backend communication behavior for chat history loading.
|
||||
touchedAreas:
|
||||
- src/lib/api-client.ts
|
||||
- src/stores/chat/history-actions.ts
|
||||
expectedUserBehavior:
|
||||
- Chat history loads through the existing host API boundary.
|
||||
- Gateway timeout does not leave the visible chat in a stale state.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
requiredTests:
|
||||
- tests/unit/chat-history-actions.test.ts
|
||||
acceptance:
|
||||
- Renderer does not add direct IPC calls.
|
||||
- Renderer does not fetch Gateway HTTP directly.
|
||||
- Comms replay and compare pass.
|
||||
docs:
|
||||
required: false
|
||||
---
|
||||
|
||||
Example task spec for gateway backend communication work. Copy this file to a task-specific name before starting an AI Coding change.
|
||||
246
harness/src/cli.mjs
Executable file
246
harness/src/cli.mjs
Executable file
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env node
|
||||
import process from 'node:process';
|
||||
import { getChangedFiles } from './git.mjs';
|
||||
import { PROFILES, selectSteps } from './profiles.mjs';
|
||||
import { writeReport } from './report.mjs';
|
||||
import { runStep } from './runner.mjs';
|
||||
import {
|
||||
isGatewayBackendCommunicationTask,
|
||||
loadRuleSpecs,
|
||||
loadScenarioSpecs,
|
||||
loadSpec,
|
||||
toArray,
|
||||
} from './specs.mjs';
|
||||
import {
|
||||
scanBackendCommunicationBoundary,
|
||||
touchesCommunicationPath,
|
||||
validateGatewayTaskSpec,
|
||||
} from './rules.mjs';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { _: [] };
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--') continue;
|
||||
if (!arg.startsWith('--')) {
|
||||
args._.push(arg);
|
||||
continue;
|
||||
}
|
||||
const key = arg.slice(2);
|
||||
const next = argv[i + 1];
|
||||
if (!next || next.startsWith('--')) {
|
||||
args[key] = true;
|
||||
continue;
|
||||
}
|
||||
args[key] = next;
|
||||
i += 1;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
console.log([
|
||||
'Usage:',
|
||||
' pnpm harness list',
|
||||
' pnpm harness validate --spec <path> [--since origin/main] [--no-diff]',
|
||||
' pnpm harness explain --spec <path> [--since origin/main]',
|
||||
' pnpm harness run --spec <path> [--since origin/main] [--dry-run] [--continue-on-error]',
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
async function findScenario(id) {
|
||||
const scenarios = await loadScenarioSpecs();
|
||||
return scenarios.find((scenario) => scenario.data.id === id);
|
||||
}
|
||||
|
||||
async function list() {
|
||||
const scenarios = await loadScenarioSpecs();
|
||||
const rules = await loadRuleSpecs();
|
||||
console.log('Profiles:');
|
||||
for (const profile of Object.keys(PROFILES)) console.log(`- ${profile}`);
|
||||
console.log('\nScenario specs:');
|
||||
for (const spec of scenarios) console.log(`- ${spec.data.id}: ${spec.path}`);
|
||||
console.log('\nRule specs:');
|
||||
for (const spec of rules) console.log(`- ${spec.data.id}: ${spec.path}`);
|
||||
}
|
||||
|
||||
async function validate(specPath, options = {}) {
|
||||
const spec = await loadSpec(specPath);
|
||||
const scenario = spec.data.scenario ? await findScenario(spec.data.scenario) : null;
|
||||
const shouldCheckDiff = !options.noDiff && (options.checkDiff || Boolean(spec.data.scenario));
|
||||
const changedFiles = shouldCheckDiff
|
||||
? await getChangedFiles(options.since ?? 'origin/main')
|
||||
: [];
|
||||
const failures = [];
|
||||
|
||||
if (spec.data.type === 'runtime-bridge' && spec.data.id === 'gateway-backend-communication') {
|
||||
for (const profile of ['fast', 'comms']) {
|
||||
if (!toArray(spec.data.requiredProfiles).includes(profile)) {
|
||||
failures.push(`${spec.path}: requiredProfiles must include "${profile}"`);
|
||||
}
|
||||
}
|
||||
for (const rule of ['renderer-main-boundary', 'backend-communication-boundary', 'comms-regression', 'docs-sync']) {
|
||||
if (!toArray(spec.data.requiredRules).includes(rule)) {
|
||||
failures.push(`${spec.path}: requiredRules must include "${rule}"`);
|
||||
}
|
||||
}
|
||||
} else if (isGatewayBackendCommunicationTask(spec)) {
|
||||
failures.push(...validateGatewayTaskSpec(spec, scenario, changedFiles));
|
||||
} else if (!spec.data.id || !spec.data.title) {
|
||||
failures.push(`${spec.path}: spec must include id and title`);
|
||||
}
|
||||
|
||||
if (changedFiles.length > 0 && touchesCommunicationPath(changedFiles) && isGatewayBackendCommunicationTask(spec)) {
|
||||
const requiredProfiles = toArray(spec.data.requiredProfiles);
|
||||
if (!requiredProfiles.includes('comms')) {
|
||||
failures.push(`${spec.path}: communication path changes must require comms`);
|
||||
}
|
||||
}
|
||||
|
||||
return { spec, scenario, changedFiles, failures };
|
||||
}
|
||||
|
||||
async function explain(specPath, options = {}) {
|
||||
const result = await validate(specPath, { ...options, checkDiff: Boolean(options.since) });
|
||||
const requiredProfiles = toArray(result.spec.data.requiredProfiles);
|
||||
const scenarioProfiles = toArray(result.scenario?.data?.requiredProfiles);
|
||||
const profiles = [...new Set([...scenarioProfiles, ...requiredProfiles])];
|
||||
console.log(`Spec: ${result.spec.path}`);
|
||||
console.log(`Scenario: ${result.spec.data.scenario ?? result.spec.data.id}`);
|
||||
console.log(`Task type: ${result.spec.data.taskType ?? result.spec.data.type ?? 'n/a'}`);
|
||||
console.log(`Required profiles: ${profiles.join(', ') || 'none'}`);
|
||||
if (result.changedFiles.length > 0) {
|
||||
console.log('\nChanged files:');
|
||||
for (const file of result.changedFiles) console.log(`- ${file}`);
|
||||
}
|
||||
console.log('\nSelected steps:');
|
||||
for (const step of selectSteps(profiles)) {
|
||||
console.log(`- [${step.profile}] ${step.command} ${step.args.join(' ')}`);
|
||||
}
|
||||
if (result.failures.length > 0) {
|
||||
console.log('\nValidation failures:');
|
||||
for (const failure of result.failures) console.log(`- ${failure}`);
|
||||
}
|
||||
return result.failures.length === 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
async function run(specPath, options = {}) {
|
||||
const startedAt = new Date().toISOString();
|
||||
const validation = await validate(specPath, { ...options, checkDiff: true });
|
||||
const requiredProfiles = [
|
||||
...toArray(validation.scenario?.data?.requiredProfiles),
|
||||
...toArray(validation.spec.data.requiredProfiles),
|
||||
];
|
||||
const profiles = [...new Set(requiredProfiles)];
|
||||
const steps = [];
|
||||
const failures = [...validation.failures];
|
||||
|
||||
const scanFiles = [
|
||||
...validation.changedFiles,
|
||||
...toArray(validation.spec.data.touchedAreas),
|
||||
];
|
||||
const boundaryFailures = await scanBackendCommunicationBoundary(scanFiles);
|
||||
failures.push(...boundaryFailures);
|
||||
steps.push({
|
||||
profile: 'rules',
|
||||
name: 'Backend communication boundary scan',
|
||||
status: boundaryFailures.length === 0 ? 'pass' : 'fail',
|
||||
exitCode: boundaryFailures.length === 0 ? 0 : 1,
|
||||
durationMs: 0,
|
||||
});
|
||||
|
||||
const selectedSteps = selectSteps(profiles);
|
||||
if (failures.length === 0) {
|
||||
for (const step of selectedSteps) {
|
||||
if (options.dryRun) {
|
||||
steps.push({ ...step, status: 'skipped', exitCode: 0, durationMs: 0 });
|
||||
continue;
|
||||
}
|
||||
const result = await runStep(step);
|
||||
steps.push(result);
|
||||
if (result.status !== 'pass') {
|
||||
failures.push(`${step.name} failed with exit code ${result.exitCode}`);
|
||||
if (!options.continueOnError) break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const step of selectedSteps) {
|
||||
steps.push({ ...step, status: 'blocked', exitCode: 1, durationMs: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
const report = {
|
||||
specPath: validation.spec.path,
|
||||
scenario: validation.spec.data.scenario ?? validation.spec.data.id,
|
||||
taskType: validation.spec.data.taskType ?? validation.spec.data.type ?? null,
|
||||
startedAt,
|
||||
finishedAt: new Date().toISOString(),
|
||||
changedFiles: validation.changedFiles,
|
||||
selectedProfiles: profiles,
|
||||
steps,
|
||||
failures,
|
||||
result: failures.length === 0 ? 'pass' : 'fail',
|
||||
};
|
||||
const paths = await writeReport(report);
|
||||
console.log(`Harness report: ${paths.markdownPath}`);
|
||||
if (failures.length > 0) {
|
||||
console.error(failures.map((failure) => `- ${failure}`).join('\n'));
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const command = args._[0];
|
||||
if (!command || args.help) {
|
||||
printUsage();
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (command === 'list') {
|
||||
await list();
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!args.spec) {
|
||||
printUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (command === 'validate') {
|
||||
const result = await validate(args.spec, {
|
||||
since: args.since,
|
||||
checkDiff: Boolean(args.since),
|
||||
noDiff: Boolean(args['no-diff']),
|
||||
});
|
||||
if (result.failures.length > 0) {
|
||||
console.error(result.failures.map((failure) => `- ${failure}`).join('\n'));
|
||||
return 1;
|
||||
}
|
||||
console.log(`Spec is valid: ${result.spec.path}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (command === 'explain') {
|
||||
return await explain(args.spec, { since: args.since });
|
||||
}
|
||||
|
||||
if (command === 'run') {
|
||||
return await run(args.spec, {
|
||||
since: args.since,
|
||||
dryRun: Boolean(args['dry-run']),
|
||||
continueOnError: Boolean(args['continue-on-error']),
|
||||
});
|
||||
}
|
||||
|
||||
printUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
main().then((exitCode) => {
|
||||
process.exitCode = exitCode;
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
23
harness/src/git.mjs
Normal file
23
harness/src/git.mjs
Normal file
@@ -0,0 +1,23 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { ROOT } from './specs.mjs';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
async function gitLines(args, cwd = ROOT) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', args, { cwd, encoding: 'utf8' });
|
||||
return stdout.split('\n').map((line) => line.trim()).filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getChangedFiles(since = 'origin/main', cwd = ROOT) {
|
||||
const files = new Set();
|
||||
for (const line of await gitLines(['diff', '--name-only', `${since}...HEAD`], cwd)) files.add(line);
|
||||
for (const line of await gitLines(['diff', '--cached', '--name-only'], cwd)) files.add(line);
|
||||
for (const line of await gitLines(['diff', '--name-only'], cwd)) files.add(line);
|
||||
for (const line of await gitLines(['ls-files', '--others', '--exclude-standard'], cwd)) files.add(line);
|
||||
return [...files].sort();
|
||||
}
|
||||
29
harness/src/profiles.mjs
Normal file
29
harness/src/profiles.mjs
Normal file
@@ -0,0 +1,29 @@
|
||||
export const PROFILES = {
|
||||
fast: [
|
||||
{ name: 'Generate extension bridge', command: 'pnpm', args: ['run', 'ext:bridge'] },
|
||||
{ name: 'Lint without autofix', command: 'pnpm', args: ['run', 'lint:check'] },
|
||||
{ name: 'Typecheck', command: 'pnpm', args: ['run', 'typecheck'] },
|
||||
{ name: 'Unit tests', command: 'pnpm', args: ['test'] },
|
||||
],
|
||||
comms: [
|
||||
{ name: 'Comms replay', command: 'pnpm', args: ['run', 'comms:replay'] },
|
||||
{ name: 'Comms compare', command: 'pnpm', args: ['run', 'comms:compare'] },
|
||||
],
|
||||
e2e: [
|
||||
{ name: 'Electron E2E', command: 'pnpm', args: ['run', 'test:e2e'] },
|
||||
],
|
||||
};
|
||||
|
||||
export function selectSteps(requiredProfiles) {
|
||||
const selected = [];
|
||||
const seen = new Set();
|
||||
for (const profile of requiredProfiles) {
|
||||
for (const step of PROFILES[profile] ?? []) {
|
||||
const key = `${step.command} ${step.args.join(' ')}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
selected.push({ profile, ...step });
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
47
harness/src/report.mjs
Normal file
47
harness/src/report.mjs
Normal file
@@ -0,0 +1,47 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { ROOT } from './specs.mjs';
|
||||
|
||||
const REPORT_DIR = path.join(ROOT, 'artifacts', 'harness');
|
||||
|
||||
export async function writeReport(report) {
|
||||
await mkdir(REPORT_DIR, { recursive: true });
|
||||
const jsonPath = path.join(REPORT_DIR, 'latest.json');
|
||||
const markdownPath = path.join(REPORT_DIR, 'latest.md');
|
||||
await writeFile(jsonPath, JSON.stringify(report, null, 2));
|
||||
await writeFile(markdownPath, renderMarkdownReport(report));
|
||||
return {
|
||||
jsonPath,
|
||||
markdownPath,
|
||||
};
|
||||
}
|
||||
|
||||
function renderMarkdownReport(report) {
|
||||
const rows = report.steps.map((step) => (
|
||||
`| ${step.profile ?? 'rules'} | ${step.name} | ${step.status} | ${step.exitCode ?? ''} | ${step.durationMs ?? 0} |`
|
||||
));
|
||||
const failures = report.failures.length > 0
|
||||
? report.failures.map((failure) => `- ${failure}`).join('\n')
|
||||
: '- none';
|
||||
|
||||
return [
|
||||
'# Harness Report',
|
||||
'',
|
||||
`- Spec: ${report.specPath}`,
|
||||
`- Scenario: ${report.scenario}`,
|
||||
`- Result: ${report.result}`,
|
||||
`- Started: ${report.startedAt}`,
|
||||
`- Finished: ${report.finishedAt}`,
|
||||
'',
|
||||
'## Steps',
|
||||
'',
|
||||
'| Profile | Step | Status | Exit code | Duration ms |',
|
||||
'|---|---|---:|---:|---:|',
|
||||
...rows,
|
||||
'',
|
||||
'## Failures',
|
||||
'',
|
||||
failures,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
137
harness/src/rules.mjs
Normal file
137
harness/src/rules.mjs
Normal file
@@ -0,0 +1,137 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { ROOT, pathMatchesAny, toArray } from './specs.mjs';
|
||||
|
||||
const DIRECT_IPC_PATTERN = /window\.electron\.ipcRenderer\.invoke\s*\(/;
|
||||
const DIRECT_GATEWAY_HTTP_PATTERN = /fetch\s*\(\s*['"`]http:\/\/(?:127\.0\.0\.1|localhost):18789/;
|
||||
const HOST_API_LOCAL_HTTP_PATTERN = /fetch\s*\(\s*['"`]http:\/\/(?:127\.0\.0\.1|localhost):13210|HOST_API_BASE\s*=\s*`?http:\/\/127\.0\.0\.1:\$\{HOST_API_PORT\}`?/;
|
||||
const LOCALHOST_FALLBACK_FLAG = 'clawx:allow-localhost-fallback';
|
||||
const SSE_FALLBACK_FLAG = 'clawx:allow-sse-fallback';
|
||||
const WS_DIAGNOSTIC_FLAG = 'clawx:gateway-ws-diagnostic';
|
||||
const GATEWAY_READY_MUTATION_PATTERN = /gatewayReady\s*[:=]\s*(?:true|false)|setStatus\s*\([^)]*gatewayReady|setState\s*\([^)]*gatewayReady/s;
|
||||
const COMMUNICATION_PATHS = [
|
||||
'src/lib/api-client.ts',
|
||||
'src/lib/host-api.ts',
|
||||
'src/stores/gateway.ts',
|
||||
'src/stores/chat.ts',
|
||||
'src/stores/chat/**',
|
||||
'electron/api/**',
|
||||
'electron/main/ipc/**',
|
||||
'electron/gateway/**',
|
||||
'electron/preload/**',
|
||||
'electron/utils/**',
|
||||
];
|
||||
|
||||
function unique(values) {
|
||||
return [...new Set(values)].sort();
|
||||
}
|
||||
|
||||
async function readTextIfExists(relativePath) {
|
||||
try {
|
||||
return await readFile(path.join(ROOT, relativePath), 'utf8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function touchesCommunicationPath(files) {
|
||||
return files.some((file) => pathMatchesAny(file, COMMUNICATION_PATHS));
|
||||
}
|
||||
|
||||
export async function scanBackendCommunicationBoundary(files) {
|
||||
const failures = [];
|
||||
const scanFiles = unique(files).filter((file) => file.startsWith('src/') && /\.(ts|tsx|js|jsx)$/.test(file));
|
||||
|
||||
for (const file of scanFiles) {
|
||||
const text = await readTextIfExists(file);
|
||||
if (!text) continue;
|
||||
|
||||
const isPageOrComponent = file.startsWith('src/pages/') || file.startsWith('src/components/');
|
||||
if (isPageOrComponent && DIRECT_IPC_PATTERN.test(text)) {
|
||||
failures.push(`${file}: renderer page/component must not call window.electron.ipcRenderer.invoke directly`);
|
||||
}
|
||||
|
||||
if (DIRECT_GATEWAY_HTTP_PATTERN.test(text)) {
|
||||
failures.push(`${file}: renderer must not fetch Gateway HTTP directly`);
|
||||
}
|
||||
|
||||
const isTest = file.startsWith('tests/');
|
||||
if (!isTest && text.includes(LOCALHOST_FALLBACK_FLAG) && file !== 'src/lib/host-api.ts') {
|
||||
failures.push(`${file}: ${LOCALHOST_FALLBACK_FLAG} is only allowed in src/lib/host-api.ts`);
|
||||
}
|
||||
|
||||
if (!isTest && HOST_API_LOCAL_HTTP_PATTERN.test(text) && file !== 'src/lib/host-api.ts') {
|
||||
failures.push(`${file}: direct Host API localhost fallback is only allowed in src/lib/host-api.ts`);
|
||||
}
|
||||
|
||||
if (!isTest && text.includes(SSE_FALLBACK_FLAG) && file !== 'src/lib/host-events.ts') {
|
||||
failures.push(`${file}: ${SSE_FALLBACK_FLAG} is only allowed in src/lib/host-events.ts`);
|
||||
}
|
||||
|
||||
if (!isTest && text.includes(WS_DIAGNOSTIC_FLAG) && file !== 'src/lib/api-client.ts') {
|
||||
failures.push(`${file}: ${WS_DIAGNOSTIC_FLAG} is only allowed in src/lib/api-client.ts`);
|
||||
}
|
||||
|
||||
const isPageOrComponentFile = file.startsWith('src/pages/') || file.startsWith('src/components/');
|
||||
if (isPageOrComponentFile && GATEWAY_READY_MUTATION_PATTERN.test(text)) {
|
||||
failures.push(`${file}: gatewayReady mutation and refresh gating must stay in stores/main lifecycle code`);
|
||||
}
|
||||
}
|
||||
|
||||
return failures;
|
||||
}
|
||||
|
||||
export function validateGatewayTaskSpec(taskSpec, scenarioSpec, changedFiles = []) {
|
||||
const failures = [];
|
||||
const data = taskSpec.data ?? {};
|
||||
const requiredProfiles = toArray(data.requiredProfiles);
|
||||
const touchedAreas = toArray(data.touchedAreas);
|
||||
const expectedUserBehavior = toArray(data.expectedUserBehavior);
|
||||
const acceptance = toArray(data.acceptance);
|
||||
|
||||
for (const field of ['id', 'title', 'scenario', 'taskType', 'intent']) {
|
||||
if (!data[field]) failures.push(`${taskSpec.path}: missing required field "${field}"`);
|
||||
}
|
||||
|
||||
if (data.scenario !== 'gateway-backend-communication') {
|
||||
failures.push(`${taskSpec.path}: gateway backend communication tasks must set scenario: gateway-backend-communication`);
|
||||
}
|
||||
|
||||
if (data.taskType !== 'runtime-bridge') {
|
||||
failures.push(`${taskSpec.path}: gateway backend communication tasks must set taskType: runtime-bridge`);
|
||||
}
|
||||
|
||||
for (const profile of ['fast', 'comms']) {
|
||||
if (!requiredProfiles.includes(profile)) {
|
||||
failures.push(`${taskSpec.path}: requiredProfiles must include "${profile}"`);
|
||||
}
|
||||
}
|
||||
|
||||
if (touchedAreas.length === 0) failures.push(`${taskSpec.path}: touchedAreas must declare affected paths`);
|
||||
if (expectedUserBehavior.length === 0) failures.push(`${taskSpec.path}: expectedUserBehavior must declare visible behavior`);
|
||||
if (acceptance.length === 0) failures.push(`${taskSpec.path}: acceptance must declare completion criteria`);
|
||||
|
||||
if (!data.docs || typeof data.docs !== 'object' || typeof data.docs.required !== 'boolean') {
|
||||
failures.push(`${taskSpec.path}: docs.required must be explicitly true or false`);
|
||||
}
|
||||
|
||||
if (scenarioSpec) {
|
||||
const scenarioProfiles = toArray(scenarioSpec.data?.requiredProfiles);
|
||||
for (const profile of scenarioProfiles) {
|
||||
if (!requiredProfiles.includes(profile)) {
|
||||
failures.push(`${taskSpec.path}: requiredProfiles must include scenario-required profile "${profile}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changedFiles.length > 0) {
|
||||
const ownedPaths = toArray(scenarioSpec?.data?.ownedPaths);
|
||||
const allowedPaths = [...touchedAreas, ...ownedPaths];
|
||||
const uncovered = changedFiles.filter((file) => !pathMatchesAny(file, allowedPaths));
|
||||
if (uncovered.length > 0) {
|
||||
failures.push(`${taskSpec.path}: changed files are not covered by touchedAreas or scenario ownedPaths: ${uncovered.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
return failures;
|
||||
}
|
||||
20
harness/src/runner.mjs
Normal file
20
harness/src/runner.mjs
Normal file
@@ -0,0 +1,20 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
export async function runStep(step) {
|
||||
const started = Date.now();
|
||||
return await new Promise((resolve) => {
|
||||
const child = spawn(step.command, step.args, {
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
|
||||
child.on('close', (exitCode) => {
|
||||
resolve({
|
||||
...step,
|
||||
status: exitCode === 0 ? 'pass' : 'fail',
|
||||
exitCode,
|
||||
durationMs: Date.now() - started,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
136
harness/src/specs.mjs
Normal file
136
harness/src/specs.mjs
Normal file
@@ -0,0 +1,136 @@
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
export const SPEC_ROOT = path.join(ROOT, 'harness', 'specs');
|
||||
|
||||
function parseScalar(value) {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === 'true') return true;
|
||||
if (trimmed === 'false') return false;
|
||||
if (trimmed === 'null') return null;
|
||||
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function parseFrontmatter(markdown) {
|
||||
const match = markdown.match(/^---\n([\s\S]*?)\n---(?:\n|$)/);
|
||||
if (!match) {
|
||||
throw new Error('Spec must start with Markdown frontmatter');
|
||||
}
|
||||
|
||||
const data = {};
|
||||
let currentKey = null;
|
||||
let nestedKey = null;
|
||||
|
||||
for (const rawLine of match[1].split('\n')) {
|
||||
if (!rawLine.trim() || rawLine.trimStart().startsWith('#')) continue;
|
||||
const indent = rawLine.match(/^ */)?.[0].length ?? 0;
|
||||
const line = rawLine.trim();
|
||||
|
||||
if (indent === 0) {
|
||||
const keyMatch = line.match(/^([A-Za-z0-9_-]+):(.*)$/);
|
||||
if (!keyMatch) continue;
|
||||
const [, key, rawValue] = keyMatch;
|
||||
currentKey = key;
|
||||
nestedKey = null;
|
||||
data[key] = rawValue.trim() ? parseScalar(rawValue) : [];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (indent === 2 && line.startsWith('- ')) {
|
||||
if (!currentKey) continue;
|
||||
if (!Array.isArray(data[currentKey])) data[currentKey] = [];
|
||||
data[currentKey].push(parseScalar(line.slice(2)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (indent === 2) {
|
||||
const keyMatch = line.match(/^([A-Za-z0-9_-]+):(.*)$/);
|
||||
if (!keyMatch || !currentKey) continue;
|
||||
const [, key, rawValue] = keyMatch;
|
||||
if (!data[currentKey] || Array.isArray(data[currentKey])) data[currentKey] = {};
|
||||
nestedKey = key;
|
||||
data[currentKey][key] = rawValue.trim() ? parseScalar(rawValue) : [];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (indent === 4 && line.startsWith('- ') && currentKey && nestedKey) {
|
||||
if (!data[currentKey] || Array.isArray(data[currentKey])) data[currentKey] = {};
|
||||
if (!Array.isArray(data[currentKey][nestedKey])) data[currentKey][nestedKey] = [];
|
||||
data[currentKey][nestedKey].push(parseScalar(line.slice(2)));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
body: markdown.slice(match[0].length).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadSpec(specPath) {
|
||||
const fullPath = path.resolve(ROOT, specPath);
|
||||
const markdown = await readFile(fullPath, 'utf8');
|
||||
return {
|
||||
path: path.relative(ROOT, fullPath),
|
||||
...parseFrontmatter(markdown),
|
||||
};
|
||||
}
|
||||
|
||||
async function listMarkdownFiles(dir) {
|
||||
try {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
return entries
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
|
||||
.map((entry) => path.join(dir, entry.name));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadScenarioSpecs() {
|
||||
const files = await listMarkdownFiles(path.join(SPEC_ROOT, 'scenarios'));
|
||||
const specs = [];
|
||||
for (const file of files) {
|
||||
const spec = await loadSpec(path.relative(ROOT, file));
|
||||
specs.push(spec);
|
||||
}
|
||||
return specs;
|
||||
}
|
||||
|
||||
export async function loadRuleSpecs() {
|
||||
const files = await listMarkdownFiles(path.join(SPEC_ROOT, 'rules'));
|
||||
const specs = [];
|
||||
for (const file of files) {
|
||||
const spec = await loadSpec(path.relative(ROOT, file));
|
||||
specs.push(spec);
|
||||
}
|
||||
return specs;
|
||||
}
|
||||
|
||||
export function toArray(value) {
|
||||
if (Array.isArray(value)) return value;
|
||||
if (value == null || value === '') return [];
|
||||
return [value];
|
||||
}
|
||||
|
||||
export function isGatewayBackendCommunicationTask(spec) {
|
||||
return spec.data?.scenario === 'gateway-backend-communication'
|
||||
|| toArray(spec.data?.scenarios).includes('gateway-backend-communication');
|
||||
}
|
||||
|
||||
export function globToRegExp(glob) {
|
||||
const escaped = glob
|
||||
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
||||
.replace(/\*\*/g, '::DOUBLE_STAR::')
|
||||
.replace(/\*/g, '[^/]*')
|
||||
.replace(/::DOUBLE_STAR::/g, '.*');
|
||||
return new RegExp(`^${escaped}$`);
|
||||
}
|
||||
|
||||
export function pathMatchesAny(filePath, patterns) {
|
||||
return patterns.some((pattern) => globToRegExp(pattern).test(filePath));
|
||||
}
|
||||
@@ -41,10 +41,13 @@
|
||||
"bundle:openclaw-plugins": "zx scripts/bundle-openclaw-plugins.mjs",
|
||||
"bundle:preinstalled-skills": "zx scripts/bundle-preinstalled-skills.mjs",
|
||||
"lint": "eslint . --fix",
|
||||
"lint:check": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "pnpm run build:vite && playwright test",
|
||||
"test:e2e:headed": "pnpm run build:vite && playwright test --headed",
|
||||
"harness": "pnpm --filter @clawx/harness start --",
|
||||
"harness:ci": "pnpm harness list && pnpm harness validate --spec harness/specs/scenarios/gateway-backend-communication.md && pnpm harness validate --spec harness/specs/tasks/fix-chat-history-gateway-timeout.example.md --no-diff && pnpm harness run --spec harness/specs/scenarios/gateway-backend-communication.md --dry-run && pnpm exec vitest run tests/unit/harness-specs.test.ts tests/unit/harness-git.test.ts",
|
||||
"comms:replay": "node scripts/comms/replay.mjs",
|
||||
"comms:baseline": "node scripts/comms/baseline.mjs",
|
||||
"comms:compare": "node scripts/comms/compare.mjs",
|
||||
@@ -174,4 +177,4 @@
|
||||
"zx": "^8.8.5"
|
||||
},
|
||||
"packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268"
|
||||
}
|
||||
}
|
||||
|
||||
2
pnpm-lock.yaml
generated
2
pnpm-lock.yaml
generated
@@ -310,6 +310,8 @@ importers:
|
||||
specifier: ^8.8.5
|
||||
version: 8.8.5
|
||||
|
||||
harness: {}
|
||||
|
||||
packages:
|
||||
|
||||
7zip-bin@5.2.0:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
packages:
|
||||
- '.'
|
||||
- 'harness'
|
||||
|
||||
ignoredBuiltDependencies:
|
||||
- electron
|
||||
|
||||
40
tests/unit/harness-git.test.ts
Normal file
40
tests/unit/harness-git.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getChangedFiles } from '../../harness/src/git.mjs';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
async function git(cwd: string, args: string[]): Promise<void> {
|
||||
await execFileAsync('git', args, { cwd });
|
||||
}
|
||||
|
||||
describe('harness git changed files', () => {
|
||||
it('includes staged tracked files when collecting changed paths', async () => {
|
||||
const repo = await mkdtemp(path.join(tmpdir(), 'clawx-harness-git-'));
|
||||
const harnessDir = path.join(repo, 'harness', 'src');
|
||||
|
||||
try {
|
||||
await mkdir(harnessDir, { recursive: true });
|
||||
await writeFile(path.join(repo, 'tracked.txt'), 'before\n');
|
||||
await git(repo, ['init']);
|
||||
await git(repo, ['config', 'user.email', 'test@example.com']);
|
||||
await git(repo, ['config', 'user.name', 'Test']);
|
||||
await git(repo, ['add', 'tracked.txt']);
|
||||
await git(repo, ['commit', '-m', 'init']);
|
||||
|
||||
await writeFile(path.join(repo, 'tracked.txt'), 'after\n');
|
||||
await git(repo, ['add', 'tracked.txt']);
|
||||
|
||||
const changed = await getChangedFiles('HEAD', repo);
|
||||
|
||||
expect(changed).toContain('tracked.txt');
|
||||
} finally {
|
||||
await rm(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
88
tests/unit/harness-specs.test.ts
Normal file
88
tests/unit/harness-specs.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
parseFrontmatter,
|
||||
pathMatchesAny,
|
||||
} from '../../harness/src/specs.mjs';
|
||||
import {
|
||||
scanBackendCommunicationBoundary,
|
||||
touchesCommunicationPath,
|
||||
validateGatewayTaskSpec,
|
||||
} from '../../harness/src/rules.mjs';
|
||||
|
||||
describe('harness specs', () => {
|
||||
it('parses Markdown frontmatter with arrays and nested docs', () => {
|
||||
const spec = parseFrontmatter(`---
|
||||
id: example
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
docs:
|
||||
required: false
|
||||
---
|
||||
|
||||
Body`);
|
||||
|
||||
expect(spec.data.id).toBe('example');
|
||||
expect(spec.data.requiredProfiles).toEqual(['fast', 'comms']);
|
||||
expect(spec.data.docs).toEqual({ required: false });
|
||||
});
|
||||
|
||||
it('matches repository glob paths', () => {
|
||||
expect(pathMatchesAny('src/stores/chat/history-actions.ts', ['src/stores/chat/**'])).toBe(true);
|
||||
expect(pathMatchesAny('src/lib/api-client.ts', ['src/lib/api-client.ts'])).toBe(true);
|
||||
expect(pathMatchesAny('src/pages/Chat/index.tsx', ['electron/gateway/**'])).toBe(false);
|
||||
});
|
||||
|
||||
it('requires gateway backend communication tasks to run fast and comms', () => {
|
||||
const taskSpec = {
|
||||
path: 'harness/specs/tasks/example.md',
|
||||
data: {
|
||||
id: 'example',
|
||||
title: 'Example',
|
||||
scenario: 'gateway-backend-communication',
|
||||
taskType: 'runtime-bridge',
|
||||
intent: 'Adjust backend communication.',
|
||||
touchedAreas: ['src/lib/api-client.ts'],
|
||||
expectedUserBehavior: ['Visible state remains consistent.'],
|
||||
requiredProfiles: ['fast'],
|
||||
acceptance: ['Comms compare passes.'],
|
||||
docs: { required: false },
|
||||
},
|
||||
};
|
||||
const scenarioSpec = {
|
||||
data: {
|
||||
requiredProfiles: ['fast', 'comms'],
|
||||
ownedPaths: ['src/lib/api-client.ts'],
|
||||
},
|
||||
};
|
||||
|
||||
expect(validateGatewayTaskSpec(taskSpec, scenarioSpec)).toContain(
|
||||
'harness/specs/tasks/example.md: requiredProfiles must include "comms"',
|
||||
);
|
||||
});
|
||||
|
||||
it('detects communication path changes', () => {
|
||||
expect(touchesCommunicationPath(['electron/gateway/manager.ts'])).toBe(true);
|
||||
expect(touchesCommunicationPath(['README.md'])).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks direct Gateway HTTP in renderer files', async () => {
|
||||
const failures = await scanBackendCommunicationBoundary(['src/pages/Chat/index.tsx']);
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
|
||||
it('allows fallback flags only in their boundary modules', async () => {
|
||||
const failures = await scanBackendCommunicationBoundary([
|
||||
'src/lib/api-client.ts',
|
||||
'src/lib/host-api.ts',
|
||||
'src/lib/host-events.ts',
|
||||
]);
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
|
||||
it('allows pages and components to display gatewayReady state', async () => {
|
||||
const failures = await scanBackendCommunicationBoundary(['src/components/layout/Sidebar.tsx']);
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user