'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(`
Welcome Test User!
`);
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(`
Google Cloud
Welcome Vahid Tam!
Country
Terms of Service
I agree to the Google Cloud Platform Terms of Service
`);
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(`
Welcome Shadow User!
Terms of Service
I agree to the Google Cloud Platform Terms of Service
`);
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(`
Welcome Test User!
Terms of Service
`);
// 极短入口预算模拟真实慢页面:第一次点击发生时原 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(`
`);
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(`
Create a new project
`);
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(`
Create a new project
Unable to create API key
Please create a project in the Google Cloud Console.
`);
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(`
`);
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(`
`);
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(`
`);
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(`
`);
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(`
`);
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(`
`);
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(`
`);
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(`
Create a new key
No Cloud Projects Available
`);
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(`
Create a new key
No Cloud Projects Available
`);
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;
});