711 lines
40 KiB
JavaScript
711 lines
40 KiB
JavaScript
'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;
|
||
});
|