test(browser): add protected authenticated smoke flow
This commit is contained in:
@@ -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
|
||||
@@ -13,3 +13,4 @@ coverage/
|
||||
|
||||
# local scratch/artifacts
|
||||
*.zip
|
||||
test-results/
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
Reference in New Issue
Block a user