Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
f5f2160
feat(test): 新增 LLM Mock 与流量录制服务支持免 Token E2E 测试
deepcoldy Oct 7, 2026
1915ff7
feat(test): 支持真机 E2E 自动拉起测试 Daemon 与全局并发互斥锁
deepcoldy Oct 8, 2026
a7434ff
fix(test): 修复 Mock Server 报文脱敏、单引号转义及动态回放覆盖
deepcoldy Oct 8, 2026
959aa0b
fix(test): 修复 E2E Daemon 启动运行时、SSRF 安全漏洞与会话查找容错
deepcoldy Oct 8, 2026
5382f38
fix(core): 修复新环境缺失 data 目录导致文件锁 ENOENT 崩溃
deepcoldy Oct 9, 2026
d724cdc
fix(test): 修复 CLI 运行时安装、工作目录跨机回退与文件锁 ENOENT 穿透
deepcoldy Oct 9, 2026
add6620
fix(test): 修复 E2E 私聊话题模式缺失导致无法匹配话题条目与等待超时
deepcoldy Oct 9, 2026
ad493fa
fix(test): 修复 Claude 认证端点配置与 Codex 侧响应判定收敛
deepcoldy Oct 9, 2026
c01766c
fix(test): 优化模型回复与 Codex 侧响应判定顺序
deepcoldy Oct 9, 2026
8a99ef7
fix(test): 优化流式卡片输出折叠展开判定与测试清理重试
deepcoldy Oct 9, 2026
bc22bdf
feat(test): 支持 E2E 录屏回放与细粒度步骤报告快照
deepcoldy Oct 10, 2026
2e2bbd5
fix(test): 将流式卡片输出折叠断言改为 aiWaitFor 容忍卡片异步重绘
deepcoldy Oct 10, 2026
427d08c
fix(test): 修复 mock-llm-server 工具结果判定被 trailing system 消息覆盖导致的重复命令循环
deepcoldy Oct 10, 2026
57f3051
fix(test): 修复 Codex Mock 协议与回复断言并打通 Responses API 投递
deepcoldy Oct 11, 2026
c25f073
chore(merge): 合并 master 分支并解决冲突,强化 Codex Mock sentinel 判定
deepcoldy Oct 12, 2026
6945a2b
chore(merge): 合并 master 分支更新
deepcoldy Oct 12, 2026
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
30 changes: 28 additions & 2 deletions .github/workflows/midscene-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ permissions:
contents: read

concurrency:
group: midscene-e2e-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
group: feishu-live-e2e-shared-lock
cancel-in-progress: false

jobs:
midscene-report:
Expand Down Expand Up @@ -121,13 +121,25 @@ jobs:
bun x playwright install --with-deps chromium
sudo apt-get install -y fonts-noto-cjk

- name: Install CLI runtimes for E2E
run: |
npm install -g @anthropic-ai/claude-code @openai/codex || sudo npm install -g @anthropic-ai/claude-code @openai/codex
mkdir -p ~/.claude
echo '{"hasCompletedOnboarding":true,"autoUpdates":false}' > ~/.claude.json
echo '{"skipDangerousModePermissionPrompt":true,"permissions":{"defaultMode":"bypassPermissions"}}' > ~/.claude/settings.json
mkdir -p ~/.codex
printf '[projects."*"]\ntrust_level = "trusted"\n' > ~/.codex/config.toml
claude --version
codex --version

- name: Build Botmux
run: bun run build

- name: Run Dashboard Midscene project
id: dashboard_midscene
env:
MIDSCENE_RUN_DIR: ${{ github.workspace }}/midscene_run/dashboard
BOTMUX_E2E_RECORD_VIDEO: "1"
run: bun run test:midscene

