Skip to content
Open
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
95 changes: 90 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ jobs:
timeout-minutes: 15
env:
PLAYWRIGHT_BROWSERS_PATH: /home/runner/.cache/ms-playwright
WEB_CI_STEP_TIMINGS_FILE: /tmp/web-ci-step-timings.tsv

steps:
- name: Checkout
Expand All @@ -42,6 +43,13 @@ jobs:
cache: pnpm
cache-dependency-path: pnpm-lock.yaml

- name: Initialize CI step timing collector
run: |
: > "$WEB_CI_STEP_TIMINGS_FILE"

- name: Mark Playwright cache restore start
run: echo "PLAYWRIGHT_CACHE_RESTORE_START=$(date +%s)" >> "$GITHUB_ENV"

- name: Restore Playwright Chromium cache
id: playwright-cache
uses: actions/cache/restore@v5
Expand All @@ -51,6 +59,15 @@ jobs:
restore-keys: |
playwright-chromium-${{ runner.os }}-

- name: Record Playwright cache restore duration
if: always()
env:
PLAYWRIGHT_CACHE_HIT: ${{ steps.playwright-cache.outputs.cache-hit }}
run: |
end_seconds=$(date +%s)
wall_seconds=$((end_seconds - PLAYWRIGHT_CACHE_RESTORE_START))
printf "Restore Playwright Chromium cache\t%s\tcache-hit=%s\n" "$wall_seconds" "${PLAYWRIGHT_CACHE_HIT:-false}" >> "$WEB_CI_STEP_TIMINGS_FILE"

- name: Record CI runtime and cache inputs
env:
PLAYWRIGHT_CACHE_HIT: ${{ steps.playwright-cache.outputs.cache-hit }}
Expand All @@ -67,13 +84,37 @@ jobs:
} >> "$GITHUB_STEP_SUMMARY"

- name: Install dependencies
run: pnpm install --frozen-lockfile
run: |
start_seconds=$(date +%s)
set +e
pnpm install --frozen-lockfile
status=$?
end_seconds=$(date +%s)
wall_seconds=$((end_seconds - start_seconds))
printf "Install dependencies\t%s\texit=%s\n" "$wall_seconds" "$status" >> "$WEB_CI_STEP_TIMINGS_FILE"
exit "$status"

- name: Install Playwright Chromium
run: pnpm --filter @draftorbit/web exec playwright install --with-deps chromium
run: |
start_seconds=$(date +%s)
set +e
pnpm --filter @draftorbit/web exec playwright install --with-deps chromium
status=$?
end_seconds=$(date +%s)
wall_seconds=$((end_seconds - start_seconds))
printf "Install Playwright Chromium\t%s\texit=%s\n" "$wall_seconds" "$status" >> "$WEB_CI_STEP_TIMINGS_FILE"
exit "$status"

- name: Web typecheck
run: pnpm --filter @draftorbit/web typecheck
run: |
start_seconds=$(date +%s)
set +e
pnpm --filter @draftorbit/web typecheck
status=$?
end_seconds=$(date +%s)
wall_seconds=$((end_seconds - start_seconds))
printf "Web typecheck\t%s\texit=%s\n" "$wall_seconds" "$status" >> "$WEB_CI_STEP_TIMINGS_FILE"
exit "$status"

- name: Web test (required)
env:
Expand All @@ -82,7 +123,7 @@ jobs:
NEXT_PUBLIC_API_URL: /__api
NEXT_PUBLIC_ENABLE_LOCAL_LOGIN: 'true'
WEB_PLAYWRIGHT_PORT: 3300
WEB_PLAYWRIGHT_WORKERS: '2'
WEB_PLAYWRIGHT_WORKERS: '3'
WEB_PLAYWRIGHT_REPORTER_TARGET_SECONDS: '10'
WEB_PLAYWRIGHT_REPORTER_HARD_BUDGET_SECONDS: '12'
WEB_PLAYWRIGHT_ENFORCE_BUDGET: '1'
Expand All @@ -101,14 +142,27 @@ jobs:
echo "| CI web test step wall time | ${wall_seconds}s |"
echo "| exit code | ${status} |"
} >> "$GITHUB_STEP_SUMMARY"
printf "Web test (required)\t%s\texit=%s\n" "$wall_seconds" "$status" >> "$WEB_CI_STEP_TIMINGS_FILE"
exit "$status"

- name: Web build
env:
NEXT_TELEMETRY_DISABLED: '1'
NEXT_PUBLIC_API_URL: /__api
NEXT_PUBLIC_ENABLE_LOCAL_LOGIN: 'true'
run: pnpm --filter @draftorbit/web build
run: |
start_seconds=$(date +%s)
set +e
pnpm --filter @draftorbit/web build
status=$?
end_seconds=$(date +%s)
wall_seconds=$((end_seconds - start_seconds))
printf "Web build\t%s\texit=%s\n" "$wall_seconds" "$status" >> "$WEB_CI_STEP_TIMINGS_FILE"
exit "$status"

