From 665e84ebbccf009ffd39950bc0282c6336517759 Mon Sep 17 00:00:00 2001 From: zj1123581321 Date: Thu, 8 Oct 2026 18:01:05 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=9C=9F=E5=AE=9E=20Chro?= =?UTF-8?q?mium=20=E6=B5=8F=E8=A7=88=E5=99=A8=E5=9B=9E=E5=BD=92=E5=85=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Executor: codex-pi Agent-Model: gpt-6-luna Agent-Effort: xhigh Dispatch-Id: dlg-20261008-095055-2ba48d Task-Id: VideoTranscriptAPI-20261008-12 --- .../progress/U-progress.md | 7 + e2e/package-lock.json | 96 ++++++ e2e/package.json | 13 + e2e/playwright.config.ts | 30 ++ e2e/reset-backend.ts | 8 + e2e/run-browser-tests.mjs | 18 ++ e2e/tests/pages.spec.ts | 121 ++++++++ e2e/tsconfig.json | 13 + package.json | 3 +- tests/browser_server.py | 293 ++++++++++++++++++ 10 files changed, 601 insertions(+), 1 deletion(-) create mode 100644 docs/sessions/261008-local-upload/progress/U-progress.md create mode 100644 e2e/package-lock.json create mode 100644 e2e/package.json create mode 100644 e2e/playwright.config.ts create mode 100644 e2e/reset-backend.ts create mode 100644 e2e/run-browser-tests.mjs create mode 100644 e2e/tests/pages.spec.ts create mode 100644 e2e/tsconfig.json create mode 100644 tests/browser_server.py diff --git a/docs/sessions/261008-local-upload/progress/U-progress.md b/docs/sessions/261008-local-upload/progress/U-progress.md new file mode 100644 index 00000000..7db3f287 --- /dev/null +++ b/docs/sessions/261008-local-upload/progress/U-progress.md @@ -0,0 +1,7 @@ +# U 进度 + +## 里程碑 1:真实 Chromium 阻断入口(implementing) + +已接入 `npm run test:browser`,用临时 SQLite 种子和动态端口驱动 Chromium 对提交、历史、公开阅读/导出及其他实际页面做行为/烟雾检查;9 条用例本地全绿。真实页面缺少 `sw.js` 时浏览器报告 404 console error,已补 test-server 路由并保留 console/request 失败观察;无产品代码改动。 + +决策:阻断层独立于 Vitest 和录屏层;`retries=0`、单 spec、`fullyParallel=false`,各用例 beforeEach 重新种子。下一步:提交/推送此入口并开 draft PR,然后接入阻断 CI、登记 has_ui caller 与录屏豁免文档。 diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 00000000..e5939dc8 --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,96 @@ +{ + "name": "video-transcript-api-browser-tests", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "video-transcript-api-browser-tests", + "version": "0.1.0", + "devDependencies": { + "@playwright/test": "1.62.0", + "@types/node": "22.20.1" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz", + "integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz", + "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", + "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 00000000..0669dc03 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,13 @@ +{ + "name": "video-transcript-api-browser-tests", + "private": true, + "version": "0.1.0", + "description": "Blocking Chromium regression tests for the existing VideoTranscriptAPI web pages", + "scripts": { + "test": "node run-browser-tests.mjs" + }, + "devDependencies": { + "@playwright/test": "1.62.0", + "@types/node": "22.20.1" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 00000000..86242172 --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,30 @@ +import { defineConfig, devices } from '@playwright/test'; + +const port = process.env.VTA_BROWSER_PORT; +if (!port) throw new Error('VTA_BROWSER_PORT must be assigned by run-browser-tests.mjs'); +const baseURL = `http://127.0.0.1:${port}`; + +export default defineConfig({ + testDir: './tests', + testMatch: '**/*.spec.ts', + fullyParallel: false, + forbidOnly: true, + retries: 0, + workers: 1, + reporter: 'list', + use: { + baseURL, + browserName: 'chromium', + ...devices['Desktop Chrome'], + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: { + command: `uv run python ../tests/browser_server.py --port ${port}`, + cwd: process.cwd(), + url: `${baseURL}/__e2e__/ready`, + reuseExistingServer: false, + timeout: 30_000, + stdout: 'pipe', + stderr: 'pipe', + }, +}); diff --git a/e2e/reset-backend.ts b/e2e/reset-backend.ts new file mode 100644 index 00000000..8c571750 --- /dev/null +++ b/e2e/reset-backend.ts @@ -0,0 +1,8 @@ +import type { APIRequestContext } from '@playwright/test'; + +export async function resetBackend(request: APIRequestContext, baseURL: string): Promise { + const response = await request.post(`${baseURL}/__e2e__/reset`); + if (!response.ok()) { + throw new Error(`POST /__e2e__/reset failed with HTTP ${response.status()}`); + } +} diff --git a/e2e/run-browser-tests.mjs b/e2e/run-browser-tests.mjs new file mode 100644 index 00000000..e8aa9fb5 --- /dev/null +++ b/e2e/run-browser-tests.mjs @@ -0,0 +1,18 @@ +import { createServer } from 'node:net'; +import { spawn } from 'node:child_process'; + +const portServer = createServer(); +portServer.listen(0, '127.0.0.1'); +await new Promise((resolve) => portServer.once('listening', resolve)); +const address = portServer.address(); +if (!address || typeof address === 'string') throw new Error('Could not allocate browser-test port'); +await new Promise((resolve, reject) => portServer.close((error) => error ? reject(error) : resolve())); + +const playwright = spawn('npx', ['playwright', 'test'], { + env: { ...process.env, VTA_BROWSER_PORT: String(address.port) }, + stdio: 'inherit', +}); +playwright.on('exit', (code, signal) => { + if (signal) process.kill(process.pid, signal); + else process.exitCode = code ?? 1; +}); diff --git a/e2e/tests/pages.spec.ts b/e2e/tests/pages.spec.ts new file mode 100644 index 00000000..63f1b9ec --- /dev/null +++ b/e2e/tests/pages.spec.ts @@ -0,0 +1,121 @@ +import { expect, test, type Page } from '@playwright/test'; +import { resetBackend } from '../reset-backend'; + +const baseURL = process.env.VTA_BROWSER_PORT + ? `http://127.0.0.1:${process.env.VTA_BROWSER_PORT}` + : ''; +const browserErrors = new WeakMap(); + +function observeBrowser(page: Page): void { + const errors: string[] = []; + browserErrors.set(page, errors); + page.on('console', (message) => { + if (message.type() === 'error') errors.push(`console: ${message.text()}`); + }); + page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`)); + page.on('requestfailed', (request) => { + errors.push(`request failed: ${request.method()} ${request.url()} (${request.failure()?.errorText})`); + }); + page.on('response', (response) => { + if (response.status() >= 400) { + errors.push(`HTTP ${response.status()}: ${response.request().method()} ${response.url()}`); + } + }); +} + +test.beforeEach(async ({ page, request }) => { + if (!baseURL) throw new Error('VTA_BROWSER_PORT must be set by the browser test runner'); + observeBrowser(page); + await resetBackend(request, baseURL); +}); + +test.afterEach(async ({ page }) => { + expect(browserErrors.get(page) ?? []).toEqual([]); +}); + +test('submission page sends selected URL and shows the accepted task', async ({ page }) => { + await page.goto('/add_task_by_web'); + await expect(page.getByRole('heading', { name: '视频转录服务' })).toBeVisible(); + await page.locator('#advanced-toggle').click(); + await page.locator('#bearer-token').fill('browser-fixture-token'); + await page.locator('#share-content').fill('https://www.youtube.com/watch?v=browser-fixture'); + await expect(page.locator('.url-option').first()).toBeVisible(); + + const submitted = page.waitForRequest((request) => + request.url().endsWith('/api/transcribe') && request.method() === 'POST'); + await page.locator('#submit-btn').click(); + const request = await submitted; + expect(request.postDataJSON()).toEqual({ + url: 'https://www.youtube.com/watch?v=browser-fixture', + use_speaker_recognition: false, + }); + await expect(page.locator('#status-content')).toContainText('任务提交成功!'); + await expect(page.locator('#status-content a[href="/view/browser-fixture-view"]')).toBeVisible(); +}); + +test('history query renders a seeded task and opening it marks it read', async ({ page }) => { + await page.goto('/static/history.html'); + await page.locator('#apiKeyInput').fill('browser-fixture-token'); + await page.locator('#historyQueryBtn').click(); + const row = page.locator('.task-row[data-vt="browser-fixture-view"]'); + await expect(row.locator('.task-title')).toHaveText('浏览器回归历史样本'); + await expect(row.locator('.read-dot')).toHaveCount(1); + + const popupPromise = page.waitForEvent('popup'); + await row.click(); + const popup = await popupPromise; + await expect(popup.locator('h1')).toHaveText('浏览器回归示例'); + await expect(row).toHaveClass(/read/); + await expect(row.locator('.read-dot')).toHaveCount(0); +}); + +test('public reading page displays transcript and opens a working text export', async ({ page }) => { + await page.goto('/view/browser-fixture-view'); + await expect(page.locator('#calibrated-content-block')).toContainText( + '隔离浏览器夹具中的正文,验证公开阅读与导出。'); + const exportMenu = page.locator('.export-links-details'); + await exportMenu.locator('summary').click(); + const rawExport = page.getByTitle('导出校对文本(纯文本)'); + await expect(rawExport).toHaveAttribute('href', '/view/browser-fixture-view?raw=calibrated'); + await rawExport.click(); + await expect(page.locator('body')).toContainText('隔离浏览器夹具中的正文,验证公开阅读与导出。'); +}); + +test('home page smoke: production landing content and entry links render', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('heading', { name: 'VideoTranscriptAPI' })).toBeVisible(); + await expect(page.getByRole('link', { name: /提交任务/ })).toHaveAttribute('href', '/add_task_by_web'); + await expect(page.getByRole('link', { name: /任务历史/ })).toHaveAttribute('href', '/static/history.html'); +}); + +test('submission page smoke: actual form and navigation are available', async ({ page }) => { + await page.goto('/add_task_by_web'); + await expect(page.locator('#transcribe-form')).toBeVisible(); + await expect(page.locator('#share-content')).toBeVisible(); + await expect(page.locator('#submit-btn')).toBeDisabled(); +}); + +test('history page smoke: authentication and task list controls render', async ({ page }) => { + await page.goto('/static/history.html'); + await expect(page.getByRole('heading', { name: '任务历史' })).toBeVisible(); + await expect(page.locator('#apiKeyInput')).toBeVisible(); + await expect(page.locator('#historyQueryBtn')).toBeVisible(); +}); + +test('processing view smoke: processing state is visible', async ({ page }) => { + await page.goto('/view/processing'); + await expect(page.getByRole('heading', { name: /转录处理中/ })).toBeVisible(); + await expect(page.getByRole('button', { name: /刷新页面/ })).toBeVisible(); +}); + +test('failed view smoke: error state is visible', async ({ page }) => { + await page.goto('/view/failed'); + await expect(page.getByRole('heading', { name: /出现错误/ })).toBeVisible(); + await expect(page.getByText('夹具失败状态')).toBeVisible(); +}); + +test('cleaned view smoke: cleanup state and recovery guidance are visible', async ({ page }) => { + await page.goto('/view/cleaned'); + await expect(page.getByRole('heading', { name: /内容已清理/ })).toBeVisible(); + await expect(page.getByText(/重新获取转录/)).toBeVisible(); +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 00000000..3f1a3017 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "moduleResolution": "node", + "lib": ["ES2022", "DOM"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["**/*.ts"] +} diff --git a/package.json b/package.json index 471108d3..46c3c07a 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "version": "0.1.0", "description": "JS unit tests (vitest) for the PWA front-end assets of VideoTranscriptAPI", "scripts": { - "test:web": "vitest run" + "test:web": "vitest run", + "test:browser": "npm --prefix e2e test" }, "devDependencies": { "jsdom": "27.0.1", diff --git a/tests/browser_server.py b/tests/browser_server.py new file mode 100644 index 00000000..84027ec6 --- /dev/null +++ b/tests/browser_server.py @@ -0,0 +1,293 @@ +"""Isolated HTTP fixture server for real-browser tests of the existing web assets.""" + +from __future__ import annotations + +import argparse +import ast +import json +import mimetypes +import sqlite3 +import tempfile +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs, urlsplit + +from jinja2 import Environment, FileSystemLoader, select_autoescape + +ROOT = Path(__file__).resolve().parents[1] +STATIC_DIR = ROOT / "src" / "web" / "static" +TEMPLATE_DIR = ROOT / "src" / "web" / "templates" +VIEWS_SOURCE = ROOT / "src" / "video_transcript_api" / "api" / "routes" / "views.py" +FIXTURE_TOKEN = "browser-fixture-token" +VIEW_TOKEN = "browser-fixture-view" + + +def read_home_page() -> str: + """Read the exact HTML constant used by the production `/` route.""" + module = ast.parse(VIEWS_SOURCE.read_text(encoding="utf-8")) + for node in module.body: + if isinstance(node, ast.Assign) and any( + isinstance(target, ast.Name) and target.id == "_HOME_HTML" + for target in node.targets + ): + return ast.literal_eval(node.value) + raise RuntimeError("Production home page constant _HOME_HTML was not found") + + +HOME_HTML = read_home_page() +TEMPLATES = Environment( + loader=FileSystemLoader(TEMPLATE_DIR), + autoescape=select_autoescape(["html", "xml"]), +) +TEMPLATES.globals["asset_v"] = "browser-test" +FIXTURE_TRANSCRIPT = "隔离浏览器夹具中的正文,验证公开阅读与导出。" + + +def initialize_database(db_path: Path) -> None: + with sqlite3.connect(db_path) as db: + db.execute( + "CREATE TABLE history (view_token TEXT PRIMARY KEY, title TEXT NOT NULL, " + "request_time TEXT NOT NULL, status TEXT NOT NULL, video_url TEXT NOT NULL)" + ) + db.execute( + "CREATE TABLE submissions (payload TEXT NOT NULL)" + ) + seed_database(db) + + +def seed_database(db: sqlite3.Connection) -> None: + db.execute("DELETE FROM history") + db.execute("DELETE FROM submissions") + db.execute( + "INSERT INTO history VALUES (?, ?, ?, ?, ?)", + ( + VIEW_TOKEN, + "浏览器回归历史样本", + "2026-10-08T12:00:00", + "success", + "https://www.youtube.com/watch?v=fixture", + ), + ) + + +class BrowserFixtureHandler(BaseHTTPRequestHandler): + server_version = "VTA-Browser-Fixture/1.0" + + @property + def db_path(self) -> Path: + return self.server.db_path # type: ignore[attr-defined] + + def log_message(self, format: str, *args: object) -> None: + return + + def send_bytes(self, status: int, content: bytes, content_type: str) -> None: + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(content))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(content) + + def send_json(self, status: int, body: dict[str, object]) -> None: + self.send_bytes( + status, + json.dumps(body, ensure_ascii=False).encode("utf-8"), + "application/json; charset=utf-8", + ) + + def read_json(self) -> dict[str, object]: + size = int(self.headers.get("Content-Length", "0")) + return json.loads(self.rfile.read(size) or b"{}") + + def authorized(self) -> bool: + return self.headers.get("Authorization") == f"Bearer {FIXTURE_TOKEN}" + + def do_GET(self) -> None: + parsed = urlsplit(self.path) + path = parsed.path + if path == "/__e2e__/ready": + self.send_bytes(200, b"ready", "text/plain; charset=utf-8") + return + if path == "/": + self.send_bytes(200, HOME_HTML.encode("utf-8"), "text/html; charset=utf-8") + return + if path == "/add_task_by_web": + self.serve_static(STATIC_DIR / "index.html") + return + if path == "/static/history.html": + self.serve_static(STATIC_DIR / "history.html") + return + if path == "/sw.js": + self.serve_static(STATIC_DIR / "sw.js") + return + if path.startswith("/static/"): + relative = Path(path.removeprefix("/static/")) + asset = (STATIC_DIR / relative).resolve() + if asset.is_relative_to(STATIC_DIR.resolve()) and asset.is_file(): + self.serve_static(asset) + return + self.send_bytes(404, b"not found", "text/plain; charset=utf-8") + return + if path.startswith("/view/"): + query = parse_qs(parsed.query) + if "raw" in query: + self.send_bytes( + 200, + ("# 浏览器回归示例\n\n" + FIXTURE_TRANSCRIPT).encode("utf-8"), + "text/plain; charset=utf-8", + ) + return + if "page" in query: + self.send_bytes( + 200, + ("

