'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(`

Project ID: ${projectId}. It cannot be changed later.

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(`

Project ID: river-module-509411-k1. It cannot be changed later.

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(`
`); // 极短入口预算模拟真实慢页面:第一次点击发生时原 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(`

Project ID: ${projectId}. It cannot be changed later.

`); 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(` `); 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; });