- name: Mark Playwright cache save start
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: echo "PLAYWRIGHT_CACHE_SAVE_START=$(date +%s)" >> "$GITHUB_ENV"

- name: Save Playwright Chromium cache
if: steps.playwright-cache.outputs.cache-hit != 'true'
Expand All @@ -117,6 +171,37 @@ jobs:
path: ~/.cache/ms-playwright
key: playwright-chromium-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}

- name: Record Playwright cache save duration
if: always()
env:
PLAYWRIGHT_CACHE_HIT: ${{ steps.playwright-cache.outputs.cache-hit }}
run: |
if [ "${PLAYWRIGHT_CACHE_HIT:-false}" = "true" ]; then
printf "Save Playwright Chromium cache\t0\tcache-hit=true (skipped)\n" >> "$WEB_CI_STEP_TIMINGS_FILE"
exit 0
fi
end_seconds=$(date +%s)
wall_seconds=$((end_seconds - PLAYWRIGHT_CACHE_SAVE_START))
printf "Save Playwright Chromium cache\t%s\texit=0\n" "$wall_seconds" >> "$WEB_CI_STEP_TIMINGS_FILE"

- name: Publish CI step duration table
if: always()
run: |
{
echo "### CI step duration table"
echo
echo "| step | wall time (s) | note |"
echo "| --- | ---: | --- |"
if [ ! -s "$WEB_CI_STEP_TIMINGS_FILE" ]; then
echo "| none | 0 | timing file missing |"
else
while IFS=$'\t' read -r step wall_seconds note; do
[ -z "$step" ] && continue
echo "| ${step} | ${wall_seconds} | ${note} |"
done < "$WEB_CI_STEP_TIMINGS_FILE"
fi
} >> "$GITHUB_STEP_SUMMARY"

- name: Upload Playwright failure artifacts
if: failure()
uses: actions/upload-artifact@v7
Expand Down
130 changes: 79 additions & 51 deletions apps/web/e2e/ordinary-user-ci.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,9 +457,23 @@ async function openApp(page: Page) {
await expect(page.getByText('未连接 X 账号 · 仍可先生成')).toBeVisible();
}