- name: Detect Feishu live-test configuration
Expand All @@ -136,6 +148,7 @@ jobs:
env:
FEISHU_STORAGE_STATE_BASE64: ${{ secrets.FEISHU_STORAGE_STATE_BASE64 }}
FEISHU_STORAGE_STATE_GZIP_BASE64: ${{ secrets.FEISHU_STORAGE_STATE_GZIP_BASE64 }}
FEISHU_E2E_BOTS_JSON: ${{ secrets.FEISHU_E2E_BOTS_JSON }}
run: |
missing=()
[[ -n "${FEISHU_TEST_GROUP_URL:-}" ]] || missing+=("FEISHU_TEST_GROUP_URL")
Expand All @@ -154,6 +167,12 @@ jobs:
fi
node -e "const fs=require('node:fs'); const value=JSON.parse(fs.readFileSync('storageState.json','utf8')); if (!Array.isArray(value.cookies) || value.cookies.length === 0) throw new Error('The Feishu Playwright storage state has no cookies')"
echo "ready=true" >> "$GITHUB_OUTPUT"
if [[ -n "${FEISHU_E2E_BOTS_JSON:-}" ]]; then
printf '%s' "$FEISHU_E2E_BOTS_JSON" > test-bots.json
echo "has_bots=true" >> "$GITHUB_OUTPUT"
else
echo "has_bots=false" >> "$GITHUB_OUTPUT"
fi

- name: Run migrated Feishu Midscene project
id: feishu_midscene
Expand All @@ -164,6 +183,10 @@ jobs:
if: always() && !cancelled() && steps.feishu_config.outputs.ready == 'true'
env:
MIDSCENE_RUN_DIR: ${{ github.workspace }}/midscene_run/feishu
BOTMUX_MOCK_LLM: "1"
BOTMUX_START_DAEMON: ${{ steps.feishu_config.outputs.has_bots }}
BOTS_CONFIG: test-bots.json
BOTMUX_E2E_RECORD_VIDEO: "1"
run: bun run test:midscene:feishu

- name: Prepare linked Midscene evidence site
Expand All @@ -185,6 +208,9 @@ jobs:
if [[ "$feishu_outcome" != "skipped" ]]; then
arguments+=(--feishu-report-root test/e2e-browser/midscene_run)
fi
if [[ -d test/e2e-browser/midscene_run/videos ]]; then
arguments+=(--videos-dir test/e2e-browser/midscene_run/videos)
fi
node scripts/prepare-midscene-pages.mjs "${arguments[@]}"

