From 869efc4a04883871f75f750784f39ea355f78f14 Mon Sep 17 00:00:00 2001 From: CoderLambert Date: Tue, 15 Sep 2026 12:26:41 +0800 Subject: [PATCH] test(browser): add protected authenticated smoke flow --- .../workflows/authenticated-browser-smoke.yml | 71 +++ .gitignore | 1 + README.md | 4 + docs/testing/AUTHENTICATED-PROVIDER-SMOKE.md | 61 +++ package.json | 2 +- tests/authenticated-browser-smoke.cjs | 448 ++++++++++++++++++ 6 files changed, 586 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/authenticated-browser-smoke.yml create mode 100644 docs/testing/AUTHENTICATED-PROVIDER-SMOKE.md create mode 100644 tests/authenticated-browser-smoke.cjs diff --git a/.github/workflows/authenticated-browser-smoke.yml b/.github/workflows/authenticated-browser-smoke.yml new file mode 100644 index 0000000..9a18a63 --- /dev/null +++ b/.github/workflows/authenticated-browser-smoke.yml @@ -0,0 +1,71 @@ +name: Authenticated Browser Smoke + +on: + workflow_dispatch: + inputs: + providers: + description: "Comma-separated Provider IDs with configured test-account Secrets" + required: true + default: "chatgpt,grok" + type: string + +permissions: + contents: read + +concurrency: + group: authenticated-browser-smoke-main + cancel-in-progress: false + +jobs: + authenticated-browser-smoke: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: + name: authenticated-provider-smoke + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install Playwright + run: npm install --no-save --no-package-lock playwright@1.55.0 + + - name: Install Chromium + run: npx playwright install --with-deps chromium + + - name: Check authenticated smoke syntax + run: node --check tests/authenticated-browser-smoke.cjs + + - name: Run authenticated smoke + env: + AI_PARALLEL_SMOKE_PROVIDER_IDS: ${{ inputs.providers }} + AI_PARALLEL_SMOKE_CHATGPT_USERNAME: ${{ secrets.AI_PARALLEL_SMOKE_CHATGPT_USERNAME }} + AI_PARALLEL_SMOKE_CHATGPT_PASSWORD: ${{ secrets.AI_PARALLEL_SMOKE_CHATGPT_PASSWORD }} + AI_PARALLEL_SMOKE_DEEPSEEK_USERNAME: ${{ secrets.AI_PARALLEL_SMOKE_DEEPSEEK_USERNAME }} + AI_PARALLEL_SMOKE_DEEPSEEK_PASSWORD: ${{ secrets.AI_PARALLEL_SMOKE_DEEPSEEK_PASSWORD }} + AI_PARALLEL_SMOKE_ZHIPU_USERNAME: ${{ secrets.AI_PARALLEL_SMOKE_ZHIPU_USERNAME }} + AI_PARALLEL_SMOKE_ZHIPU_PASSWORD: ${{ secrets.AI_PARALLEL_SMOKE_ZHIPU_PASSWORD }} + AI_PARALLEL_SMOKE_QWEN_USERNAME: ${{ secrets.AI_PARALLEL_SMOKE_QWEN_USERNAME }} + AI_PARALLEL_SMOKE_QWEN_PASSWORD: ${{ secrets.AI_PARALLEL_SMOKE_QWEN_PASSWORD }} + AI_PARALLEL_SMOKE_KIMI_USERNAME: ${{ secrets.AI_PARALLEL_SMOKE_KIMI_USERNAME }} + AI_PARALLEL_SMOKE_KIMI_PASSWORD: ${{ secrets.AI_PARALLEL_SMOKE_KIMI_PASSWORD }} + AI_PARALLEL_SMOKE_CLAUDE_USERNAME: ${{ secrets.AI_PARALLEL_SMOKE_CLAUDE_USERNAME }} + AI_PARALLEL_SMOKE_CLAUDE_PASSWORD: ${{ secrets.AI_PARALLEL_SMOKE_CLAUDE_PASSWORD }} + AI_PARALLEL_SMOKE_GEMINI_USERNAME: ${{ secrets.AI_PARALLEL_SMOKE_GEMINI_USERNAME }} + AI_PARALLEL_SMOKE_GEMINI_PASSWORD: ${{ secrets.AI_PARALLEL_SMOKE_GEMINI_PASSWORD }} + AI_PARALLEL_SMOKE_GROK_USERNAME: ${{ secrets.AI_PARALLEL_SMOKE_GROK_USERNAME }} + AI_PARALLEL_SMOKE_GROK_PASSWORD: ${{ secrets.AI_PARALLEL_SMOKE_GROK_PASSWORD }} + run: xvfb-run --auto-servernum node tests/authenticated-browser-smoke.cjs + + - name: Upload sanitized diagnostics + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: authenticated-browser-smoke-diagnostics + path: test-results/authenticated-browser-smoke/diagnostics.json + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index e92451a..e9991d1 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ coverage/ # local scratch/artifacts *.zip +test-results/ diff --git a/README.md b/README.md index 0b09d10..7cf2384 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,10 @@ npm run check npm test ``` +需要真实 Provider 登录态时,使用独立的手动、受保护 authenticated smoke +流程;默认 Browser Smoke 仍不读取凭据。配置和保留边界见 +[Authenticated Provider Smoke](docs/testing/AUTHENTICATED-PROVIDER-SMOKE.md)。 + 从源码加载时,在扩展管理页选择 `apps/browser-extension`。 生成 Release 包: diff --git a/docs/testing/AUTHENTICATED-PROVIDER-SMOKE.md b/docs/testing/AUTHENTICATED-PROVIDER-SMOKE.md new file mode 100644 index 0000000..b85291e --- /dev/null +++ b/docs/testing/AUTHENTICATED-PROVIDER-SMOKE.md @@ -0,0 +1,61 @@ +# Authenticated Provider Smoke + +The authenticated smoke flow is an opt-in validation path for dedicated +Provider test accounts. It is separate from the default credential-free +Browser Smoke workflow and runs only from `main` through a manually dispatched +GitHub Actions job. + +## Protected setup + +Create a protected GitHub Environment named `authenticated-provider-smoke` and +require reviewer approval before jobs can start. Add username and password +Secrets for only the dedicated non-personal accounts that will be tested: + +```text +AI_PARALLEL_SMOKE_CHATGPT_USERNAME +AI_PARALLEL_SMOKE_CHATGPT_PASSWORD +AI_PARALLEL_SMOKE_DEEPSEEK_USERNAME +AI_PARALLEL_SMOKE_DEEPSEEK_PASSWORD +AI_PARALLEL_SMOKE_ZHIPU_USERNAME +AI_PARALLEL_SMOKE_ZHIPU_PASSWORD +AI_PARALLEL_SMOKE_QWEN_USERNAME +AI_PARALLEL_SMOKE_QWEN_PASSWORD +AI_PARALLEL_SMOKE_KIMI_USERNAME +AI_PARALLEL_SMOKE_KIMI_PASSWORD +AI_PARALLEL_SMOKE_CLAUDE_USERNAME +AI_PARALLEL_SMOKE_CLAUDE_PASSWORD +AI_PARALLEL_SMOKE_GEMINI_USERNAME +AI_PARALLEL_SMOKE_GEMINI_PASSWORD +AI_PARALLEL_SMOKE_GROK_USERNAME +AI_PARALLEL_SMOKE_GROK_PASSWORD +``` + +The workflow maps these Secrets only to the smoke process. Do not place values +in workflow inputs, command arguments, URLs, repository files, or local shell +history. The selected Provider list is an explicit comma-separated input; the +default is `chatgpt,grok`. + +## Run and validation + +Use **Actions → Authenticated Browser Smoke → Run workflow** on the `main` +branch. The test launches a fresh temporary persistent Chrome Profile, logs in +to each selected Provider, opens the AI Parallel workspace, selects only those +Providers, sends a fixed health-check Prompt, and collects each visible answer. +Grok additionally must have an allowlisted top-level `grok.com` tab and an +observed `grok.com`/`x.ai` WebSocket connection. + +Provider authentication selectors are best-effort test configuration. Accounts +requiring interactive MFA, CAPTCHA, or an unsupported OAuth-only flow must not +be used for this automated path until an explicitly reviewed test strategy is +available. + +## Retention boundary + +The smoke test never starts Playwright tracing, takes screenshots, writes +`storageState`, or uploads the browser Profile. It removes the temporary Profile +after the run. On failure it writes only provider, stage, status, bounded error +code, and timestamps to `diagnostics.json`; Prompt, response, Cookie, password, +authentication URL, page HTML, and WebSocket URL contents are excluded. + +The extension manifest, permissions, runtime message allowlists, and the +credential-free Browser Smoke workflow are unchanged by this validation path. diff --git a/package.json b/package.json index f886f55..6b842fa 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "version": "2.1.4", "description": "Monorepo for parallel AI workflows across browser, web and desktop clients.", "scripts": { - "check": "node --check apps/browser-extension/shared/provider-catalog.js && node --check apps/browser-extension/shared/provider-adapter-contract.js && node --check apps/browser-extension/shared/provider-task-runtime.js && node --check apps/browser-extension/shared/agent-execution-controller.js && node --check apps/browser-extension/service-worker.js && node --check apps/browser-extension/popup.js && node --check apps/browser-extension/content/providers/core.js && node --check apps/browser-extension/content/providers/chatgpt.js && node --check apps/browser-extension/content/providers/deepseek.js && node --check apps/browser-extension/content/providers/qwen.js && node --check apps/browser-extension/content/providers/kimi.js && node --check apps/browser-extension/content/providers/zhipu.js && node --check apps/browser-extension/content/providers/claude.js && node --check apps/browser-extension/content/providers/gemini.js && node --check apps/browser-extension/content/providers/grok.js && node --check apps/browser-extension/content/frame-bridge.js && node --check apps/browser-extension/workspace/context-utils.js && node --check apps/browser-extension/workspace/workspace.js", + "check": "node --check apps/browser-extension/shared/provider-catalog.js && node --check apps/browser-extension/shared/provider-adapter-contract.js && node --check apps/browser-extension/shared/provider-task-runtime.js && node --check apps/browser-extension/shared/agent-execution-controller.js && node --check apps/browser-extension/service-worker.js && node --check apps/browser-extension/popup.js && node --check apps/browser-extension/content/providers/core.js && node --check apps/browser-extension/content/providers/chatgpt.js && node --check apps/browser-extension/content/providers/deepseek.js && node --check apps/browser-extension/content/providers/qwen.js && node --check apps/browser-extension/content/providers/kimi.js && node --check apps/browser-extension/content/providers/zhipu.js && node --check apps/browser-extension/content/providers/claude.js && node --check apps/browser-extension/content/providers/gemini.js && node --check apps/browser-extension/content/providers/grok.js && node --check apps/browser-extension/content/frame-bridge.js && node --check apps/browser-extension/workspace/context-utils.js && node --check apps/browser-extension/workspace/workspace.js && node --check tests/authenticated-browser-smoke.cjs", "test": "node --test tests/*.test.js", "package:extension": "bash scripts/package-extension.sh" } diff --git a/tests/authenticated-browser-smoke.cjs b/tests/authenticated-browser-smoke.cjs new file mode 100644 index 0000000..94ce70d --- /dev/null +++ b/tests/authenticated-browser-smoke.cjs @@ -0,0 +1,448 @@ +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { chromium } = require("playwright"); + +const extensionRoot = path.join(__dirname, "..", "apps", "browser-extension"); +const diagnosticsDir = path.join(process.cwd(), "test-results", "authenticated-browser-smoke"); +const diagnosticsPath = path.join(diagnosticsDir, "diagnostics.json"); +const DEFAULT_PROVIDER_IDS = ["chatgpt", "grok"]; +const MAX_PROVIDER_IDS = 8; +const AUTH_TIMEOUT_MS = 45000; +const RESPONSE_TIMEOUT_MS = 120000; +const SMOKE_PROMPT = "AI Parallel authenticated smoke: respond with a short health check."; +const GROK_HOSTS = new Set(["grok.com", "www.grok.com"]); + +const COMMON_LOGIN_SELECTORS = [ + "a[href*='/login']", + "a[href*='/sign-in']", + "button:has-text('Log in')", + "button:has-text('Sign in')", + "a:has-text('Log in')", + "a:has-text('Sign in')", + "button:has-text('登录')", + "a:has-text('登录')" +]; +const COMMON_USERNAME_SELECTORS = [ + "input[type='email']", + "input[name='email']", + "input[name='username']", + "input[autocomplete='username']", + "input[autocomplete='email']" +]; +const COMMON_PASSWORD_SELECTORS = [ + "input[type='password']", + "input[name='password']", + "input[autocomplete='current-password']" +]; +const COMMON_SUBMIT_SELECTORS = [ + "button[type='submit']", + "button:has-text('Continue')", + "button:has-text('Next')", + "button:has-text('Log in')", + "button:has-text('Sign in')", + "button:has-text('登录')", + "input[type='submit']" +]; + +const PROVIDERS = Object.freeze({ + chatgpt: { + mode: "iframe", + hosts: ["chatgpt.com", "chat.openai.com"], + url: "https://chatgpt.com/", + editorSelectors: [ + "#prompt-textarea", + "textarea[data-testid='prompt-textarea']", + "div[contenteditable='true'][data-testid='prompt-textarea']", + "form textarea", + "main textarea" + ], + responseSelectors: ["[data-message-author-role='assistant']"] + }, + deepseek: { + mode: "iframe", + hosts: ["chat.deepseek.com"], + url: "https://chat.deepseek.com/", + editorSelectors: ["textarea", "div[contenteditable='true']"], + responseSelectors: ["[class*='ds-markdown']", "[class*='message-content']"] + }, + zhipu: { + mode: "iframe", + hosts: ["chatglm.cn"], + url: "https://chatglm.cn/", + editorSelectors: ["textarea", "div[contenteditable='true']"], + responseSelectors: ["[class*='markdown']", "[class*='message-content']"] + }, + qwen: { + mode: "iframe", + hosts: ["chat.qwen.ai"], + url: "https://chat.qwen.ai/", + editorSelectors: ["textarea", "div[contenteditable='true']"], + responseSelectors: ["[class*='markdown']", "[class*='message-content']"] + }, + kimi: { + mode: "iframe", + hosts: ["www.kimi.com", "kimi.com"], + url: "https://www.kimi.com/", + editorSelectors: ["textarea", "div[contenteditable='true']"], + responseSelectors: ["[class*='markdown']", "[class*='message-content']"] + }, + claude: { + mode: "iframe", + hosts: ["claude.ai"], + url: "https://claude.ai/new", + editorSelectors: ["div.ProseMirror[contenteditable='true']", "div[contenteditable='true']", "textarea"], + responseSelectors: ["[data-testid='assistant-message']", "[class*='font-claude-response']"] + }, + gemini: { + mode: "iframe", + hosts: ["gemini.google.com"], + url: "https://gemini.google.com/app", + editorSelectors: ["rich-textarea div[contenteditable='true']", "div[contenteditable='true']", "textarea"], + responseSelectors: ["message-content", ".markdown-main-panel", "[class*='markdown']"] + }, + grok: { + mode: "tab", + hosts: ["grok.com"], + url: "https://grok.com/", + editorSelectors: [ + "textarea[aria-label='Ask Grok anything']", + "textarea[placeholder*='Ask']", + "textarea", + "div.ProseMirror[contenteditable='true']", + "div[contenteditable='true'][role='textbox']", + "div[contenteditable='true']" + ], + responseSelectors: [ + "[data-testid='assistant-message']", + "[data-message-author-role='assistant']", + "[class*='markdown']" + ] + } +}); + +class SmokeFailure extends Error { + constructor(code) { + super(code); + this.name = "SmokeFailure"; + this.code = code; + } +} + +function parseProviderIds(value = process.env.AI_PARALLEL_SMOKE_PROVIDER_IDS) { + const rawIds = typeof value === "string" && value.trim() + ? value.split(",").map((id) => id.trim().toLowerCase()).filter(Boolean) + : DEFAULT_PROVIDER_IDS; + const ids = [...new Set(rawIds)]; + if (!ids.length || ids.length > MAX_PROVIDER_IDS || ids.some((id) => !PROVIDERS[id])) { + throw new SmokeFailure("INVALID_PROVIDER_ALLOWLIST"); + } + return ids; +} + +function credentialsFor(providerId) { + const prefix = `AI_PARALLEL_SMOKE_${providerId.toUpperCase()}`; + const username = process.env[`${prefix}_USERNAME`]; + const password = process.env[`${prefix}_PASSWORD`]; + if (typeof username !== "string" || !username.trim() || typeof password !== "string" || !password) { + throw new SmokeFailure(`MISSING_${providerId.toUpperCase()}_CREDENTIALS`); + } + return { username, password }; +} + +function writeDiagnostics(entries) { + fs.mkdirSync(diagnosticsDir, { recursive: true, mode: 0o700 }); + fs.writeFileSync(diagnosticsPath, JSON.stringify({ + schemaVersion: 1, + generatedAt: new Date().toISOString(), + entries + }, null, 2), { encoding: "utf8", mode: 0o600 }); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function positiveInteger(value, fallback, maximum) { + const number = Number(value); + if (!Number.isFinite(number) || number <= 0) return fallback; + return Math.min(Math.max(1, Math.floor(number)), maximum); +} + +async function waitForVisible(pageOrFrame, selectors, timeoutMs = 10000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + for (const selector of selectors) { + try { + const locator = pageOrFrame.locator(selector).first(); + if (await locator.isVisible()) return locator; + } catch { + // A provider may replace the document while the login flow is changing pages. + } + } + await sleep(250); + } + return null; +} + +async function clickVisible(page, selectors, timeoutMs = 10000) { + const locator = await waitForVisible(page, selectors, timeoutMs); + if (!locator) return false; + await locator.click(); + return true; +} + +async function pageHasEditor(page, provider) { + return Boolean(await waitForVisible(page, provider.editorSelectors, 5000)); +} + +async function authenticateProvider(page, provider, credentials) { + try { + await page.goto(provider.url, { waitUntil: "domcontentloaded", timeout: AUTH_TIMEOUT_MS }); + if (await pageHasEditor(page, provider)) return; + + let usernameInput = await waitForVisible(page, COMMON_USERNAME_SELECTORS, 5000); + let passwordInput = await waitForVisible(page, COMMON_PASSWORD_SELECTORS, 1000); + if (!usernameInput && !passwordInput) { + const clicked = await clickVisible(page, COMMON_LOGIN_SELECTORS, 10000); + if (!clicked) throw new SmokeFailure("AUTH_FORM_NOT_FOUND"); + usernameInput = await waitForVisible(page, COMMON_USERNAME_SELECTORS, 20000); + passwordInput = await waitForVisible(page, COMMON_PASSWORD_SELECTORS, 3000); + } + if (!usernameInput) throw new SmokeFailure("AUTH_USERNAME_FIELD_NOT_FOUND"); + + await usernameInput.fill(credentials.username); + if (!passwordInput) { + await clickVisible(page, COMMON_SUBMIT_SELECTORS, 5000); + passwordInput = await waitForVisible(page, COMMON_PASSWORD_SELECTORS, 20000); + } + if (!passwordInput) throw new SmokeFailure("AUTH_PASSWORD_FIELD_NOT_FOUND"); + + await passwordInput.fill(credentials.password); + const submitted = await clickVisible(page, COMMON_SUBMIT_SELECTORS, 5000); + if (!submitted) await passwordInput.press("Enter"); + await page.waitForLoadState("domcontentloaded", { timeout: AUTH_TIMEOUT_MS }).catch(() => {}); + if (!await pageHasEditor(page, provider)) throw new SmokeFailure("AUTH_INCOMPLETE"); + } catch (error) { + if (error instanceof SmokeFailure) throw error; + throw new SmokeFailure("AUTH_PROVIDER_ERROR"); + } finally { + credentials.username = ""; + credentials.password = ""; + } +} + +function hostMatches(value, hosts) { + try { + return hosts.includes(new URL(value).hostname); + } catch { + return false; + } +} + +async function waitForProviderResponse(context, provider, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + for (const page of context.pages()) { + for (const frame of page.frames()) { + if (!hostMatches(frame.url(), provider.hosts)) continue; + for (const selector of provider.responseSelectors) { + try { + const locator = frame.locator(selector).last(); + if (!await locator.isVisible()) continue; + if ((await locator.innerText()).trim()) return true; + } catch { + // The response DOM can be replaced while a provider is streaming. + } + } + } + } + await sleep(500); + } + return false; +} + +async function configureWorkspace(page, providerIds) { + await page.waitForFunction(() => document.querySelectorAll("#providerBar .provider-chip").length === 8, { + timeout: 20000 + }); + const selected = new Set(providerIds); + const providerOrder = Object.keys(PROVIDERS); + for (let index = 0; index < providerOrder.length; index += 1) { + const button = page.locator("#providerBar .provider-chip").nth(index); + const expected = selected.has(providerOrder[index]); + if ((await button.getAttribute("data-selected")) === String(expected)) continue; + await button.click(); + await page.waitForFunction(({ index: buttonIndex, value }) => ( + document.querySelectorAll("#providerBar .provider-chip")[buttonIndex]?.dataset.selected === String(value) + ), { index, value: expected }, { timeout: 5000 }); + } + await page.waitForFunction((ids) => ids.every((id) => ( + document.querySelector(`[data-provider-id="${id}"]`) + )), providerIds, { timeout: 20000 }); +} + +async function waitForWorkspaceProviders(page, providerIds) { + await page.waitForFunction((ids) => ids.every((id) => { + const panel = document.querySelector(`[data-provider-id="${id}"]`); + return panel?.dataset.ready === "true"; + }), providerIds, { timeout: 60000 }); +} + +async function sendAndCollect(page, context, providerIds) { + await page.locator("#promptInput").fill(SMOKE_PROMPT); + await page.locator("#sendBtn").click(); + await page.waitForFunction(() => document.querySelector("#dispatchStatus")?.textContent.includes("已发送"), { + timeout: 60000 + }); + const dispatchStatus = await page.locator("#dispatchStatus").innerText(); + if (dispatchStatus.includes("需要处理")) throw new SmokeFailure("PROMPT_SEND_FAILED"); + + const responseTimeout = positiveInteger( + process.env.AI_PARALLEL_SMOKE_RESPONSE_TIMEOUT_MS, + RESPONSE_TIMEOUT_MS, + RESPONSE_TIMEOUT_MS + ); + for (const providerId of providerIds) { + const provider = PROVIDERS[providerId]; + if (!await waitForProviderResponse(context, provider, responseTimeout)) { + throw new SmokeFailure(`RESPONSE_NOT_VISIBLE_${providerId.toUpperCase()}`); + } + } + + await page.locator("#compareBtn").click(); + await page.waitForFunction((count) => ( + document.querySelectorAll("#responseList .response-card").length === count + ), providerIds.length, { timeout: 30000 }); + const contentCount = await page.locator("#responseList .response-card-content").count(); + if (contentCount !== providerIds.length) throw new SmokeFailure("RESPONSE_COLLECTION_INCOMPLETE"); +} + +async function run() { + const diagnostics = []; + let context; + let userDataDir; + try { + const providerIds = parseProviderIds(); + writeDiagnostics(diagnostics); + const recordStage = async (providerId, stage, action) => { + const startedAt = new Date().toISOString(); + try { + const result = await action(); + diagnostics.push({ providerId, stage, status: "passed", code: null, startedAt, finishedAt: new Date().toISOString() }); + writeDiagnostics(diagnostics); + console.log(`[authenticated-smoke] ${providerId} ${stage} passed`); + return result; + } catch (error) { + const code = error instanceof SmokeFailure ? error.code : "UNEXPECTED_FAILURE"; + diagnostics.push({ providerId, stage, status: "failed", code, startedAt, finishedAt: new Date().toISOString() }); + writeDiagnostics(diagnostics); + console.log(`[authenticated-smoke] ${providerId} ${stage} failed (${code})`); + throw error instanceof SmokeFailure ? error : new SmokeFailure(code); + } + }; + + userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "ai-parallel-auth-smoke-")); + context = await chromium.launchPersistentContext(userDataDir, { + headless: false, + args: [ + `--disable-extensions-except=${extensionRoot}`, + `--load-extension=${extensionRoot}` + ] + }); + + let grokWebSocketCount = 0; + const observePage = (page) => { + page.on("websocket", (webSocket) => { + try { + const hostname = new URL(webSocket.url()).hostname; + if (hostname === "grok.com" || hostname.endsWith(".grok.com") || hostname.endsWith(".x.ai")) { + grokWebSocketCount += 1; + } + } catch { + // WebSocket URLs are never included in diagnostics. + } + }); + }; + context.pages().forEach(observePage); + context.on("page", observePage); + + let serviceWorker = context.serviceWorkers()[0]; + if (!serviceWorker) serviceWorker = await context.waitForEvent("serviceworker", { timeout: 20000 }); + const extensionId = new URL(serviceWorker.url()).hostname; + + for (const providerId of providerIds) { + const provider = PROVIDERS[providerId]; + let credentials; + try { + credentials = await recordStage(providerId, "credentials", () => credentialsFor(providerId)); + const providerPage = await context.newPage(); + try { + await recordStage(providerId, "authentication", () => authenticateProvider(providerPage, provider, credentials)); + } finally { + if (providerId !== "grok") await providerPage.close().catch(() => {}); + } + } finally { + if (credentials) { + credentials.username = ""; + credentials.password = ""; + } + } + } + + const workspace = await context.newPage(); + await recordStage("workspace", "extension-load", async () => { + await workspace.goto(`chrome-extension://${extensionId}/workspace/index.html`, { + waitUntil: "domcontentloaded", + timeout: 30000 + }); + await workspace.waitForFunction(() => document.querySelectorAll("#providerBar .provider-chip").length === 8, { + timeout: 20000 + }); + }); + await recordStage("workspace", "provider-selection", () => configureWorkspace(workspace, providerIds)); + await recordStage("workspace", "provider-ready", () => waitForWorkspaceProviders(workspace, providerIds)); + await recordStage("workspace", "prompt-send-and-response-collect", () => sendAndCollect(workspace, context, providerIds)); + + if (providerIds.includes("grok")) { + await recordStage("grok", "top-level-tab", async () => { + const page = context.pages().find((candidate) => { + try { return GROK_HOSTS.has(new URL(candidate.url()).hostname); } catch { return false; } + }); + if (!page) throw new SmokeFailure("GROK_TAB_NOT_OPEN"); + await page.bringToFront(); + }); + await recordStage("grok", "websocket-observed", async () => { + if (!grokWebSocketCount) throw new SmokeFailure("GROK_WEBSOCKET_NOT_OBSERVED"); + }); + } + + console.log(`[authenticated-smoke] passed for ${providerIds.join(",")}`); + } catch (error) { + if (!diagnostics.some((entry) => entry.status === "failed")) { + diagnostics.push({ + providerId: "run", + stage: "setup", + status: "failed", + code: error instanceof SmokeFailure ? error.code : "UNEXPECTED_FAILURE", + startedAt: new Date().toISOString(), + finishedAt: new Date().toISOString() + }); + writeDiagnostics(diagnostics); + } + console.error("[authenticated-smoke] failed; inspect sanitized diagnostics artifact"); + process.exitCode = 1; + } finally { + if (context) await context.close().catch(() => {}); + if (userDataDir) fs.rmSync(userDataDir, { recursive: true, force: true }); + writeDiagnostics(diagnostics); + } +} + +if (require.main === module) run(); + +module.exports = Object.freeze({ + PROVIDERS, + parseProviderIds, + credentialsFor +});