feat: 完善 AI Studio Key 自动化流程

This commit is contained in:
2026-09-23 15:39:20 +08:00
parent f41a979462
commit 04309f60c7
45 changed files with 6600 additions and 119 deletions
@@ -0,0 +1,710 @@
'use strict';
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const { chromium } = require('playwright');
const service = require('../src/services/aistudio_api_key_service');
const {
apiKeysUrlForProject,
extractCloudProjectId,
needsCloudConsoleProjectFallback,
} = service._private;
assert.strictEqual(
service.CLOUD_PROJECT_CREATE_URL,
'https://console.cloud.google.com/projectcreate',
'Cloud Console 兜底必须使用项目创建页(不能包含中文逗号)',
);
const projectId = 'stalwart-yen-509406-k1';
const projectName = 'My Project 25180';
assert.strictEqual(
extractCloudProjectId(`https://console.cloud.google.com/home/dashboard?project=${projectId}`, ''),
projectId,
'应从 Cloud Console 创建成功后的 dashboard URL 读取项目 ID',
);
assert.strictEqual(
extractCloudProjectId('', `Project name\nMy Project 25180\nProject ID: ${projectId}. It cannot be changed later.`),
projectId,
'应从截图对应的项目创建页文案读取项目 ID',
);
assert.strictEqual(
apiKeysUrlForProject(projectId),
`https://aistudio.google.com/api-keys?project=${projectId}`,
'返回 AI Studio 时必须携带精确项目 ID',
);
assert.strictEqual(
needsCloudConsoleProjectFallback('新建项目请求被拒绝(CreateCloudProject HTTP 403)'),
true,
'CreateCloudProject 403 应触发 Cloud Console 兜底',
);
assert.strictEqual(
needsCloudConsoleProjectFallback('Unable to create API key:AI Studio 无法代建 Google Cloud 项目'),
true,
'截图中的 Unable to create API key 引导弹窗应触发兜底',
);
assert.strictEqual(
needsCloudConsoleProjectFallback('GenerateCloudApiKey HTTP 403 / The request is suspicious'),
false,
'仅生成 Key 被风控时不能误建新的 Cloud 项目',
);
const sourcePath = path.join(__dirname, '..', 'src', 'services', 'aistudio_api_key_service.js');
const source = fs.readFileSync(sourcePath, 'utf8');
const createKeyStart = source.indexOf('async function createKey(page, log, opts)');
const createKeyEnd = source.indexOf('\nasync function createKeyOnce', createKeyStart);
const createKeySource = source.slice(createKeyStart, createKeyEnd);
assert(createKeySource.includes('createProjectViaCloudConsole(page, log'),
'创建项目被拒绝后必须实际调用 Cloud Console 兜底');
assert(createKeySource.includes('runtimeOpts.preferredProjectId = fallback.projectId'),
'Cloud Console 返回的项目 ID 必须传入后续精确导入流程');
assert(createKeySource.includes('runtimeOpts.preferredProjectName = fallback.projectName'),
'Cloud Console 返回的项目名称必须传入导入搜索流程');
assert(createKeySource.includes('dismissCloudProjectFallbackDialogs(page, log)'),
'Cloud Console 返回后必须清理 Unable 提示和残留的新建项目弹窗');
assert(createKeySource.includes('r.preferredProjectMissing'),
'旧 project ID 精确搜索无结果时必须进入失效项目恢复分支');
assert(createKeySource.includes("runtimeOpts.preferredProjectId = ''"),
'确认旧 project ID 不存在后必须解除项目限制,允许创建新项目');
const cloudFallbackStart = source.indexOf('async function createProjectViaCloudConsole');
const cloudFallbackEnd = source.indexOf('\n/** 在打开的浮层菜单里点“删除”项', cloudFallbackStart);
const cloudFallbackSource = source.slice(cloudFallbackStart, cloudFallbackEnd);
assert(cloudFallbackSource.includes('if (projectId) {'),
'Cloud Console 已提交且有项目 ID 时,页面超时不能误判为创建失败');
assert(cloudFallbackSource.includes('gotoApiKeys(page, 30000, projectId)'),
'Cloud Console 提交后超时必须回 AI Studio 检查 Import projects');
assert(cloudFallbackSource.includes('submittedPending: true'),
'已提交项目的超时应标记为待导入,不能再新建项目');
const selectStart = source.indexOf('async function selectProjectInNewUi');
const selectEnd = source.indexOf('\nasync function classifyProjectOptions', selectStart);
const selectSource = source.slice(selectStart, selectEnd);
assert(selectSource.indexOf('if (classified.importIndex >= 0)') >= 0,
'没有真实项目时必须先检查 Import projects,包括首次运行账号');
assert(selectSource.indexOf('if (classified.importIndex >= 0)')
< selectSource.indexOf('if (classified.createIndex >= 0)'),
'项目流程必须先 Import、确认无候选后才 Create project');
assert(selectSource.includes('Import projects 确认没有可用项目'),
'普通账号必须在导入列表明确为空后才新建项目');
assert(selectSource.includes("classified = await classifyProjectOptions(page, '')"),
'关闭空导入抽屉后必须重开并重新读取项目下拉,不能使用失效 DOM 标记');
assert(selectSource.includes('waitCreateReadyAfterProject(page, log, 8000'),
'Imported 后应先短时复用当前创建框,避免固定整页重载');
assert(selectSource.includes('gotoApiKeys(page, 30000, importedProjectId)'),
'短时复用未刷新项目时必须精确刷新一次,不能在旧项目列表里连续空等');
assert(selectSource.includes('不要求网址发生变化'),
'当前位置创建项目成功不能依赖 URL 跳转,应按弹窗关闭和项目选中判断');
const importDrawerStart = source.indexOf('async function importCloudProjectFromDrawer');
const importDrawerEnd = source.indexOf('\n/** 轮询等待创建密钥按钮变为可点', importDrawerStart);
const importDrawerSource = source.slice(importDrawerStart, importDrawerEnd);
assert(importDrawerSource.includes('postSubmitDeadline = Date.now() + 12000'),
'点击 Import 后必须开启独立完成确认窗口,不能复用已经耗尽的初始超时');
assert(importDrawerSource.includes('importSuccessToast'),
'导入完成确认必须识别 Projects imported successfully 提示');
(async () => {
const serviceSource = fs.readFileSync(
path.join(__dirname, '..', 'src', 'services', 'aistudio_api_key_service.js'),
'utf8',
);
const cloudFallbackStart = serviceSource.indexOf('async function createProjectViaCloudConsole');
const cloudFallbackEnd = serviceSource.indexOf('\n/**', cloudFallbackStart + 20);
const cloudFallbackSource = serviceSource.slice(cloudFallbackStart, cloudFallbackEnd);
assert(
cloudFallbackSource.indexOf('dismissBrowserChromePopup(page, log)')
< cloudFallbackSource.indexOf('page.goto(CLOUD_PROJECT_CREATE_URL'),
'进入 Cloud Console 前必须关闭浏览器原生保存密码弹窗,避免遮挡首次条款',
);
assert(
cloudFallbackSource.includes('projectId ? Math.min(timeoutMs, 15000) : timeoutMs'),
'表单已取得 project ID 后最多前台等待15秒,随后应释放窗口进入非阻塞同步流程',
);
assert(
cloudFallbackSource.includes('if (form.ready && !termsDialogVisible)')
&& cloudFallbackSource.indexOf('if (form.ready && !termsDialogVisible)')
< cloudFallbackSource.indexOf('acceptCloudConsoleTerms(page, log, 35000)'),
'项目创建表单已经就绪且没有条款弹窗遮挡时,必须跳过条款流程并直接点击 Create',
);
const termsStart = serviceSource.indexOf('async function acceptCloudConsoleTerms');
const termsEnd = serviceSource.indexOf('\n/** 在 Cloud Console 的项目创建表单', termsStart);
const termsSource = serviceSource.slice(termsStart, termsEnd);
assert(termsSource.includes('/console\\.cloud\\.google\\.com\\/terms'),
'误入 Cloud 条款说明页时必须返回 projectcreate,不能停留等待');
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage({ viewport: { width: 1200, height: 800 } });
await page.setContent(`
<main>
<form>
<label for="project-name">Project name *</label>
<input id="project-name" name="projectName" type="text" value="My Project 25180">
<p>Project ID: ${projectId}. It cannot be changed later.</p>
<button type="submit">Create</button>
</form>
</main>
<div role="dialog" aria-modal="true" style="position:fixed;left:300px;top:100px;width:500px;height:400px;background:white">
<h1>Welcome Test User!</h1>
<label>Country <select><option>United States</option></select></label>
<label><input id="terms" type="checkbox"> I agree to the Google Cloud Platform Terms of Service</label>
<button id="agree" disabled>Agree and continue</button>
</div>
<script>
document.getElementById('terms').addEventListener('change', event => {
document.getElementById('agree').disabled = !event.target.checked;
});
document.getElementById('agree').addEventListener('click', () => {
document.querySelector('[role="dialog"]').remove();
});
</script>
`);
assert.strictEqual(
await service._private.hasVisibleCloudConsoleTermsDialog(page),
true,
'项目表单已渲染但条款弹窗仍遮挡时不能直接点击底层 Create',
);
const terms = await service._private.acceptCloudConsoleTerms(page, () => {}, 3000);
assert.strictEqual(terms.ok, true, '首次 Google Cloud 条款应自动勾选并确认');
assert.strictEqual(terms.handled, true, '存在首次条款弹窗时应报告已处理');
assert.strictEqual(await page.locator('[role="dialog"]').count(), 0, '确认后条款弹窗应关闭');
assert.strictEqual(
await service._private.hasVisibleCloudConsoleTermsDialog(page),
false,
'条款弹窗关闭后应允许直接点击项目 Create',
);
// 2026-09-22 实测新版 Cloud Console:720px 高窗口里的条款容器不一定带
// role=dialog/cfc-dialog,复选框也可能是自定义 MDC 根节点而非可见 input。
// 必须能从 Agree and continue 按钮反向定位弹窗并滚动点击,不能误点底层 Create。
await page.setViewportSize({ width: 1255, height: 720 });
await page.setContent(`
<main>
<form>
<label>Project name * <input name="projectName" value="My Project 99735"></label>
<p>Project ID: river-module-509411-k1. It cannot be changed later.</p>
<button type="submit">Create</button>
</form>
</main>
<div class="new-cloud-modal-container" style="position:fixed;left:350px;top:145px;width:575px;height:490px;overflow:auto;background:white">
<h1>Google Cloud</h1>
<h2>Welcome Vahid Tam!</h2>
<p>Country</p>
<select><option>United States</option></select>
<h3>Terms of Service</h3>
<div id="custom-terms" class="mdc-checkbox" role="checkbox" aria-checked="false" style="width:20px;height:20px"></div>
<span>I agree to the Google Cloud Platform Terms of Service</span>
<button id="custom-agree" disabled>Agree and continue</button>
</div>
<script>
document.getElementById('custom-terms').addEventListener('click', event => {
event.currentTarget.setAttribute('aria-checked', 'true');
document.getElementById('custom-agree').disabled = false;
});
document.getElementById('custom-agree').addEventListener('click', () => {
document.querySelector('.new-cloud-modal-container').remove();
});
</script>
`);
const customTerms = await service._private.acceptCloudConsoleTerms(page, () => {}, 3000);
assert.strictEqual(customTerms.ok, true,
'小屏幕新版自定义 Cloud 条款弹窗应自动勾选并确认');
assert.strictEqual(customTerms.handled, true,
'无 role=dialog 的新版条款弹窗也必须报告已处理');
assert.strictEqual(await page.locator('.new-cloud-modal-container').count(), 0,
'新版条款确认后弹窗必须消失,才允许操作底层项目表单');
// 真实页面还存在复选框封装在不可查询组件中的版本:DOM 只能看到条款文字,
// 视觉复选框位于文字左侧。此时应按文字 Range 坐标反推并点击,不能空等20秒。
await page.setContent(`
<style>
#shadow-agreement::before { content:''; display:inline-block; width:18px; height:18px; margin-right:10px; border:2px solid #666; vertical-align:middle; }
</style>
<main><form><input name="projectName" value="Blocked Project"><button>Create</button></form></main>
<div class="shadow-modal-container" style="position:fixed;left:340px;top:145px;width:575px;height:410px;background:white">
<h2>Welcome Shadow User!</h2>
<h3>Terms of Service</h3>
<div id="shadow-agreement" style="margin-left:28px"><span id="shadow-label">I agree to the Google Cloud Platform Terms of Service</span></div>
<button id="shadow-agree" disabled>Agree and continue</button>
</div>
<script>
document.addEventListener('click', event => {
const rect = document.getElementById('shadow-label')?.getBoundingClientRect();
if (!rect) return;
if (Math.abs(event.clientX - (rect.left - 18)) <= 9 && event.clientY >= rect.top && event.clientY <= rect.bottom) {
document.getElementById('shadow-agree').disabled = false;
}
});
document.getElementById('shadow-agree').addEventListener('click', () => {
document.querySelector('.shadow-modal-container').remove();
});
</script>
`);
const shadowTerms = await service._private.acceptCloudConsoleTerms(page, () => {}, 3000);
assert.strictEqual(shadowTerms.ok, true,
'复选框封装节点不可查询时应按条款文字左侧视觉坐标完成确认');
assert.strictEqual(await page.locator('.shadow-modal-container').count(), 0,
'视觉坐标兜底确认后条款弹窗必须消失');
// yinlulu970612 真实回归:首次确认点击已发出,但 Cloud Console 弹层没有
// 及时关闭。自动流程必须重新定位后再点一次,不能固定等待后直接判失败。
let delayedTermsClicks = 0;
await page.exposeFunction('recordDelayedTermsClick', () => { delayedTermsClicks++; });
await page.setContent(`
<main><form><label>Project name * <input name="projectName" value="My Project 46088"></label><button>Create</button></form></main>
<div id="delayed-terms-dialog" role="dialog" aria-modal="true" style="position:fixed;left:300px;top:100px;width:500px;height:400px;background:white">
<h2>Welcome Test User!</h2><h3>Terms of Service</h3>
<label><input id="delayed-terms" type="checkbox"> I agree to the Google Cloud Platform Terms of Service</label>
<button id="delayed-agree" disabled>Agree and continue</button>
</div>
<script>
document.getElementById('delayed-terms').addEventListener('change', event => {
document.getElementById('delayed-agree').disabled = !event.target.checked;
});
document.getElementById('delayed-agree').addEventListener('click', () => {
window.recordDelayedTermsClick();
delayedTermsClicks = (window.delayedTermsClicks || 0) + 1;
window.delayedTermsClicks = delayedTermsClicks;
if (delayedTermsClicks >= 2) document.getElementById('delayed-terms-dialog').remove();
});
</script>
`);
// 极短入口预算模拟真实慢页面:第一次点击发生时原 deadline 已接近耗尽。
// 点击后必须获得独立确认窗口,第二次点击仍应能够执行。
const retriedTerms = await service._private.acceptCloudConsoleTerms(page, () => {}, 1000);
assert.strictEqual(retriedTerms.ok, true,
'首次确认后弹窗未关闭时应重新定位并确认一次');
assert.strictEqual(delayedTermsClicks, 2,
'条款弹窗卡住时应只补一次确认点击');
assert.strictEqual(await page.locator('#delayed-terms-dialog').count(), 0,
'第二次确认成功后才能继续操作项目表单');
await page.setContent(`
<main>
<form>
<label for="project-name">Project name *</label>
<input id="project-name" name="projectName" type="text" value="My Project 25180">
<p>Project ID: ${projectId}. It cannot be changed later.</p>
<button type="submit">Create</button>
</form>
</main>
`);
const form = await service._private.markCloudProjectCreateForm(page);
assert.strictEqual(form.ready, true, '应识别截图中的 Project name + Create 表单');
assert.strictEqual(form.value, 'My Project 25180', '网页已有默认项目名时应保留原值');
assert.strictEqual(form.projectId, projectId, '提交前应保存页面生成的项目 ID');
assert.strictEqual(await page.locator('[data-cloud-project-create="1"]').count(), 1,
'应只标记项目表单内的 Create 主按钮');
// 文档截图明确要求:AI Studio 内创建项目时若默认名不合规/不是英文,改成英文名。
let submittedProjectName = '';
await page.exposeFunction('recordSubmittedProjectName', value => { submittedProjectName = value; });
await page.setContent(`
<div role="dialog" aria-modal="true" style="position:fixed;left:300px;top:120px;width:480px;height:260px;background:white">
<h2>Create a new project</h2>
<input id="invalid-project-name" type="text" value="中文项目">
<button>Cancel</button>
<button id="create-project-submit" class="ms-button-primary">Create project</button>
</div>
<script>
document.getElementById('create-project-submit').addEventListener('click', () => {
window.recordSubmittedProjectName(document.getElementById('invalid-project-name').value);
document.querySelector('[role="dialog"]').remove();
});
</script>
`);
page.__osDisabled = true;
const renamedProject = await service._private.createProject(page, () => {}, 'Gemini Project', 3000);
assert.strictEqual(renamedProject.ok, true, '不合规项目名修正后应能提交创建');
assert.strictEqual(submittedProjectName, 'Gemini Project', '非英文/不合规默认项目名必须替换为英文名');
// Cloud Console 返回时按人工文档顺序关闭 Unable 提示,再取消底下残留项目弹窗。
await page.setContent(`
<div id="project-dialog" role="dialog" style="position:fixed;left:250px;top:100px;width:500px;height:300px;background:white">
<h2>Create a new project</h2><button id="project-cancel">Cancel</button>
</div>
<div id="failure-dialog" role="dialog" style="position:fixed;left:300px;top:150px;width:500px;height:220px;background:white">
<h2>Unable to create API key</h2><p>Please create a project in the Google Cloud Console.</p><button id="failure-close">Close</button>
</div>
<script>
document.getElementById('failure-close').addEventListener('click', () => document.getElementById('failure-dialog').remove());
document.getElementById('project-cancel').addEventListener('click', () => document.getElementById('project-dialog').remove());
</script>
`);
const cleanedFallbackDialogs = await service._private.dismissCloudProjectFallbackDialogs(page, () => {});
assert.strictEqual(cleanedFallbackDialogs, true, 'Cloud Console 返回后应处理两层残留弹窗');
assert.strictEqual(await page.locator('[role="dialog"]').count(), 0, 'Close 和 Cancel 后不应残留项目失败弹窗');
let importClicks = 0;
let searchInputs = 0;
await page.exposeFunction('recordImportClick', () => { importClicks++; });
await page.exposeFunction('recordSearchInput', () => { searchInputs++; });
await page.setContent(`
<aside style="position:fixed;right:0;top:0;width:500px;height:700px;background:white">
<h2>Import projects</h2>
<input id="project-search" placeholder="Search for a project">
<p>1 project selected</p>
<button id="import-submit">Import ↵</button>
<label style="display:block;width:400px;height:80px">
<input id="import-project" type="checkbox" checked>
<span>My Project 25180</span>
<span>${projectId}</span>
</label>
</aside>
<script>
document.getElementById('project-search').addEventListener('input', () => window.recordSearchInput());
document.getElementById('import-submit').addEventListener('click', () => {
window.recordImportClick();
document.querySelector('aside').remove();
});
</script>
`);
const imported = await service._private.importCloudProjectFromDrawer(page, () => {}, projectId, 5000);
assert.strictEqual(imported.ok, true, '项目已预选时应点击 Import 主按钮完成导入');
assert.strictEqual(imported.projectId, projectId, '导入结果必须携带项目 ID,供同步等待后精确重试');
assert.strictEqual(importClicks, 1, '勾选项目不等于已导入,必须实际点击一次 Import');
assert.strictEqual(searchInputs, 0, '建议项目已显示时不应再搜索项目 ID');
assert.strictEqual(page.url(), 'about:blank',
'导入完成后应复用当前创建框,不能为了刷新项目整页重载 API Keys');
// williamdoborogodo 真实回归:Import 在初始超时即将耗尽时才被点击,服务端约1秒后
// 才关闭抽屉。旧代码点击后只等1秒便退出循环并误报失败;现在必须使用独立确认窗口。
let lateImportClicks = 0;
await page.exposeFunction('recordLateImportClick', () => { lateImportClicks++; });
await page.setContent(`
<aside id="late-import-drawer" style="position:fixed;right:0;top:0;width:500px;height:700px;background:white">
<h2>Import projects</h2>
<input placeholder="Search for a project">
<p>1 project selected</p>
<label style="display:block;width:400px;height:80px">
<input type="checkbox" checked><span>My Project 13167</span><span>${projectId}</span>
</label>
<button id="late-import-submit">Import ↵</button>
</aside>
<script>
document.getElementById('late-import-submit').addEventListener('click', () => {
window.recordLateImportClick();
setTimeout(() => {
document.getElementById('late-import-drawer')?.remove();
const toast = document.createElement('div');
toast.setAttribute('role', 'status');
toast.textContent = 'Projects imported successfully!';
document.body.append(toast);
}, 1800);
});
</script>
`);
const lateImportStartedAt = Date.now();
const lateImported = await service._private.importCloudProjectFromDrawer(page, () => {}, '', 500);
assert.strictEqual(lateImported.ok, true,
'初始超时耗尽后导入才完成时,仍应确认成功而不是误报未完成');
assert.strictEqual(lateImportClicks, 1, '延迟完成场景只能点击一次 Import');
assert(Date.now() - lateImportStartedAt >= 1500,
'测试必须实际跨过初始超时并等待服务端完成,防止用例假通过');
let projectChoiceClicks = 0;
let selectAllClicks = 0;
let uncheckedImportClicks = 0;
await page.exposeFunction('recordProjectChoiceClick', () => { projectChoiceClicks++; });
await page.exposeFunction('recordSelectAllClick', () => { selectAllClicks++; });
await page.exposeFunction('recordUncheckedImportClick', () => { uncheckedImportClicks++; });
await page.setContent(`
<aside style="position:fixed;right:0;top:0;width:500px;height:700px;background:white">
<h2>Import projects</h2>
<input placeholder="Search for a project">
<p>Previously Imported projects may appear below.</p>
<label style="display:block;width:400px;height:40px">
<input id="select-all" type="checkbox"> Select all
</label>
<label style="display:block;width:400px;height:80px">
<input id="project-choice" type="checkbox">
<span>My Project 83665</span>
<span>${projectId}</span>
</label>
<button id="unchecked-import" disabled>Import ↵</button>
</aside>
<script>
document.getElementById('select-all').addEventListener('click', () => window.recordSelectAllClick());
document.getElementById('project-choice').addEventListener('click', event => {
window.recordProjectChoiceClick();
document.getElementById('unchecked-import').disabled = !event.target.checked;
});
document.getElementById('unchecked-import').addEventListener('click', () => {
window.recordUncheckedImportClick();
document.querySelector('aside').remove();
});
</script>
`);
const uncheckedImported = await service._private.importCloudProjectFromDrawer(page, () => {}, projectId, 5000);
assert.strictEqual(uncheckedImported.ok, true,
'项目已出现但未勾选时,应先勾选具体项目再点击 Import');
assert.strictEqual(uncheckedImported.projectId, projectId,
'从未勾选项目行中应提取项目 ID,防止延迟重试时重复创建项目');
assert.strictEqual(projectChoiceClicks, 1, '必须点击具体项目复选框');
assert.strictEqual(selectAllClicks, 0, '不能把 Select all 误当成项目复选框');
assert.strictEqual(uncheckedImportClicks, 1, '项目选中后必须点击 Import 主按钮');
let delayedChoiceClicks = 0;
let delayedImportClicks = 0;
await page.exposeFunction('recordDelayedChoiceClick', () => { delayedChoiceClicks++; });
await page.exposeFunction('recordDelayedImportClick', () => { delayedImportClicks++; });
await page.setContent(`
<aside style="position:fixed;right:0;top:0;width:500px;height:700px;background:white">
<h2>Import projects</h2>
<button aria-label="Close">×</button>
<p id="empty-state">No projects found</p>
</aside>
<script>
setTimeout(() => {
document.getElementById('empty-state').remove();
const label = document.createElement('label');
label.style.cssText = 'display:block;width:400px;height:80px';
label.innerHTML = '<input id="delayed-choice" type="checkbox"><span>Delayed Project</span><span>${projectId}</span>';
const button = document.createElement('button');
button.id = 'delayed-import';
button.disabled = true;
button.textContent = 'Import ↵';
document.querySelector('aside').append(label, button);
document.getElementById('delayed-choice').addEventListener('click', event => {
window.recordDelayedChoiceClick();
button.disabled = !event.target.checked;
});
button.addEventListener('click', () => {
window.recordDelayedImportClick();
document.querySelector('aside').remove();
});
}, 900);
</script>
`);
const delayedImported = await service._private.importCloudProjectFromDrawer(page, () => {}, projectId, 5000);
assert.strictEqual(delayedImported.ok, true,
'指定 Cloud 项目同步时,抽屉短暂显示 No projects 后候选晚到仍应继续勾选并导入');
assert.strictEqual(delayedChoiceClicks, 1, '晚到候选必须点击具体项目复选框');
assert.strictEqual(delayedImportClicks, 1, '晚到候选选中后必须点击 Import 主按钮');
let exactSearchInputs = 0;
let searchedChoiceClicks = 0;
let searchedImportClicks = 0;
await page.exposeFunction('recordExactSearchInput', () => { exactSearchInputs++; });
await page.exposeFunction('recordSearchedChoiceClick', () => { searchedChoiceClicks++; });
await page.exposeFunction('recordSearchedImportClick', () => { searchedImportClicks++; });
await page.setContent(`
<aside style="position:fixed;right:0;top:0;width:500px;height:700px;background:white">
<h2>Import projects</h2>
<button aria-label="Close">×</button>
<input id="exact-project-search" placeholder="Search for a project">
<p id="exact-empty-state">No projects found</p>
</aside>
<script>
document.getElementById('exact-project-search').addEventListener('input', event => {
window.recordExactSearchInput();
if (event.target.value !== '${projectName}') return;
document.getElementById('exact-empty-state')?.remove();
if (document.getElementById('searched-choice')) return;
const label = document.createElement('label');
label.style.cssText = 'display:block;width:400px;height:80px';
label.innerHTML = '<input id="searched-choice" type="checkbox"><span>Searched Project</span><span>${projectId}</span>';
const button = document.createElement('button');
button.id = 'searched-import';
button.disabled = true;
button.textContent = 'Import ↵';
document.querySelector('aside').append(label, button);
document.getElementById('searched-choice').addEventListener('click', checkedEvent => {
window.recordSearchedChoiceClick();
button.disabled = !checkedEvent.target.checked;
});
button.addEventListener('click', () => {
window.recordSearchedImportClick();
document.querySelector('aside').remove();
});
});
</script>
`);
const searchedImported = await service._private.importCloudProjectFromDrawer(
page,
() => {},
projectId,
10000,
projectName,
);
assert.strictEqual(searchedImported.ok, true,
'新建项目不在推荐列表时,应按项目名称搜索后继续导入,不能重复创建项目');
assert(exactSearchInputs >= 1, '推荐列表持续为空时必须输入 Cloud Console 项目名称搜索');
assert.strictEqual(searchedChoiceClicks, 1, '搜索结果出现后必须勾选具体项目');
assert.strictEqual(searchedImportClicks, 1, '搜索结果选中后必须点击 Import 主按钮');
let missingSearchInputs = 0;
let missingDrawerClosed = 0;
await page.exposeFunction('recordMissingSearchInput', () => { missingSearchInputs++; });
await page.exposeFunction('recordMissingDrawerClose', () => { missingDrawerClosed++; });
await page.setContent(`
<aside style="position:fixed;right:0;top:0;width:500px;height:700px;background:white">
<h2>Import projects</h2>
<button id="missing-drawer-close" aria-label="Close">×</button>
<input id="missing-project-search" placeholder="Search for a project">
<p>No projects found</p>
</aside>
<script>
document.getElementById('missing-project-search').addEventListener('input', () => {
window.recordMissingSearchInput();
});
document.getElementById('missing-drawer-close').addEventListener('click', () => {
window.recordMissingDrawerClose();
document.querySelector('aside').remove();
});
</script>
`);
const missingProject = await service._private.importCloudProjectFromDrawer(
page,
() => {},
projectId,
5000,
projectName,
);
assert.strictEqual(missingProject.ok, false, '精确搜索仍无项目时不应误报导入成功');
assert.strictEqual(missingProject.noProjects, true, '精确搜索无结果应转入无项目恢复流程');
assert.strictEqual(missingProject.exactSearchMiss, true,
'精确搜索无结果必须显式标记旧 project ID 已失效');
assert.strictEqual(missingProject.projectId, projectId, '失效结果应保留被核验的旧 project ID');
assert.strictEqual(missingProject.projectName, projectName, '失效结果应保留被搜索的项目名称');
assert(missingSearchInputs >= 1, '必须实际执行 project ID 精确搜索后才能判定旧项目失效');
assert.strictEqual(missingDrawerClosed, 1, '创建新项目前必须关闭 Import projects 抽屉');
let drawerClosed = 0;
await page.exposeFunction('recordDrawerClose', () => { drawerClosed++; });
await page.setContent(`
<aside style="position:fixed;right:0;top:0;width:500px;height:700px;background:white">
<h2>Import projects</h2>
<button id="drawer-close" aria-label="Close">×</button>
<p>No projects found</p>
</aside>
<script>
document.getElementById('drawer-close').addEventListener('click', () => {
window.recordDrawerClose();
document.querySelector('aside').remove();
});
</script>
`);
const noProject = await service._private.importCloudProjectFromDrawer(page, () => {}, '', 3000);
assert.strictEqual(noProject.ok, false, '没有可导入项目时不应误报导入成功');
assert.strictEqual(noProject.noProjects, true, '没有候选项目时应允许上层回退到 Create project');
assert.strictEqual(drawerClosed, 1, '回退创建项目前应先关闭 Import projects 抽屉');
// 状态机级回归:无真实选中项目时,必须先 Import 已有候选,不能误点 Create。
let importOptionClicks = 0;
let createOptionClicks = 0;
await page.exposeFunction('recordImportOptionClick', () => { importOptionClicks++; });
await page.exposeFunction('recordCreateOptionClick', () => { createOptionClicks++; });
await page.setContent(`
<ms-api-key-creation-dialog style="display:block;position:fixed;left:300px;top:120px;width:520px;height:320px;background:white">
<h2>Create a new key</h2>
<div id="api-key-name-input"><input type="text" value="Gemini API Key"></div>
<mat-select id="state-project-select" role="combobox" style="display:block;width:360px;height:42px">
<span class="mat-mdc-select-value-text">No Cloud Projects Available</span>
</mat-select>
<button>Create key</button>
</ms-api-key-creation-dialog>
<div id="state-overlay"></div>
<script>
const select = document.getElementById('state-project-select');
const value = select.querySelector('.mat-mdc-select-value-text');
const showOptions = () => {
const overlay = document.getElementById('state-overlay');
overlay.innerHTML = '<mat-option id="state-import-option" role="option" style="display:block;width:300px;height:40px">Import project</mat-option>'
+ '<mat-option id="state-create-option" role="option" style="display:block;width:300px;height:40px">+ Create project</mat-option>';
document.getElementById('state-import-option').addEventListener('click', () => {
window.recordImportOptionClick();
overlay.innerHTML = '';
const drawer = document.createElement('aside');
drawer.style.cssText = 'position:fixed;right:0;top:0;width:480px;height:700px;background:white';
drawer.innerHTML = '<h2>Import projects</h2><input placeholder="Search for a project">'
+ '<p>1 project selected</p><label style="display:block;width:380px;height:70px">'
+ '<input type="checkbox" checked><span>Existing Project</span><span>${projectId}</span></label>'
+ '<button id="state-import-submit">Import ↵</button>';
document.body.append(drawer);
document.getElementById('state-import-submit').addEventListener('click', () => {
value.textContent = 'Existing Project';
drawer.remove();
});
});
document.getElementById('state-create-option').addEventListener('click', () => window.recordCreateOptionClick());
};
select.addEventListener('click', showOptions);
</script>
`);
page.__osDisabled = true;
const importedBeforeCreate = await service._private.selectProjectInNewUi(page, () => {}, 'Gemini Project');
assert.strictEqual(importedBeforeCreate.ok, true, '存在可导入项目时应完成导入并选中');
assert.strictEqual(importOptionClicks, 1, '无真实项目时应先进入一次 Import projects');
assert.strictEqual(createOptionClicks, 0, '已有可导入项目时严禁重复创建项目');
// Import 明确为空后,要关闭抽屉、重开下拉并创建;验证不能复用已经销毁的 option。
let emptyImportClicks = 0;
let fallbackCreateClicks = 0;
await page.exposeFunction('recordEmptyImportClick', () => { emptyImportClicks++; });
await page.exposeFunction('recordFallbackCreateClick', () => { fallbackCreateClicks++; });
await page.setContent(`
<ms-api-key-creation-dialog style="display:block;position:fixed;left:300px;top:120px;width:520px;height:320px;background:white">
<h2>Create a new key</h2>
<div id="api-key-name-input"><input type="text" value="Gemini API Key"></div>
<mat-select id="empty-state-project-select" role="combobox" style="display:block;width:360px;height:42px">
<span class="mat-mdc-select-value-text">No Cloud Projects Available</span>
</mat-select>
<button>Create key</button>
</ms-api-key-creation-dialog>
<div id="empty-state-overlay"></div>
<script>
const emptySelect = document.getElementById('empty-state-project-select');
const emptyValue = emptySelect.querySelector('.mat-mdc-select-value-text');
const showEmptyOptions = () => {
const overlay = document.getElementById('empty-state-overlay');
overlay.innerHTML = '<mat-option id="empty-import-option" role="option" style="display:block;width:300px;height:40px">Import project</mat-option>'
+ '<mat-option id="fallback-create-option" role="option" style="display:block;width:300px;height:40px">+ Create project</mat-option>';
document.getElementById('empty-import-option').addEventListener('click', () => {
window.recordEmptyImportClick();
overlay.innerHTML = '';
const drawer = document.createElement('aside');
drawer.style.cssText = 'position:fixed;right:0;top:0;width:480px;height:700px;background:white';
drawer.innerHTML = '<h2>Import projects</h2><button id="empty-drawer-close" aria-label="Close">×</button>'
+ '<input placeholder="Search for a project"><p>No projects found</p>';
document.body.append(drawer);
document.getElementById('empty-drawer-close').addEventListener('click', () => drawer.remove());
});
document.getElementById('fallback-create-option').addEventListener('click', () => {
window.recordFallbackCreateClick();
overlay.innerHTML = '';
const projectDialog = document.createElement('mat-dialog-container');
projectDialog.setAttribute('role', 'dialog');
projectDialog.style.cssText = 'display:block;position:fixed;left:360px;top:180px;width:420px;height:220px;background:white';
projectDialog.innerHTML = '<h2>Create a new project</h2><input type="text" value="项目 123">'
+ '<button>Cancel</button><button id="fallback-create-submit" class="ms-button-primary">Create project</button>';
document.body.append(projectDialog);
document.getElementById('fallback-create-submit').addEventListener('click', () => {
emptyValue.textContent = 'Gemini Project';
projectDialog.remove();
});
});
};
emptySelect.addEventListener('click', showEmptyOptions);
</script>
`);
page.__osDisabled = true;
const createdAfterEmptyImport = await service._private.selectProjectInNewUi(page, () => {}, 'Gemini Project');
assert.strictEqual(createdAfterEmptyImport.ok, true, 'Import 为空后应返回下拉创建并选中新项目');
assert.strictEqual(createdAfterEmptyImport.created, true, '空导入列表分支应标记为已创建项目');
assert.strictEqual(emptyImportClicks, 1, '创建项目前必须先确认一次 Import projects 为空');
assert.strictEqual(fallbackCreateClicks, 1, '空抽屉关闭后必须重开下拉并点击 Create project');
console.log('aistudio_cloud_project_fallback.test.js: PASS');
} finally {
await browser.close();
}
})().catch(error => {
console.error(error);
process.exitCode = 1;
});