- name: Write Midscene run summary
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,4 @@ src/desktop/assets/*.png

# OMO 计划产物(.omo/ 计划、草稿、证据),绝不提交
.omo/
test-bots.json
40 changes: 40 additions & 0 deletions scripts/mock-llm-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#!/usr/bin/env tsx
import { MockLlmServer, type MockServerMode } from '../test/helpers/mock-llm-server/index.js';

const args = process.argv.slice(2);

function getArgValue(flag: string): string | undefined {
const idx = args.indexOf(flag);
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : undefined;
}

const port = Number(getArgValue('--port') ?? process.env.MOCK_PORT ?? '9999');
const mode = (getArgValue('--mode') ?? process.env.MOCK_MODE ?? 'synthetic') as MockServerMode;
const upstreamUrl = getArgValue('--upstream') ?? process.env.UPSTREAM_URL;
const fixturesDir = getArgValue('--fixtures-dir') ?? process.env.FIXTURES_DIR;

console.log(`[mock-llm-server] Starting server on port=${port}, mode=${mode}...`);

const server = new MockLlmServer({
port,
mode,
upstreamUrl,
fixturesDir,
verbose: true,
});

const { baseUrl } = await server.start();
console.log(`[mock-llm-server] ✅ Ready at ${baseUrl}`);
console.log(`[mock-llm-server] To route Claude Code through this mock server, set:`);
console.log(` export ANTHROPIC_BASE_URL=${baseUrl}`);
console.log(` export ANTHROPIC_API_KEY=mock-key`);
console.log(`[mock-llm-server] Press Ctrl+C to stop.`);

const shutdown = async () => {
console.log('\n[mock-llm-server] Stopping server...');
await server.stop();
process.exit(0);
};

process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
49 changes: 44 additions & 5 deletions scripts/prepare-midscene-pages.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,44 @@ function caseTarget(testCase) {
: testCase.reportPath;
}

function landingPage(cases) {
function replayCell(testCase, videosByCase) {
const src = videosByCase[testCase.name];
if (!src) return '<span class="unavailable">Not available</span>';
return `<video controls preload="metadata" src="${escapeHtml(src)}"></video>`;
}

async function videoSources(videosDir, output) {
if (!videosDir) return {};
const root = path.resolve(videosDir);
let manifest;
try {
manifest = JSON.parse(await readFile(path.join(root, 'manifest.json'), 'utf8'));
} catch (error) {
if (error?.code === 'ENOENT') return {};
throw error;
}
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) return {};
const sources = {};
await mkdir(path.join(output, 'videos'), { recursive: true });
for (const [caseName, fileName] of Object.entries(manifest)) {
if (typeof fileName !== 'string') continue;
const base = path.basename(fileName);
if (base !== fileName || !base.endsWith('.webm')) continue;
const source = path.join(root, base);
try {
const info = await stat(source);
if (!info.isFile()) continue;
} catch (error) {
if (error?.code === 'ENOENT') continue;
throw error;
}
await cp(source, path.join(output, 'videos', base));
sources[caseName] = `videos/${base}`;
}
return sources;
}

function landingPage(cases, videosByCase) {
const passed = cases.filter((testCase) => testCase.status === 'success').length;
const failed = cases.filter((testCase) => testCase.status === 'failed').length;
const skipped = cases.filter((testCase) => testCase.status === 'skipped').length;
Expand All @@ -182,7 +219,8 @@ function landingPage(cases) {
const preview = testCase.previewPath && target
? `<a href="${escapeHtml(target)}"><img src="${escapeHtml(testCase.previewPath)}" alt="${escapeHtml(testCase.name)} screenshot" loading="lazy"></a>`
: '<span class="unavailable">Not available</span>';
return `<tr><td>${icon}</td><td>${name}</td><td>${escapeHtml(testCase.project)}</td><td>${escapeHtml(testCase.status)}</td><td>${formatDuration(testCase.durationMs)}</td><td>${preview}</td></tr>`;
const replay = replayCell(testCase, videosByCase);
return `<tr><td>${icon}</td><td>${name}</td><td>${escapeHtml(testCase.project)}</td><td>${escapeHtml(testCase.status)}</td><td>${formatDuration(testCase.durationMs)}</td><td>${preview}</td><td>${replay}</td></tr>`;
}).join('\n');
return `<!doctype html>
<html lang="en">
Expand All @@ -199,14 +237,14 @@ function landingPage(cases) {
table { width: 100%; border-collapse: collapse; background: #131722; border: 1px solid #2b3142; }
th, td { padding: 12px 14px; border-bottom: 1px solid #2b3142; text-align: left; vertical-align: top; }
th { color: #aeb4c6; font-size: 13px; text-transform: uppercase; }
img { width: 260px; max-height: 160px; object-fit: cover; border-radius: 8px; border: 1px solid #343b50; }
img, video { width: 260px; max-height: 160px; object-fit: cover; border-radius: 8px; border: 1px solid #343b50; background: #000; }
</style>
</head>
<body><main>
<h1>Botmux × Midscene</h1>
<p class="summary"><strong>${passed}/${cases.length} cases passed</strong> · ${failed} failed · ${skipped} skipped · ${notRun} not run</p>
<table>
<thead><tr><th></th><th>Case</th><th>Project</th><th>Status</th><th>Duration</th><th>Node screenshot</th></tr></thead>
<thead><tr><th></th><th>Case</th><th>Project</th><th>Status</th><th>Duration</th><th>Node screenshot</th><th>Replay</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</main></body>
Expand All @@ -219,6 +257,7 @@ export async function prepareMidscenePages(options) {
const skippedCasesDir = required(options, 'skipped-cases-dir');
const output = required(options, 'output');
await mkdir(output, { recursive: true });
const videosByCase = await videoSources(options['videos-dir'], output);

const cases = await loadProjectReport({
reportRoot: dashboardReportRoot,
Expand Down Expand Up @@ -262,7 +301,7 @@ export async function prepareMidscenePages(options) {

const manifest = { schemaVersion: 1, cases };
await writeFile(path.join(output, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
await writeFile(path.join(output, 'index.html'), landingPage(cases));
await writeFile(path.join(output, 'index.html'), landingPage(cases, videosByCase));
process.stdout.write(`Prepared ${cases.length} Midscene cases for publication.\n`);
return manifest;
}
Expand Down
Loading
Loading