test(browser): add protected authenticated smoke flow

This commit is contained in:
2026-09-15 12:26:41 +08:00
parent eaec144ee7
commit 869efc4a04
6 changed files with 586 additions and 1 deletions
@@ -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
+1
View File
@@ -13,3 +13,4 @@ coverage/
# local scratch/artifacts
*.zip
test-results/
+4
View File
@@ -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 包:
@@ -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.
+1 -1
View File
@@ -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"
}
+448
View File
@@ -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
});