Files
go_bt/tests/aistudio_cloud_project_fallback.test.js

711 lines
40 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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;
});