async function startGeneration(page: Page, input: { prompt: string; format?: 'tweet' | 'thread' | 'article'; visualMode?: string }) {
await openApp(page);
type GenerationScenario = {
name: string;
prompt: string;
format?: 'tweet' | 'thread' | 'article';
visualMode?: string;
expected: RegExp[];
};

async function ensureAdvancedOptionsOpen(page: Page) {
const visualModeSelect = page.locator('select[name="visualMode"]');
if (await visualModeSelect.isVisible()) return;
await page.getByText('高级选项').click();
await expect(visualModeSelect).toBeVisible();
}

async function startGenerationInOpenApp(page: Page, input: { prompt: string; format?: 'tweet' | 'thread' | 'article'; visualMode?: string }) {
await ensureAdvancedOptionsOpen(page);
if (input.format && input.format !== 'tweet') {
const label = input.format === 'thread' ? '串推' : '长文';
await page.getByRole('button', { name: new RegExp(label, 'u') }).click();
Expand All @@ -472,11 +486,34 @@ async function startGeneration(page: Page, input: { prompt: string; format?: 'tw
await expect(page.getByText('结果区')).toBeVisible();
}

async function runGenerationScenario(page: Page, scenario: GenerationScenario) {
const scenarioStart = Date.now();
await startGenerationInOpenApp(page, scenario);
for (const expected of scenario.expected) {
await expect(page.getByText(expected).first()).toBeVisible();
}

if (scenario.name.includes('thread')) {
await expect(page.getByRole('img', { name: /卡片组/u }).first()).toBeVisible();
}

if (scenario.name.includes('article')) {
await page.getByText('查看依据与配图建议').click();
await expect(page.getByText('来源已抓取').first()).toBeVisible();
}

const bundleLink = page.getByRole('link', { name: /下载全部图文资产|下载 bundle/u }).first();
await expect(bundleLink).toHaveAttribute('href', /token=/u);
await expect(page.getByRole('button', { name: /只重试图片\/图文资产/u })).toBeDisabled();
const durationSeconds = ((Date.now() - scenarioStart) / 1000).toFixed(2);
console.log(`[ci-perf] generation scenario "${scenario.name}" completed in ${durationSeconds}s`);
}

test.beforeEach(async ({ page }) => {
await mockDraftOrbitApi(page);
});

test('ordinary user can enter the app from home local CTA with visible focus and responsive layout', async ({ page }) => {
test('ordinary user can enter the app from home local CTA and verify safe connect/queue/pricing gates', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 900 });
await page.goto('/');

Expand All @@ -485,7 +522,6 @@ test('ordinary user can enter the app from home local CTA with visible focus and
await localCta.hover();
await localCta.focus();
await expect(localCta).toBeFocused();
await page.screenshot({ path: test.info().outputPath('home-local-cta-mobile.png'), fullPage: true });

const hasHorizontalOverflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth + 1);
expect(hasHorizontalOverflow).toBe(false);
Expand All @@ -494,9 +530,26 @@ test('ordinary user can enter the app from home local CTA with visible focus and
await expect(page).toHaveURL(/\/app$/u);
await expect(page.getByRole('button', { name: /开始生成/u })).toBeVisible();
await expect(page.getByText('未连接 X 账号 · 仍可先生成')).toBeVisible();

await page.goto('/connect?intent=connect_x_self');
await expect(page).toHaveURL(/\/app\?nextAction=connect_x_self/u);
await expect(page.getByRole('heading', { name: '连接 X 账号后再发布会更顺' })).toBeVisible();
await expect(page.getByRole('button', { name: /^连接 X 账号$/u })).toBeVisible();

await page.goto('/queue?intent=confirm_publish');
await expect(page).toHaveURL(/\/app\?nextAction=confirm_publish/u);
await expect(page.getByText('确认这条内容是否发出')).toBeVisible();
await expect(page.getByText('当前待确认内容')).toBeVisible();
await expect(page.getByText('这条内容等待你确认后再发出')).toBeVisible();

await page.goto('/pricing');
await expect(page.getByText('升级与结账')).toBeVisible();
await expect(page.getByRole('button', { name: '月付' })).toBeVisible();
await expect(page.getByRole('button', { name: /开始 3 天试用/u }).first()).toBeVisible();
await expect(page).not.toHaveURL(/checkout\.example\.test/u);
});

const generationScenarios = [
const generationScenariosFast: GenerationScenario[] = [
{
name: 'tweet cover assets and safe publish gate',
prompt: '别再靠灵感写推文,给我一条更像真人的冷启动判断句。',
Expand All @@ -507,7 +560,10 @@ const generationScenarios = [
prompt: '把一个 AI 产品新功能写成 4 条 thread,不要像建议模板。',
format: 'thread' as const,
expected: [/1\/4/u, /4\/4/u, /下载 bundle/u]
},
}
];

const generationScenariosRich: GenerationScenario[] = [
{
name: 'article with cover infographic illustration and exports',
prompt: '根据这篇来源写一篇关于最新 Hermes Agent 的 X 长文:https://example.com/source',
Expand All @@ -517,37 +573,33 @@ const generationScenarios = [
{
name: 'diagram visual mode',
prompt: '用一条短推解释 DraftOrbit 从输入一句话到手动确认发布的 5 步流程,并配一个流程图:输入→来源→正文→图文→确认。',
format: 'tweet' as const,
visualMode: 'diagram',
expected: [/流程图/u, /输入→来源→正文→图文→确认/u, /下载 SVG/u]
}
];

test('app generation covers tweet thread article and diagram visual outputs', async ({ page }) => {
for (const scenario of generationScenarios) {
test('app generation covers tweet and thread visual outputs with minimal page churn', async ({ page }) => {
await openApp(page);
for (const scenario of generationScenariosFast) {
await test.step(scenario.name, async () => {
await startGeneration(page, scenario);
for (const expected of scenario.expected) {
await expect(page.getByText(expected).first()).toBeVisible();
}

if (scenario.name.includes('thread')) {
await expect(page.getByRole('img', { name: /卡片组/u }).first()).toBeVisible();
}

if (scenario.name.includes('article')) {
await page.getByText('查看依据与配图建议').click();
await expect(page.getByText('来源已抓取').first()).toBeVisible();
}
await runGenerationScenario(page, scenario);
});
}
});

const bundleLink = page.getByRole('link', { name: /下载全部图文资产|下载 bundle/u }).first();
await expect(bundleLink).toHaveAttribute('href', /token=/u);
await expect(page.getByRole('button', { name: /只重试图片\/图文资产/u })).toBeDisabled();
test('app generation covers article and diagram visual outputs with minimal page churn', async ({ page }) => {
await openApp(page);
for (const scenario of generationScenariosRich) {
await test.step(scenario.name, async () => {
await runGenerationScenario(page, scenario);
});
}
});

test('app exposes Markdown copy success and retry-only visual asset recovery', async ({ page }) => {
await startGeneration(page, { prompt: '重试图文:生成一条带失败图片的短推,用来验证只重试图文资产。' });
test('app handles retry-only visual recovery and latest-source fail-closed path in one user session', async ({ page }) => {
await openApp(page);
await startGenerationInOpenApp(page, { prompt: '重试图文:生成一条带失败图片的短推,用来验证只重试图文资产。' });

await expect(page.getByText('部分图片资产没有达到可发布标准')).toBeVisible();
const retryButton = page.getByRole('button', { name: /只重试图片\/图文资产/u });
Expand All @@ -558,10 +610,7 @@ test('app exposes Markdown copy success and retry-only visual asset recovery', a

await page.getByRole('button', { name: '复制 Markdown' }).click();
await expect(page.getByText('Markdown 已复制')).toBeVisible();
});

test('latest ambiguous source request fails closed with recoverable copy and no ready visual assets', async ({ page }) => {
await startGeneration(page, { prompt: '生成关于最新的 Hermes 的文章', format: 'article' });
await startGenerationInOpenApp(page, { prompt: '生成关于最新的 Hermes 的文章', format: 'article' });

await expect(page.getByText('需要可靠来源,不能编造最新事实', { exact: true }).first()).toBeVisible();
await expect(page.getByRole('button', { name: '粘贴来源 URL 再生成' })).toBeVisible();
Expand All @@ -573,24 +622,3 @@ test('latest ambiguous source request fails closed with recoverable copy and no
await page.getByRole('button', { name: '粘贴来源 URL 再生成' }).click();
await expect(page.locator('textarea').first()).toHaveValue(/来源 URL:/u);
});

test('connect queue and pricing routes expose safe manual gates without external posting or payment', async ({ page }) => {
await seedSession(page);

await page.goto('/connect?intent=connect_x_self');
await expect(page).toHaveURL(/\/app\?nextAction=connect_x_self/u);
await expect(page.getByRole('heading', { name: '连接 X 账号后再发布会更顺' })).toBeVisible();
await expect(page.getByRole('button', { name: /^连接 X 账号$/u })).toBeVisible();

await page.goto('/queue?intent=confirm_publish');
await expect(page).toHaveURL(/\/app\?nextAction=confirm_publish/u);
await expect(page.getByText('确认这条内容是否发出')).toBeVisible();
await expect(page.getByText('当前待确认内容')).toBeVisible();
await expect(page.getByText('这条内容等待你确认后再发出')).toBeVisible();

await page.goto('/pricing');
await expect(page.getByText('升级与结账')).toBeVisible();
await expect(page.getByRole('button', { name: '月付' })).toBeVisible();
await expect(page.getByRole('button', { name: /开始 3 天试用/u }).first()).toBeVisible();
await expect(page).not.toHaveURL(/checkout\.example\.test/u);
});
20 changes: 20 additions & 0 deletions docs/agent-handoff.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,26 @@ Use this file to transfer execution state between Codex, Cursor, and other agent
Update it before pausing work, switching tools, or asking another agent to continue.


## Current Playwright reporter-time stabilization pass (2026-04-17)

- Worktree: `/Users/yangshu/.config/superpowers/worktrees/002-draftorbit.io/web-ci-perf-8s-stability`
- Branch: `codex/web-ci-perf-8s-stability`
- Base: `origin/main` at `90fb21816e7b3df9ce628fbcea390c699729f88f`.
- Goal: stabilize web Playwright reporter time around the 8s lane with lower run-to-run variance while preserving the required-check contract.
- Scenario optimization:
- `apps/web/e2e/ordinary-user-ci.spec.ts` now keeps the app open per generation-group test and reuses the same page/session across scenarios instead of reopening `/app` every scenario.
- Generation scenarios are split into two grouped tests (`tweet/thread` and `article/diagram`) to reduce per-step churn and improve CI worker scheduling.
- Retry-only visual recovery + latest-source fail-closed assertions now run in one continuous app session test instead of two separate reopen flows.
- Connect/queue/pricing safe-gate checks are folded into the home→app entry test to remove an extra test lifecycle while keeping route coverage.
- Mobile CTA test keeps hover/focus/overflow assertions but removes always-on screenshot capture in CI runs to cut avoidable I/O latency.
- Added per-scenario timing logs (`[ci-perf] generation scenario ...`) for direct hotspot inspection in Actions logs.
- CI observability upgrade:
- `.github/workflows/ci.yml` now writes a persistent `CI step duration table` into `$GITHUB_STEP_SUMMARY`.
- Web Playwright workers are tuned to `3` in CI to improve parallel scheduling while staying below the prior contention seen at higher worker counts.
- The table includes wall time + note for `Restore Playwright Chromium cache`, `Install Playwright Chromium`, `Web test (required)`, `Web build`, and cache save behavior.
- Added explicit restore/save timing rows (including skipped-on-cache-hit visibility) so long-tail cache behavior is observable across runs.


## Current main CI budget flake recovery (2026-04-17)

- Worktree: `/Users/yangshu/.config/superpowers/worktrees/002-draftorbit.io/fix-main-web-budget-flake`
Expand Down