浏览器回归示例

" + + FIXTURE_TRANSCRIPT + + "

").encode("utf-8"), + "text/html; charset=utf-8", + ) + return + status = "processing" if path.endswith("processing") else "success" + if path.endswith("failed"): + status = "failed" + elif path.endswith("cleaned"): + status = "file_cleaned" + self.serve_view(status) + return + if path == "/api/audit/filter-options": + if not self.authorized(): + self.send_json(401, {"code": 401, "message": "unauthorized"}) + return + self.send_json( + 200, + { + "code": 200, + "data": { + "api_key_masked": "browser-…-token", + "webhooks": [], + "platforms": ["youtube"], + "authors": ["浏览器夹具频道"], + }, + }, + ) + return + if path == "/api/audit/history": + if not self.authorized(): + self.send_json(401, {"code": 401, "message": "unauthorized"}) + return + with sqlite3.connect(self.db_path) as db: + rows = db.execute( + "SELECT view_token, title, request_time, status, video_url FROM history " + "ORDER BY request_time DESC" + ).fetchall() + items = [ + { + "view_token": row[0], + "title": row[1], + "request_time": row[2], + "status": row[3], + "video_url": row[4], + "author": "浏览器夹具频道", + "platform": "youtube", + "wechat_webhook": None, + } + for row in rows + ] + self.send_json( + 200, + { + "code": 200, + "data": { + "items": items, + "total": len(items), + "api_key_masked": "browser-…-token", + }, + }, + ) + return + if path == "/api/audit/summary": + self.send_json(200, {"code": 200, "data": {"summary": "夹具摘要"}}) + return + if path == "/robots.txt": + self.send_bytes(200, b"User-agent: *\n", "text/plain; charset=utf-8") + return + if path == "/sitemap.xml": + self.send_bytes(200, b"", "application/xml") + return + if path == "/favicon.ico": + self.send_bytes(204, b"", "image/x-icon") + return + self.send_bytes(404, b"not found", "text/plain; charset=utf-8") + + def do_POST(self) -> None: + path = urlsplit(self.path).path + if path == "/__e2e__/reset": + with sqlite3.connect(self.db_path) as db: + seed_database(db) + self.send_json(200, {"ok": True}) + return + if path == "/api/transcribe": + if not self.authorized(): + self.send_json(401, {"code": 401, "message": "unauthorized"}) + return + payload = self.read_json() + with sqlite3.connect(self.db_path) as db: + db.execute("INSERT INTO submissions VALUES (?)", (json.dumps(payload),)) + self.send_json( + 200, + { + "code": 202, + "data": {"task_id": "browser-fixture-task", "view_token": VIEW_TOKEN}, + }, + ) + return + self.send_bytes(404, b"not found", "text/plain; charset=utf-8") + + def serve_static(self, path: Path) -> None: + content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream" + if content_type.startswith("text/") or content_type in ("application/javascript", "application/json"): + content_type += "; charset=utf-8" + self.send_bytes(200, path.read_bytes(), content_type) + + def serve_view(self, status: str) -> None: + template_name = { + "processing": "processing.html", + "failed": "error.html", + "file_cleaned": "cleaned.html", + }.get(status, "transcript.html") + context: dict[str, object] = { + "request": None, + "view_token": VIEW_TOKEN, + "title": "浏览器回归示例", + "platform": "YouTube", + "url": "https://www.youtube.com/watch?v=fixture", + "status": status, + "created_at": "2026-10-08T12:00:00", + "summary_html": '

这是原页面模板显示的样例总结。

', + "summary_state": "generated", + "calibrated_html": f"

{FIXTURE_TRANSCRIPT}

", + "calibrated_text": FIXTURE_TRANSCRIPT, + "stats": {"original_length": len(FIXTURE_TRANSCRIPT), "calibrated_length": len(FIXTURE_TRANSCRIPT), "summary_length": 13}, + "deep_read_prompt_presets": [], + "page_title": "浏览器回归示例", + "error_message": "夹具失败状态", + "message": "夹具失败状态", + } + html = TEMPLATES.get_template(template_name).render(**context) + self.send_bytes(200, html.encode("utf-8"), "text/html; charset=utf-8") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--port", required=True, type=int) + args = parser.parse_args() + with tempfile.TemporaryDirectory(prefix="vta-browser-") as directory: + db_path = Path(directory) / "fixtures.sqlite3" + initialize_database(db_path) + server = ThreadingHTTPServer(("127.0.0.1", args.port), BrowserFixtureHandler) + server.daemon_threads = True + server.db_path = db_path # type: ignore[attr-defined] + server.serve_forever() + + +if __name__ == "__main__": + main() From 0054cee8565c18172056da748ec83b48358f7081 Mon Sep 17 00:00:00 2001 From: zj1123581321 Date: Thu, 8 Oct 2026 18:09:41 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E6=B5=8F=E8=A7=88?= =?UTF-8?q?=E5=99=A8=E5=9B=9E=E5=BD=92=E9=98=BB=E6=96=AD=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Executor: codex-pi Agent-Model: gpt-6-luna Agent-Effort: xhigh Dispatch-Id: dlg-20261008-095055-2ba48d Task-Id: VideoTranscriptAPI-20261008-12 --- .github/workflows/gate.yml | 2 +- .github/workflows/ui-browser.yml | 43 +++++++++++++++++++ AGENTS.md | 2 + .../progress/U-progress.md | 6 ++- docs/testing/local-upload-browser.md | 25 +++++++++++ e2e/playwright.config.ts | 1 + e2e/tests/pages.spec.ts | 13 +++++- 7 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/ui-browser.yml create mode 100644 docs/testing/local-upload-browser.md diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml index 317675d4..50b6f448 100644 --- a/.github/workflows/gate.yml +++ b/.github/workflows/gate.yml @@ -22,7 +22,7 @@ jobs: with: tier: personal runner: self - has_ui: false + has_ui: true design_doc: "" secrets: FEISHU_CI_WEBHOOK: ${{ secrets.FEISHU_CI_WEBHOOK }} diff --git a/.github/workflows/ui-browser.yml b/.github/workflows/ui-browser.yml new file mode 100644 index 00000000..eff19fa3 --- /dev/null +++ b/.github/workflows/ui-browser.yml @@ -0,0 +1,43 @@ +name: Browser regression + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft] + workflow_dispatch: + +permissions: + contents: read + +jobs: + chromium-regression: + runs-on: [self-hosted, ci] + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + + - uses: astral-sh/setup-uv@v8.1.0 + with: + enable-cache: true + - name: Install Python dependencies + run: uv sync --frozen + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: | + package-lock.json + e2e/package-lock.json + - name: Install root Node dependencies + run: npm ci + - name: Install browser-test dependencies + working-directory: e2e + run: npm ci + - name: Install canonical Chromium browser + working-directory: e2e + run: npx playwright install chromium + + - name: Run existing Vitest web contracts + run: npm run test:web + - name: Run blocking Chromium regression + run: npm run test:browser diff --git a/AGENTS.md b/AGENTS.md index 05eb750b..f01a512f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,6 @@ 过程中请使用中文和我沟通,但 console 里请优先使用英文。 + +ui-evidence-exempt: 2026-10-08 PWRS_REPORTER_TOKEN 缺失且自建 runner 的 PWRS 访问与实际发布链路尚未验证 (#199) # Repository Guidelines ## Project Structure & Module Organization diff --git a/docs/sessions/261008-local-upload/progress/U-progress.md b/docs/sessions/261008-local-upload/progress/U-progress.md index 7db3f287..e4e011b2 100644 --- a/docs/sessions/261008-local-upload/progress/U-progress.md +++ b/docs/sessions/261008-local-upload/progress/U-progress.md @@ -4,4 +4,8 @@ 已接入 `npm run test:browser`,用临时 SQLite 种子和动态端口驱动 Chromium 对提交、历史、公开阅读/导出及其他实际页面做行为/烟雾检查;9 条用例本地全绿。真实页面缺少 `sw.js` 时浏览器报告 404 console error,已补 test-server 路由并保留 console/request 失败观察;无产品代码改动。 -决策:阻断层独立于 Vitest 和录屏层;`retries=0`、单 spec、`fullyParallel=false`,各用例 beforeEach 重新种子。下一步:提交/推送此入口并开 draft PR,然后接入阻断 CI、登记 has_ui caller 与录屏豁免文档。 +决策:阻断层独立于 Vitest 和录屏层;`retries=0`、单 spec、`fullyParallel=false`,各用例 beforeEach 重新种子。下一步:入口 commit `665e84eb` 已推送并开 draft PR #200;继续接入阻断 CI、登记 has_ui caller 与录屏豁免文档。 + +## 里程碑 2:CI、覆盖文档与录屏例外(implementing) + +新增 PR 阻断 workflow,保留现有 gate caller 且改 `has_ui: true`;实际查询 `zlxlabs/gate` 的 `v2` workflow_call schema 确认布尔输入存在。PWRS secret 名称检查结果为 absent,因此未创建录屏 workflow,按 90 天约定写入豁免并开本仓 issue #199;上游台账 #1393 仍 open。Chromium 9/9、Vitest 170/170 通过;将控件选择器临时改为不存在值会得到 `expect(locator).toBeVisible()` AssertionError,将 test-only URL 改为不存在路径会由 HTTP 404/console 404 观察器触发 `toEqual` AssertionError,恢复后重新全绿。唯一下一步:提交并推送 CI 接线单元,核对 PR 的真实 run/job conclusion 与浏览器 workflow 结果。 diff --git a/docs/testing/local-upload-browser.md b/docs/testing/local-upload-browser.md new file mode 100644 index 00000000..09e74081 --- /dev/null +++ b/docs/testing/local-upload-browser.md @@ -0,0 +1,25 @@ +# 本仓真实浏览器回归 + +阻断入口:`npm run test:browser`。使用本地 Chromium、`e2e/` 锁定依赖、动态回环端口和测试 server 每次新建的临时 SQLite 数据库;每个 case 调用 `POST /__e2e__/reset` 独立重种。测试 server 仅在 `tests/browser_server.py`,不接生产配置、数据库或服务,不启动 ASR、LLM、通知服务。提交接口的 HTTP 响应为受控 fixture,因此本套测试验证现有页面与交互,不代表后台上传实现通过。 + +## 页面与覆盖 + +| 实际页面 / 路由 | 级别 | 阻断 spec 与可见断言 | +|---|---|---| +| `/`(`views.py` 的生产 HTML 常量) | 次要 smoke | `home page smoke`:标题及提交/历史链接 | +| `/add_task_by_web`(`src/web/static/index.html`) | 主路径 | `submission page sends...`:填令牌与 URL、点击提交、核对浏览器实际 POST payload 及成功状态;另有表单 smoke | +| `/static/history.html`(真实静态页面) | 主路径 | `history query renders...`:真实查询渲染 SQLite 种子、打开阅读页、已读状态变化;另有控件 smoke | +| `/view/{view_token}` 成功阅读(`src/web/templates/transcript.html`) | 主路径 | `public reading page...`:正文可见、展开导出控件、点击 TXT/HTML 导出并核对结果 | +| `/view/{view_token}` 处理中(`processing.html`) | 次要 smoke | `processing view smoke`:处理中状态与刷新控件 | +| `/view/{view_token}` 失败(`error.html`) | 次要 smoke | `failed view smoke`:错误标题和消息 | +| `/view/{view_token}` 文件清理(`cleaned.html`) | 次要 smoke | `cleaned view smoke`:清理状态和恢复指引 | + +`/view/{token}?raw=...` 与 `?page=...` 是阅读页导出目的地,主路径测试通过真实链接点击断言。`/robots.txt`、`/sitemap.xml`、`/sw.js`、manifest 与 API 是资源/接口,不是额外页面;profile 路由是 API JSON,不是 UI。浏览器测试收集 console error、page error、request failed 及 HTTP 4xx/5xx,任一均使 case 失败。 + +## 运行及 CI + +首次运行:`uv sync`、根目录 `npm ci`、`npm --prefix e2e ci`,并安装 Chromium:`cd e2e && npx playwright install chromium`。之后 `npm run test:browser`;保留的 Vitest 契约层单独以 `npm run test:web` 运行。缺 Chromium、服务无法启动或断言不符均非零;无 skip/retry。单一 spec、`fullyParallel: false`、`workers: 1` 避免共享 fixture 并发污染。 + +`.github/workflows/ui-browser.yml` 对所有 PR `opened`、`synchronize`、`reopened`、`ready_for_review`、`converted_to_draft` 事件在 `[self-hosted, ci]` 执行 Chromium 与 Vitest;也可 `workflow_dispatch`。本仓没有 push workflow。`.github/workflows/gate.yml` 的 caller 已设置 `has_ui: true`;引用的 `zlxlabs/gate/.github/workflows/gate-v2.yml@v2` 在实际 tag 的 `workflow_call.inputs` 声明 `has_ui: boolean`(tag `v2` 当前解析为 commit `805856b9c6a501145521e8cae06000dc669af8c6`,已通过 GitHub API 读取实际 workflow_call schema 确认)。gate-hub 台账仍待 #1393,不能据本仓 caller 推断登记已生效。 + +录屏证据不在本阻断 workflow。因无 `PWRS_REPORTER_TOKEN` 且 runner 发布通路未经验证,未接假 workflow;`AGENTS.md` 中有效期 90 天的 `ui-evidence-exempt:` 对应本仓接入待办 #199。该豁免只针对录屏,不豁免真实浏览器行为门禁。 diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 86242172..5c998344 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -7,6 +7,7 @@ const baseURL = `http://127.0.0.1:${port}`; export default defineConfig({ testDir: './tests', testMatch: '**/*.spec.ts', + outputDir: './node_modules/.cache/playwright-test-results', fullyParallel: false, forbidOnly: true, retries: 0, diff --git a/e2e/tests/pages.spec.ts b/e2e/tests/pages.spec.ts index 63f1b9ec..5d73d34a 100644 --- a/e2e/tests/pages.spec.ts +++ b/e2e/tests/pages.spec.ts @@ -26,11 +26,14 @@ function observeBrowser(page: Page): void { test.beforeEach(async ({ page, request }) => { if (!baseURL) throw new Error('VTA_BROWSER_PORT must be set by the browser test runner'); observeBrowser(page); + page.context().on('page', observeBrowser); await resetBackend(request, baseURL); }); test.afterEach(async ({ page }) => { - expect(browserErrors.get(page) ?? []).toEqual([]); + for (const observedPage of page.context().pages()) { + expect(browserErrors.get(observedPage) ?? []).toEqual([]); + } }); test('submission page sends selected URL and shows the accepted task', async ({ page }) => { @@ -79,6 +82,14 @@ test('public reading page displays transcript and opens a working text export', await expect(rawExport).toHaveAttribute('href', '/view/browser-fixture-view?raw=calibrated'); await rawExport.click(); await expect(page.locator('body')).toContainText('隔离浏览器夹具中的正文,验证公开阅读与导出。'); + + await page.goto('/view/browser-fixture-view'); + await page.locator('.export-links-details summary').click(); + const htmlExport = page.getByTitle('导出校对文本(HTML 页面)'); + await expect(htmlExport).toHaveAttribute('href', '/view/browser-fixture-view?page=calibrated'); + await htmlExport.click(); + await expect(page.getByRole('heading', { name: '浏览器回归示例' })).toBeVisible(); + await expect(page.locator('body')).toContainText('隔离浏览器夹具中的正文,验证公开阅读与导出。'); }); test('home page smoke: production landing content and entry links render', async ({ page }) => { From 43f4db771dd850062ccedc2d05ccf394b25ca9d3 Mon Sep 17 00:00:00 2001 From: zj1123581321 Date: Thu, 8 Oct 2026 18:19:33 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E8=AE=B0=E5=BD=95=E6=B5=8F=E8=A7=88?= =?UTF-8?q?=E5=99=A8=20CI=20=E9=AA=8C=E8=AF=81=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Executor: codex-pi Agent-Model: gpt-6-luna Agent-Effort: xhigh Dispatch-Id: dlg-20261008-095055-2ba48d Task-Id: VideoTranscriptAPI-20261008-12 --- docs/sessions/261008-local-upload/progress/U-progress.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sessions/261008-local-upload/progress/U-progress.md b/docs/sessions/261008-local-upload/progress/U-progress.md index e4e011b2..05a5fcdd 100644 --- a/docs/sessions/261008-local-upload/progress/U-progress.md +++ b/docs/sessions/261008-local-upload/progress/U-progress.md @@ -8,4 +8,4 @@ ## 里程碑 2:CI、覆盖文档与录屏例外(implementing) -新增 PR 阻断 workflow,保留现有 gate caller 且改 `has_ui: true`;实际查询 `zlxlabs/gate` 的 `v2` workflow_call schema 确认布尔输入存在。PWRS secret 名称检查结果为 absent,因此未创建录屏 workflow,按 90 天约定写入豁免并开本仓 issue #199;上游台账 #1393 仍 open。Chromium 9/9、Vitest 170/170 通过;将控件选择器临时改为不存在值会得到 `expect(locator).toBeVisible()` AssertionError,将 test-only URL 改为不存在路径会由 HTTP 404/console 404 观察器触发 `toEqual` AssertionError,恢复后重新全绿。唯一下一步:提交并推送 CI 接线单元,核对 PR 的真实 run/job conclusion 与浏览器 workflow 结果。 +新增 PR 阻断 workflow,保留现有 gate caller 且改 `has_ui: true`;实际查询 `zlxlabs/gate` 的 `v2` workflow_call schema 确认布尔输入存在。PWRS secret 名称检查结果为 absent,因此未创建录屏 workflow,按 90 天约定写入豁免并开本仓 issue #199;上游台账 #1393 仍 open。Chromium 9/9、Vitest 170/170 通过;将控件选择器临时改为不存在值会得到 `expect(locator).toBeVisible()` AssertionError,将 test-only URL 改为不存在路径会由 HTTP 404/console 404 观察器触发 `toEqual` AssertionError,恢复后重新全绿。PR #200 的真实 Chromium workflow `chromium-regression` 已 SUCCESS,gate quality 也 SUCCESS;PR 仍为 draft,`gate / primary` 按预期 SKIPPED,不能声称模型主审通过。唯一下一步:由 lead 做独立 review,并在其授权下标 ready 后等待完整 gate;执行器不自行 ready/merge。