From 724290e8642a8627e3c9542efb3072ff71505dfe Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Sat, 8 Aug 2026 14:44:08 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20Makelore=20=E4=B8=80?= =?UTF-8?q?=E9=94=AE=E6=8F=90=E4=BA=A4=E5=AE=A1=E6=A0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求:让非专业用户在项目操作区一次提交,运营审核通过后直接发布。 实现:由 Electron Main 完成安全打包、自动版本、幂等重试和状态脱敏;补齐友好失败反馈、唯一提交入口及隔离 Electron E2E fixture。 --- .../20260807-makelore-release-client-8c4e.md | 66 ++ README.md | 3 +- electron/api/routes/works.ts | 467 +++++++++++++- electron/services/project-packager.ts | 585 ++++++++++++++++++ src/components/works/ProjectPublishAction.tsx | 199 ++++++ src/lib/works-project-publish.ts | 255 ++++++++ src/lib/works-square.ts | 79 ++- src/pages/ProjectConfiguration/index.tsx | 8 +- tests/e2e/app-smoke.spec.ts | 8 +- tests/e2e/fixtures/electron.ts | 273 ++++++++ tests/e2e/language-russian.spec.ts | 32 +- tests/e2e/project-superpowers-toggle.spec.ts | 7 +- .../unit/project-configuration-page.test.tsx | 1 + tests/unit/project-packager.test.ts | 298 +++++++++ tests/unit/project-publish-action.test.tsx | 210 +++++++ tests/unit/works-project-publish.test.ts | 54 ++ tests/unit/works-routes.test.ts | 431 ++++++++++++- tests/unit/works-square.test.ts | 111 ++++ 18 files changed, 3056 insertions(+), 31 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260807-makelore-release-client-8c4e.md create mode 100644 electron/services/project-packager.ts create mode 100644 src/components/works/ProjectPublishAction.tsx create mode 100644 src/lib/works-project-publish.ts create mode 100644 tests/e2e/fixtures/electron.ts create mode 100644 tests/unit/project-packager.test.ts create mode 100644 tests/unit/project-publish-action.test.tsx create mode 100644 tests/unit/works-project-publish.test.ts diff --git a/.project-docs/30-worklog/tasks/20260807-makelore-release-client-8c4e.md b/.project-docs/30-worklog/tasks/20260807-makelore-release-client-8c4e.md new file mode 100644 index 0000000..56beb4b --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260807-makelore-release-client-8c4e.md @@ -0,0 +1,66 @@ +# Task: 迁移并验收 Makelore 一键发布客户端 + +## Identity + +- Task ID: 20260807-makelore-release-client-8c4e +- Mode: Feature +- Branch: codex/20260807-makelore-release-client-8c4e-makelore-release-client +- Worktree: D:\Datas\OthersProjects\makelore-release-client-8c4e +- Base commit: 7b23cce67a1b9e61a48d89dedbd088d37b10ecd4 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- 以 `D:\Datas\OthersProjects\makelore` 为正确客户端仓库,重新核对一键发布实现与 Electron E2E 基线。 +- 补齐 `tests/e2e/fixtures/electron.ts`,恢复现有 Electron E2E 的测试收集和隔离启动能力。 +- 在不恢复独立发布/上传工作台的前提下,迁移 Main-owned 安全打包、自动版本、幂等重试和友好失败反馈。 +- 用户已确认在现有项目操作区增加唯一“提交审核”动作;不新增发布页面、侧栏入口或发布资源卡。 + +## Intent And Constraints + +- 项目文件、ZIP 生成、Works Square Token、幂等键和网络重试均由 Electron Main 持有;Renderer 只调用 Host API。 +- 遵守 Makelore 产品边界:不恢复 Works/素材广场、发布上传页面或云部署工作台。 +- 保留内部 `niancode` 兼容标识,面向用户的新增文案使用 Makelore。 +- 当前主工作区存在未归属的登录、设备预览等改动;所有实现只允许在本任务隔离工作树进行。 +- E2E 每个测试使用独立临时 profile,同一测试内重启复用该 profile,结束后关闭 Electron 并清理临时目录。 + +## Outcome + +- 已确认此前客户端改动落在错误的 NianCode 工作树,不能计为 Makelore 已完成;服务端发布链路不受此路径更正影响。 +- 已确认 Makelore 当前 HEAD 与全部本地 Git 历史都缺少 `tests/e2e/fixtures/electron.ts`,但 13 个 E2E spec 均导入该模块,因此 Playwright 会在测试收集阶段因模块不可解析而停止。 +- 已补齐 fixture 最小契约:`test`、`expect`、`launchElectronApp`、`electronApp`、`page`、`getStableWindow`、`closeElectronApp` 与 `completeSetup`;每个测试隔离 profile,同测试重启复用,所有 Electron 进程都有有界关闭与强杀兜底;启动环境采用跨平台必要变量白名单,不继承 Works 配置、密钥或开发服务器地址。 +- 已在 Electron Main 增加确定性静态项目打包:校验 npm/Vite 根项目、生成兼容 `niancode.yml`、排除依赖/构建产物和常见云凭据/私钥/密钥状态文件、限制数量与体积,并通过目录祖先 identity 链在扫描、打开和读取前后拒绝符号链接、越界目录与打包期间替换。 +- 已将 Works Square 会话 Token、`VERSION.md`/时间版本、更新说明、临时 ZIP 和幂等键全部留在 Main;408/502/503/504 与网络异常重试复用同一键和相同字节,新的用户提交生成新键。 +- 已为提交和当前项目状态提供无 Renderer Token 的 Host API;成功响应也只投影安全字段并校验必需结构,异常 200、安全失败、上游正文、构建堆栈、路径与未知字段均不会进入 Renderer。 +- 已在现有项目配置底部操作区加入唯一“一键提交审核”:自动作品 ID/标题/简介/分类,2 秒轮询云端构建,成功后显示“已提交,等待运营审核”,可恢复失败给出原因和下一步,不确定结果保持锁定以防重复提交。 +- 未新增 `/publish` 路由、发布侧栏或发布资源卡;现有 E2E 继续断言这些工作台入口不存在。 +- fixture 恢复后暴露的登录按钮旧文案、设置页旧 test id/控件和项目配置旧入口断言已按当前 Makelore 产品行为更新,没有为测试绕开生产登录门禁。 +- README 已同步当前产品状态:不恢复独立发布工作台,只在现有项目配置底部提供 Main-owned 一键提交审核。 + +## Verification + +- 只读文件检查确认 `tests/e2e/fixtures` 目录不存在。 +- `git ls-tree -r HEAD -- tests/e2e` 确认当前提交只包含 13 个 spec,不包含 fixture。 +- `git log --all -- tests/e2e/fixtures` 无结果,确认没有可直接 cherry-pick 的本地历史来源。 +- `rg` 确认现有 spec 需要 shared fixture,同时现有 E2E 明确断言发布侧栏和发布资源卡不存在。 +- 发布链路聚焦 Vitest:6 files / 77 tests passed。 +- `pnpm run typecheck` 与全部变更 TypeScript/TSX 的 scoped ESLint 通过。 +- `pnpm run build:vite` 的 Renderer、Main、Preload 构建通过;仅保留仓库既有的大 chunk 提示。 +- `pnpm exec playwright test --list --reporter=line` 成功收集 13 files / 21 tests。 +- 真实 Electron 串行验收覆盖首次设置、跳过后登录门禁、同 profile 重启、俄语设置持久化、创建项目、Superpowers 默认关闭和唯一发布入口:8 passed。 +- `git diff --check` 通过,构建输出与依赖没有进入未跟踪文件列表。 + +## Follow-ups + +- 使用真实 Works Square URL、创作者账号和已部署 Builder/Static Gateway 运行“客户端提交 → 构建/浏览器门禁 → 运营批准 → 公共作品”整链;当前本地验证未冒充生产发布成功。 +- 集成时优先合并服务端发布生命周期与门禁,再发布依赖新状态字段和错误码的 Makelore 客户端。 + +## Promotion Candidates + +- Target: Makelore `AGENTS.md` 或发布集成说明。 + - Proposal: 将“发布入口只存在于现有项目操作区,Renderer 只发 projectId/非敏感元数据,Main 持有文件、Token、版本、ZIP、幂等与重试”记录为长期客户端边界。 + - Evidence: 本任务实现、77 项聚焦回归、13/21 E2E 收集和 8 项真实 Electron 验收。 + - Future impact: 后续发布状态、失败反馈和真实环境验收不得重新引入发布工作台或 Renderer Token/文件路径。 + - Semantic conflicts: 无;与仓库禁止恢复发布/上传工作台的现行约束一致。 + - Human confirmation required: 是。 diff --git a/README.md b/README.md index c4a969f..ea599e0 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Makelore 是一个面向软件与视觉创作的 AI 桌面工作台。当前版 - `Makelore Canvas|AI 绘画`:以设计项目(Workspace)组织 Agent 对话、方向确认、图片/视频生成任务和私有结果。 - `Makelore Learning|AI 学习`:数学与知识宇宙主题的学习入口,当前暂未开通;开屏页和模块切换菜单保留置灰入口,不能进入。 -作品广场、素材广场、发布上传和云部署页面不属于 Makelore 2.0 工作台。项目成果预览 `/deliverables` 继续保留。 +作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。AI 编程项目只在现有项目配置底部提供“一键提交审核”:Main 自动预检、安全打包并提交,构建通过后进入运营审核,审核通过即直接发布。项目成果预览 `/deliverables` 继续保留。 ## 当前产品状态 @@ -79,6 +79,7 @@ pnpm run package:linux - Renderer 的后端调用统一经过 `src/lib/host-api.ts` 或 `src/lib/api-client.ts`。 - Renderer 不直接调用 Electron IPC 或本地运行时 HTTP 地址。 - Electron Main 负责认证、秘密存储、运行时生命周期、代理、同步和系统集成。 +- AI 编程发布只经过 Main-owned Host API:Renderer 仅提交本地项目标识和非敏感作品资料;Main 持有文件读取、安全打包、Works Token、版本生成、幂等重试和安全状态投影。 - AI 编程项目配置以项目内 `.niancode/project.json` 为准;项目文件和会话保持本地。 - AI 绘画 Renderer 只调用 Main-owned Host API;Main 负责 Works Square Token 刷新、可跨异常重启幂等复用的 Agent Session、单次 WebSocket ticket、断点续传与事件契约映射,并通过本机 Host API 的 SSE 投影同步任务状态;注销/退出时回收 Session,远端 Token 与 ticket 不进入 Renderer。 - AI 绘画使用独立的云端 Workspace 边界,不回退到 AI 编程项目数据,也不向 Renderer 暴露 Provider、模型、Prompt、存储 URI 或远端登录 Token。 diff --git a/electron/api/routes/works.ts b/electron/api/routes/works.ts index 6419d50..e3fddac 100644 --- a/electron/api/routes/works.ts +++ b/electron/api/routes/works.ts @@ -1,5 +1,7 @@ import type { IncomingMessage, ServerResponse } from 'http'; -import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { lstat, mkdir, mkdtemp, open, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import { basename, extname, isAbsolute, join, normalize, resolve } from 'node:path'; import type { HostApiContext } from '../context'; import { parseJsonBody, sendJson } from '../route-utils'; @@ -7,6 +9,11 @@ import { proxyAwareFetch } from '../../utils/proxy-fetch'; import { WORKS_SQUARE_CONFIG } from '../works-config'; import { readWorksDeployCheck } from '../../opencode/works-square-deploy-check'; import { readWorksPublishFile } from '../../opencode/works-publish-file'; +import { + createStaticProjectPackage, + ProjectPackageError, +} from '../../services/project-packager'; +import { getValidWorksSquareAccessToken } from '../../services/works-square-session'; type CreateProjectInput = { accessToken?: unknown; @@ -21,6 +28,11 @@ type UploadProjectVersionInput = { zipFilePath?: unknown; }; +type PublishProjectSourceInput = { + projectId?: unknown; + project?: unknown; +}; + type DownloadAssetInput = { projectId?: unknown; }; @@ -59,6 +71,11 @@ function readRequiredHeader(req: IncomingMessage, name: string): string { return readRequiredString(firstValue, name); } +function readOptionalHeader(req: IncomingMessage, name: string): string | undefined { + const value = req.headers[name.toLowerCase()]; + return readOptionalString(Array.isArray(value) ? value[0] : value); +} + function normalizeWorksBase(value = WORKS_SQUARE_CONFIG.apiBaseUrl): string { const apiBase = value.replace(/\/+$/, ''); if (!/^https?:\/\//i.test(apiBase)) { @@ -110,6 +127,43 @@ async function sendUpstreamError( }); } +function sendPublishSourceFailure( + res: ServerResponse, + status: number, + code: string, + error: string, +): void { + sendJson(res, 200, { success: false, status, code, error }); +} + +async function sendPublishSourceUpstreamError( + res: ServerResponse, + response: Response, + fallbackCode: string, + fallbackMessage: string, +): Promise { + let code = fallbackCode; + let error = fallbackMessage; + if (response.status === 401) { + code = 'AUTH_REQUIRED'; + error = '登录状态已失效,请重新登录。'; + } else if (response.status === 403) { + code = 'PUBLISH_FORBIDDEN'; + error = '当前账号不能发布这个作品。'; + } else if (response.status === 409) { + code = 'PROJECT_SUBMISSION_CONFLICT'; + error = '这个作品已有版本正在构建或审核。'; + } else if (response.status === 413) { + code = 'ARCHIVE_TOO_LARGE'; + error = '项目压缩包超过平台限制。'; + } else if (response.status === 408 || response.status >= 500) { + code = 'WORKS_SQUARE_UNAVAILABLE'; + error = '发布服务暂时不可用,请稍后重试。'; + } + await response.body?.cancel().catch(() => undefined); + sendPublishSourceFailure(res, response.status, code, error); +} + function appendOptionalSearchParam(target: URL, source: URLSearchParams, name: string): void { const value = source.get(name); if (value !== null && value.trim()) { @@ -125,6 +179,123 @@ function unwrapPayload(payload: unknown, field: string): unknown { return payload; } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function readNullableStringField( + source: Record, + field: string, +): string | null | undefined { + const value = source[field]; + if (value === undefined) return null; + if (value === null) return null; + return typeof value === 'string' ? value : undefined; +} + +function projectSafeProject(value: unknown): Record | null { + if (!isRecord(value)) return null; + const appId = readOptionalString(value.app_id); + const title = readOptionalString(value.title); + const summary = readOptionalString(value.summary); + if (!appId || !title || !summary) return null; + + const projected: Record = { app_id: appId, title, summary }; + for (const field of [ + 'cover_url', + 'category', + 'age_band', + 'difficulty', + 'status', + 'updated_at', + 'runtime_url', + 'creator_name', + 'buddy_name', + 'buddy_sprite_url', + 'buddy_pose_url', + 'version_name', + 'testing_ask', + 'update_note', + 'remix_note', + 'how_to', + 'learning_note', + 'visual_alt', + ]) { + const fieldValue = value[field]; + if (fieldValue === null || typeof fieldValue === 'string') projected[field] = fieldValue; + } + if (typeof value.playable === 'boolean') projected.playable = value.playable; + return projected; +} + +function projectSafeVersion(value: unknown): Record | null { + if (!isRecord(value)) return null; + const id = readOptionalString(value.id); + const versionName = readOptionalString(value.version_name); + const reviewStatus = readOptionalString(value.review_status); + const changeLog = typeof value.change_log === 'string' ? value.change_log : null; + const createdAt = readOptionalString(value.created_at); + if (!id || !versionName || !reviewStatus || changeLog === null || !createdAt) return null; + + const buildJobId = readNullableStringField(value, 'build_job_id'); + const buildStatus = readNullableStringField(value, 'build_status'); + const buildErrorCode = readNullableStringField(value, 'build_error_code'); + const releaseId = readNullableStringField(value, 'release_id'); + if ( + buildJobId === undefined + || buildStatus === undefined + || buildErrorCode === undefined + || releaseId === undefined + ) return null; + + const projected: Record = { + id, + version_name: versionName, + review_status: reviewStatus, + change_log: changeLog, + build_job_id: buildJobId, + build_status: buildStatus, + build_error_code: buildErrorCode, + release_id: releaseId, + created_at: createdAt, + }; + const rejectionReason = readNullableStringField(value, 'rejection_reason'); + if (rejectionReason !== undefined) projected.rejection_reason = rejectionReason; + return projected; +} + +function projectSafeStatusPayload(value: unknown): Record | null { + if (!isRecord(value) || !Array.isArray(value.versions)) return null; + const project = projectSafeProject(value.project); + if (!project) return null; + const versions = value.versions.map(projectSafeVersion); + if (versions.some((version) => version === null)) return null; + const latestVersion = value.latest_version === null || value.latest_version === undefined + ? null + : projectSafeVersion(value.latest_version); + if (value.latest_version !== null && value.latest_version !== undefined && !latestVersion) { + return null; + } + return { project, latest_version: latestVersion, versions }; +} + +function projectSafeUploadPayload(value: unknown): Record | null { + if (!isRecord(value)) return null; + const versionId = readOptionalString(value.version_id); + const reviewStatus = readOptionalString(value.review_status); + if (!versionId || !reviewStatus) return null; + const projected: Record = { + version_id: versionId, + review_status: reviewStatus, + }; + for (const field of ['build_job_id', 'build_status']) { + const fieldValue = readNullableStringField(value, field); + if (fieldValue === undefined) return null; + projected[field] = fieldValue; + } + return projected; +} + async function handleListProjects(res: ServerResponse, url: URL): Promise { const upstreamUrl = createWorksUrl('/api/projects'); appendOptionalSearchParam(upstreamUrl, url.searchParams, 'q'); @@ -418,7 +589,12 @@ async function handleGetMyProjectStatus( res: ServerResponse, appId: string, ): Promise { - const accessToken = readRequiredHeader(req, 'x-niancode-access-token'); + const accessToken = readOptionalHeader(req, 'x-niancode-access-token') + ?? await getValidWorksSquareAccessToken(); + if (!accessToken) { + sendPublishSourceFailure(res, 401, 'AUTH_REQUIRED', '登录状态已失效,请重新登录。'); + return; + } const response = await proxyAwareFetch( createWorksUrl(`/api/projects/mine/${encodeURIComponent(appId)}/status`).toString(), { @@ -430,11 +606,26 @@ async function handleGetMyProjectStatus( ); if (!response.ok) { - await sendUpstreamError(res, response, `Works Square project status failed (${response.status})`); + await sendPublishSourceUpstreamError( + res, + response, + 'PROJECT_STATUS_UNAVAILABLE', + '暂时无法获取作品处理状态,请稍后重试。', + ); return; } - sendJson(res, response.status, { success: true, status: await readResponsePayload(response) }); + const statusPayload = projectSafeStatusPayload(await readResponsePayload(response)); + if (!statusPayload) { + sendPublishSourceFailure( + res, + 502, + 'PROJECT_STATUS_UNAVAILABLE', + '暂时无法获取作品处理状态,请稍后重试。', + ); + return; + } + sendJson(res, response.status, { success: true, status: statusPayload }); } async function createArchiveFormData( @@ -507,6 +698,242 @@ async function handleUploadProjectVersion( sendJson(res, response.status, { success: true, upload: await readResponsePayload(response) }); } +const SOURCE_PUBLISH_CHANGE_LOG = '通过 Makelore 一键提交'; +const RETRYABLE_SOURCE_UPLOAD_STATUSES = new Set([408, 502, 503, 504]); +const VERSION_FILE_MAX_BYTES = 64 * 1024; + +function createFallbackVersionName(now = new Date()): string { + return `v${now.toISOString().replace(/\D/g, '').slice(0, 14)}`; +} + +async function readAutomaticVersionName(projectPath: string): Promise { + let handle: Awaited> | null = null; + try { + const versionPath = join(projectPath, 'VERSION.md'); + const scanned = await lstat(versionPath); + if (!scanned.isFile() || scanned.isSymbolicLink() || scanned.size > VERSION_FILE_MAX_BYTES) { + return createFallbackVersionName(); + } + handle = await open(versionPath, 'r'); + const before = await handle.stat(); + if ( + !before.isFile() + || before.size !== scanned.size + || before.dev !== scanned.dev + || before.ino !== scanned.ino + || before.mtimeMs !== scanned.mtimeMs + || before.ctimeMs !== scanned.ctimeMs + ) return createFallbackVersionName(); + const versionDocument = await handle.readFile('utf8'); + const after = await handle.stat(); + if ( + after.size !== before.size + || after.dev !== before.dev + || after.ino !== before.ino + || after.mtimeMs !== before.mtimeMs + || after.ctimeMs !== before.ctimeMs + ) return createFallbackVersionName(); + const currentVersion = versionDocument.match(/^Current:\s*(.+)$/m)?.[1]?.trim(); + if (currentVersion && currentVersion.length <= 80) return currentVersion; + } catch { + // Imported projects do not have to contain VERSION.md. + } finally { + await handle?.close().catch(() => undefined); + } + return createFallbackVersionName(); +} + +function createSourceUploadForm( + archiveBytes: Buffer, + archiveName: string, + versionName: string, +): FormData { + const archiveBlob = new Blob([new Uint8Array(archiveBytes)], { type: 'application/zip' }); + const form = new FormData(); + form.set('version_name', versionName); + form.set('change_log', SOURCE_PUBLISH_CHANGE_LOG); + form.set('archive', archiveBlob, archiveName); + return form; +} + +async function uploadSourceProjectVersion(input: { + accessToken: string; + appId: string; + archiveBytes: Buffer; + archiveName: string; + versionName: string; + idempotencyKey: string; +}): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + const response = await proxyAwareFetch( + createWorksUrl(`/api/projects/${encodeURIComponent(input.appId)}/versions/upload`).toString(), + { + method: 'POST', + headers: { + Authorization: `Bearer ${input.accessToken}`, + 'Idempotency-Key': input.idempotencyKey, + }, + body: createSourceUploadForm( + input.archiveBytes, + input.archiveName, + input.versionName, + ), + }, + ); + if (attempt === 0 && RETRYABLE_SOURCE_UPLOAD_STATUSES.has(response.status)) { + await response.body?.cancel().catch(() => undefined); + continue; + } + return response; + } catch (error) { + lastError = error; + if (attempt === 1) throw error; + } + } + throw lastError instanceof Error ? lastError : new Error('Source upload retry exhausted'); +} + +function readProjectMetadata(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const source = value as Record; + try { + const metadata: Record = { + app_id: readRequiredString(source.app_id, 'project.app_id'), + title: readRequiredString(source.title, 'project.title'), + summary: readRequiredString(source.summary, 'project.summary'), + }; + for (const field of ['cover_url', 'category', 'age_band', 'difficulty'] as const) { + const fieldValue = readOptionalString(source[field]); + if (fieldValue) metadata[field] = fieldValue; + } + return metadata; + } catch { + return null; + } +} + +async function handlePublishProjectSource( + req: IncomingMessage, + res: ServerResponse, + ctx: HostApiContext, +): Promise { + const body = await parseJsonBody(req); + const accessToken = await getValidWorksSquareAccessToken(); + if (!accessToken) { + sendPublishSourceFailure(res, 401, 'AUTH_REQUIRED', '登录状态已失效,请重新登录。'); + return; + } + + const projectId = readOptionalString(body.projectId); + const projectMetadata = readProjectMetadata(body.project); + if (!projectId || !projectMetadata) { + sendPublishSourceFailure( + res, + 400, + 'PROJECT_METADATA_INVALID', + '作品信息不完整,请返回项目后重试。', + ); + return; + } + const appId = projectMetadata.app_id as string; + const localProject = (await ctx.opencodeProjectStore.listProjects()) + .find((candidate) => candidate.id === projectId); + if (!localProject) { + sendPublishSourceFailure(res, 404, 'PROJECT_NOT_FOUND', '本地项目不存在,请重新选择项目。'); + return; + } + + const temporaryDirectory = await mkdtemp(join(tmpdir(), 'makelore-publish-')); + const archivePath = join(temporaryDirectory, 'project.zip'); + try { + const packageSummary = await createStaticProjectPackage({ + projectPath: localProject.path, + archivePath, + }); + const archiveBytes = await readFile(archivePath); + const versionName = await readAutomaticVersionName(localProject.path); + const idempotencyKey = `makelore-${randomUUID()}`; + + const createResponse = await proxyAwareFetch(createWorksUrl('/api/projects').toString(), { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(projectMetadata), + }); + if (!createResponse.ok && createResponse.status !== 409) { + await sendPublishSourceUpstreamError( + res, + createResponse, + 'PROJECT_CREATE_REJECTED', + '平台没有接受作品信息,请修改后重试。', + ); + return; + } + await createResponse.body?.cancel().catch(() => undefined); + + if (createResponse.status === 409) { + const ownershipResponse = await proxyAwareFetch( + createWorksUrl(`/api/projects/mine/${encodeURIComponent(appId)}/status`).toString(), + { + method: 'GET', + headers: { Authorization: `Bearer ${accessToken}` }, + }, + ); + if (!ownershipResponse.ok) { + await sendPublishSourceUpstreamError( + res, + ownershipResponse, + 'PROJECT_OWNERSHIP_UNCONFIRMED', + '这个作品 ID 已被占用,请更换后重试。', + ); + return; + } + await ownershipResponse.body?.cancel().catch(() => undefined); + } + + const uploadResponse = await uploadSourceProjectVersion({ + accessToken, + appId, + archiveBytes, + archiveName: packageSummary.archiveName, + versionName, + idempotencyKey, + }); + if (!uploadResponse.ok) { + await sendPublishSourceUpstreamError( + res, + uploadResponse, + 'SOURCE_PACKAGE_REJECTED', + '项目没有通过平台检查,请修复后重试。', + ); + return; + } + + const uploadPayload = projectSafeUploadPayload(await readResponsePayload(uploadResponse)); + if (!uploadPayload) { + sendPublishSourceFailure( + res, + 502, + 'WORKS_SQUARE_UNAVAILABLE', + '发布服务返回结果异常,请稍后重试。', + ); + return; + } + const { archivePath: _archivePath, ...rendererPackageSummary } = packageSummary; + sendJson(res, uploadResponse.status, { + success: true, + package: rendererPackageSummary, + upload: uploadPayload, + }); + } finally { + await rm(temporaryDirectory, { recursive: true, force: true }).catch(() => undefined); + } +} + function createSpeechTranscriptionFormData(body: SpeechTranscriptionInput): FormData { const audioBase64 = readRequiredString(body.audioBase64, 'audioBase64'); const fileName = readOptionalString(body.fileName) ?? 'voice.wav'; @@ -646,6 +1073,11 @@ export async function handleWorksRoutes( return true; } + if (url.pathname === '/api/works/projects/publish-source' && req.method === 'POST') { + await handlePublishProjectSource(req, res, ctx); + return true; + } + if (url.pathname === '/api/works/projects/mine' && req.method === 'GET') { await handleListMyProjects(req, res, url); return true; @@ -678,10 +1110,29 @@ export async function handleWorksRoutes( sendJson(res, 404, { success: false, error: `No route for ${req.method} ${url.pathname}` }); return true; } catch (error) { - sendJson(res, 400, { - success: false, - error: error instanceof Error ? error.message : String(error), - }); + const isProjectSourcePublish = url.pathname === '/api/works/projects/publish-source'; + const isProjectStatus = /^\/api\/works\/projects\/mine\/[^/]+\/status$/.test(url.pathname); + if (isProjectSourcePublish) { + const isPackageError = error instanceof ProjectPackageError; + sendPublishSourceFailure( + res, + isPackageError ? 400 : 503, + isPackageError ? error.code : 'WORKS_SQUARE_UNAVAILABLE', + isPackageError ? error.message : '发布服务暂时不可用,请稍后重试。', + ); + } else if (isProjectStatus) { + sendPublishSourceFailure( + res, + 503, + 'PROJECT_STATUS_UNAVAILABLE', + '暂时无法获取作品处理状态,请稍后重试。', + ); + } else { + sendJson(res, 400, { + success: false, + error: error instanceof Error ? error.message : String(error), + }); + } return true; } } diff --git a/electron/services/project-packager.ts b/electron/services/project-packager.ts new file mode 100644 index 0000000..320da91 --- /dev/null +++ b/electron/services/project-packager.ts @@ -0,0 +1,585 @@ +import { createHash } from 'node:crypto'; +import type { Stats } from 'node:fs'; +import { createRequire } from 'node:module'; +import { + lstat, + mkdir, + open, + readdir, + realpath, + writeFile, +} from 'node:fs/promises'; +import { dirname, isAbsolute, join, relative, sep } from 'node:path'; + +const require = createRequire(import.meta.url); +const AdmZip = require('adm-zip') as typeof import('adm-zip'); + +const GENERATED_MANIFEST = ( + 'schema_version: 1\n' + + 'kind: web\n' + + 'runtime: static\n' + + 'build:\n' + + ' preset: vite\n' + + ' package_manager: npm\n' + + ' entry: index.html\n' +); +const FIXED_ZIP_TIME = new Date(1980, 0, 1, 0, 0, 0, 0); +const FILE_MODE = 0o100644; +const DEFAULT_LIMITS: ProjectPackageLimits = { + maxFiles: 2_000, + maxSourceBytes: 200 * 1024 * 1024, + maxArchiveBytes: 50 * 1024 * 1024, +}; +const REQUIRED_FILE_LIMITS = { + 'package.json': 2 * 1024 * 1024, + 'package-lock.json': 50 * 1024 * 1024, + 'index.html': 10 * 1024 * 1024, +} as const; +const EXCLUDED_DIRECTORY_NAMES = new Set([ + '.cache', + '.git', + '.gnupg', + '.idea', + '.kube', + '.next', + '.niancode', + '.opencode', + '.project-docs', + '.turbo', + '.vite', + '.vscode', + '.aws', + '.docker', + '.ssh', + 'build', + 'coverage', + 'deploy', + 'dist', + 'knowledge', + 'node_modules', + 'secrets', +]); +const EXCLUDED_FILE_NAMES = new Set([ + '.dockerignore', + '.ds_store', + '.git-credentials', + '.netrc', + '.npmrc', + 'art_guide.md', + 'asset_plan.md', + 'credentials.json', + 'debug.log', + 'dockerfile', + 'docker-compose.yaml', + 'docker-compose.yml', + 'findings.md', + 'gdd.md', + 'nginx.conf', + 'niancode.yml', + '_netrc', + 'id_dsa', + 'id_ecdsa', + 'id_ed25519', + 'id_rsa', + 'product_overview.md', + 'progress.md', + 'release.md', + 'task_plan.md', + 'tasks.md', + 'thumbs.db', + 'version.md', + 'works-cloud-deploy.json', + 'works-deploy-check.json', + 'works-publish.json', + '部署报告.md', +]); +const EXCLUDED_FILE_SUFFIXES = [ + '.crt', + '.jks', + '.key', + '.keystore', + '.log', + '.p12', + '.pem', + '.pfx', + '.p8', + '.ppk', + '.tfstate', + '.tfvars', +] as const; + +export type ProjectPackageLimits = { + maxFiles: number; + maxSourceBytes: number; + maxArchiveBytes: number; +}; + +export type StaticProjectPackageSummary = { + archivePath: string; + archiveName: string; + sha256: string; + fileCount: number; + sourceBytes: number; + archiveBytes: number; + excludedCount: number; + excludedPaths: string[]; + manifest: { + schema_version: 1; + kind: 'web'; + runtime: 'static'; + build: { + preset: 'vite'; + package_manager: 'npm'; + entry: 'index.html'; + }; + }; +}; + +export class ProjectPackageError extends Error { + readonly code: string; + + constructor(code: string, message: string) { + super(message); + this.name = 'ProjectPackageError'; + this.code = code; + } +} + +type PackageFile = { + absolutePath: string; + archivePath: string; + size: number; + device: number; + inode: number; + modifiedAtMs: number; + changedAtMs: number; + directoryChain: DirectoryIdentity[]; +}; + +type DirectoryIdentity = { + path: string; + realPath: string; + device: number; + inode: number; +}; + +type PackageScan = { + files: PackageFile[]; + sourceBytes: number; + excludedCount: number; + excludedPaths: string[]; +}; + +export async function createStaticProjectPackage(input: { + projectPath: string; + archivePath: string; + limits?: ProjectPackageLimits; +}): Promise { + const limits = input.limits ?? DEFAULT_LIMITS; + const projectPath = await resolveProjectDirectory(input.projectPath); + await validateViteProject(projectPath, limits); + const scan = await scanProject(projectPath, limits); + const manifestBytes = Buffer.from(GENERATED_MANIFEST, 'utf8'); + const sourceBytes = scan.sourceBytes + manifestBytes.length; + if (sourceBytes > limits.maxSourceBytes) { + throw new ProjectPackageError( + 'PROJECT_TOO_LARGE', + `项目文件总大小超过 ${formatBytes(limits.maxSourceBytes)} 限制`, + ); + } + if (scan.files.length + 1 > limits.maxFiles) { + throw new ProjectPackageError( + 'PROJECT_TOO_MANY_FILES', + `项目文件数超过 ${limits.maxFiles} 个限制`, + ); + } + + const zip = new AdmZip(); + const filesByArchivePath = new Map(scan.files.map((file) => [file.archivePath, file])); + const archivePaths = ['niancode.yml', ...filesByArchivePath.keys()] + .sort((left, right) => left.localeCompare(right, 'en')); + for (const archivePath of archivePaths) { + const data = archivePath === 'niancode.yml' + ? manifestBytes + : await readStableFile(projectPath, filesByArchivePath.get(archivePath)!); + zip.addFile(archivePath, data, '', FILE_MODE); + const entry = zip.getEntry(archivePath); + if (entry) entry.header.time = FIXED_ZIP_TIME; + } + + const archiveBytes = zip.toBuffer(); + if (archiveBytes.length > limits.maxArchiveBytes) { + throw new ProjectPackageError( + 'ARCHIVE_TOO_LARGE', + `压缩包超过 ${formatBytes(limits.maxArchiveBytes)} 限制`, + ); + } + await mkdir(dirname(input.archivePath), { recursive: true }); + await writeFile(input.archivePath, archiveBytes); + + return { + archivePath: input.archivePath, + archiveName: 'project.zip', + sha256: createHash('sha256').update(archiveBytes).digest('hex'), + fileCount: archivePaths.length, + sourceBytes, + archiveBytes: archiveBytes.length, + excludedCount: scan.excludedCount, + excludedPaths: scan.excludedPaths, + manifest: { + schema_version: 1, + kind: 'web', + runtime: 'static', + build: { + preset: 'vite', + package_manager: 'npm', + entry: 'index.html', + }, + }, + }; +} + +async function resolveProjectDirectory(projectPath: string): Promise { + try { + const resolved = await realpath(projectPath); + const stats = await lstat(resolved); + if (!stats.isDirectory()) throw new Error('not a directory'); + return resolved; + } catch { + throw new ProjectPackageError('PROJECT_NOT_FOUND', '本地项目目录不存在或无法读取'); + } +} + +async function validateViteProject( + projectPath: string, + limits: ProjectPackageLimits, +): Promise { + const packageJson = await readRequiredJson(projectPath, 'package.json', limits); + const packageLock = await readRequiredJson(projectPath, 'package-lock.json', limits); + const indexHtml = await readRequiredFile(projectPath, 'index.html', limits); + const dependencies = isRecord(packageJson.dependencies) ? packageJson.dependencies : {}; + const devDependencies = isRecord(packageJson.devDependencies) ? packageJson.devDependencies : {}; + const viteVersion = devDependencies.vite ?? dependencies.vite; + if (typeof viteVersion !== 'string' || !viteVersion.trim()) { + throw new ProjectPackageError('VITE_NOT_DECLARED', 'package.json 没有声明 Vite 依赖'); + } + if ( + packageJson.packageManager !== undefined + && (typeof packageJson.packageManager !== 'string' || !packageJson.packageManager.startsWith('npm@')) + ) { + throw new ProjectPackageError( + 'PACKAGE_MANAGER_UNSUPPORTED', + '当前仅支持带 package-lock.json 的 npm 项目', + ); + } + if ( + !Number.isInteger(packageLock.lockfileVersion) + || ![2, 3].includes(packageLock.lockfileVersion as number) + ) { + throw new ProjectPackageError( + 'LOCKFILE_UNSUPPORTED', + 'package-lock.json 必须使用 lockfileVersion 2 或 3', + ); + } + if (!indexHtml.toString('utf8').trim()) { + throw new ProjectPackageError('INDEX_HTML_EMPTY', 'index.html 不能为空'); + } +} + +async function readRequiredJson( + projectPath: string, + filename: 'package.json' | 'package-lock.json', + limits: ProjectPackageLimits, +): Promise> { + const content = await readRequiredFile(projectPath, filename, limits); + try { + const value = JSON.parse(content.toString('utf8')) as unknown; + if (!isRecord(value)) throw new Error('not an object'); + return value; + } catch { + throw new ProjectPackageError( + 'PROJECT_FILE_INVALID', + `${filename} 不是有效的 JSON 对象`, + ); + } +} + +async function readRequiredFile( + projectPath: string, + filename: keyof typeof REQUIRED_FILE_LIMITS, + limits: ProjectPackageLimits, +): Promise { + try { + const filePath = join(projectPath, filename); + const stats = await lstat(filePath); + if (!stats.isFile() || stats.isSymbolicLink()) throw new Error('not a regular file'); + const maxBytes = Math.min(limits.maxSourceBytes, REQUIRED_FILE_LIMITS[filename]); + if (stats.size > maxBytes) { + throw new ProjectPackageError( + 'PROJECT_TOO_LARGE', + `${filename} 超过 ${formatBytes(maxBytes)} 限制`, + ); + } + const rootIdentity = await readSafeDirectoryIdentity(projectPath, projectPath); + return await readStableFile(projectPath, { + absolutePath: filePath, + archivePath: filename, + size: stats.size, + device: stats.dev, + inode: stats.ino, + modifiedAtMs: stats.mtimeMs, + changedAtMs: stats.ctimeMs, + directoryChain: [rootIdentity], + }); + } catch (error) { + if (error instanceof ProjectPackageError) throw error; + throw new ProjectPackageError('PROJECT_FILE_MISSING', `项目根目录缺少 ${filename}`); + } +} + +async function scanProject( + projectPath: string, + limits: ProjectPackageLimits, +): Promise { + const files: PackageFile[] = []; + const excludedPaths: string[] = []; + let excludedCount = 0; + let sourceBytes = 0; + + async function walk( + directory: string, + parentChain: DirectoryIdentity[], + ): Promise { + await assertDirectoryIdentityChain(projectPath, parentChain); + const before = await readSafeDirectoryIdentity(projectPath, directory); + const directoryChain = [...parentChain, before]; + const entries = await readdir(directory, { withFileTypes: true }); + await assertDirectoryIdentityChain(projectPath, directoryChain); + entries.sort((left, right) => left.name.localeCompare(right.name, 'en')); + for (const entry of entries) { + await assertDirectoryIdentityChain(projectPath, directoryChain); + const absolutePath = join(directory, entry.name); + const archivePath = toArchivePath(relative(projectPath, absolutePath)); + const isDirectory = entry.isDirectory(); + if (isExcludedPath(archivePath, entry.name, isDirectory)) { + excludedCount += 1; + if (excludedPaths.length < 100) { + excludedPaths.push(isDirectory ? `${archivePath}/` : archivePath); + } + await assertDirectoryIdentityChain(projectPath, directoryChain); + continue; + } + if (entry.isSymbolicLink()) { + throw new ProjectPackageError( + 'PROJECT_SYMLINK_UNSUPPORTED', + `项目包含不支持的符号链接:${archivePath}`, + ); + } + validateArchivePath(archivePath); + if (isDirectory) { + await walk(absolutePath, directoryChain); + await assertDirectoryIdentityChain(projectPath, directoryChain); + continue; + } + if (!entry.isFile()) { + throw new ProjectPackageError( + 'PROJECT_FILE_UNSUPPORTED', + `项目包含不支持的文件类型:${archivePath}`, + ); + } + const stats = await lstat(absolutePath); + if (!stats.isFile() || stats.isSymbolicLink()) { + throw new ProjectPackageError( + 'PROJECT_CHANGED_DURING_PACKAGING', + `打包过程中项目文件发生变化:${archivePath}`, + ); + } + sourceBytes += stats.size; + if (sourceBytes > limits.maxSourceBytes) { + throw new ProjectPackageError( + 'PROJECT_TOO_LARGE', + `项目文件总大小超过 ${formatBytes(limits.maxSourceBytes)} 限制`, + ); + } + files.push({ + absolutePath, + archivePath, + size: stats.size, + device: stats.dev, + inode: stats.ino, + modifiedAtMs: stats.mtimeMs, + changedAtMs: stats.ctimeMs, + directoryChain, + }); + if (files.length + 1 > limits.maxFiles) { + throw new ProjectPackageError( + 'PROJECT_TOO_MANY_FILES', + `项目文件数超过 ${limits.maxFiles} 个限制`, + ); + } + await assertDirectoryIdentityChain(projectPath, directoryChain); + } + await assertDirectoryIdentityChain(projectPath, directoryChain); + } + + await walk(projectPath, []); + return { + files, + sourceBytes, + excludedCount, + excludedPaths: excludedPaths.sort((left, right) => left.localeCompare(right, 'en')), + }; +} + +async function readSafeDirectoryIdentity( + projectPath: string, + directory: string, +): Promise { + try { + const stats = await lstat(directory); + if (!stats.isDirectory() || stats.isSymbolicLink()) { + throw new ProjectPackageError( + 'PROJECT_SYMLINK_UNSUPPORTED', + `项目包含不支持的目录链接:${toArchivePath(relative(projectPath, directory)) || '.'}`, + ); + } + const resolvedDirectory = await realpath(directory); + const relativePath = relative(projectPath, resolvedDirectory); + if (relativePath === '..' || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) { + throw new ProjectPackageError( + 'PROJECT_PATH_UNSUPPORTED', + '项目目录指向了项目范围之外的位置', + ); + } + return { + path: directory, + device: stats.dev, + inode: stats.ino, + realPath: resolvedDirectory, + }; + } catch (error) { + if (error instanceof ProjectPackageError) throw error; + throw new ProjectPackageError( + 'PROJECT_CHANGED_DURING_PACKAGING', + `打包过程中项目目录发生变化:${toArchivePath(relative(projectPath, directory)) || '.'}`, + ); + } +} + +async function assertDirectoryIdentityChain( + projectPath: string, + directoryChain: DirectoryIdentity[], +): Promise { + for (const expected of directoryChain) { + const current = await readSafeDirectoryIdentity(projectPath, expected.path); + if ( + current.device !== expected.device + || current.inode !== expected.inode + || current.realPath !== expected.realPath + ) { + throw new ProjectPackageError( + 'PROJECT_CHANGED_DURING_PACKAGING', + `打包过程中项目目录发生变化:${toArchivePath(relative(projectPath, expected.path)) || '.'}`, + ); + } + } +} + +function isExcludedPath(archivePath: string, basename: string, isDirectory: boolean): boolean { + const lowerName = basename.toLowerCase(); + if (isDirectory) return EXCLUDED_DIRECTORY_NAMES.has(lowerName); + if (lowerName.startsWith('.env')) return true; + if (EXCLUDED_FILE_NAMES.has(lowerName)) return true; + const compactName = lowerName.replace(/[^a-z0-9]/g, ''); + if ( + lowerName.endsWith('.json') + && ( + compactName.includes('serviceaccount') + || compactName.includes('firebaseadmin') + || compactName.includes('applicationdefaultcredentials') + ) + ) { + return true; + } + if (lowerName.includes('.tfstate') || lowerName.includes('.tfvars')) return true; + return EXCLUDED_FILE_SUFFIXES.some((suffix) => lowerName.endsWith(suffix)) + || archivePath.toLowerCase().startsWith('works-'); +} + +function validateArchivePath(archivePath: string): void { + const parts = archivePath.split('/'); + if ( + !archivePath + || archivePath.includes('\\') + || parts.some((part) => !part + || part === '.' + || part === '..' + || part.includes(':') + || part.endsWith(' ') + || part.endsWith('.')) + ) { + throw new ProjectPackageError( + 'PROJECT_PATH_UNSUPPORTED', + `项目包含不支持的文件路径:${archivePath}`, + ); + } +} + +async function readStableFile(projectPath: string, file: PackageFile): Promise { + await assertDirectoryIdentityChain(projectPath, file.directoryChain); + let handle: Awaited>; + try { + handle = await open(file.absolutePath, 'r'); + } catch { + throw new ProjectPackageError( + 'PROJECT_CHANGED_DURING_PACKAGING', + `打包过程中项目文件发生变化:${file.archivePath}`, + ); + } + try { + await assertDirectoryIdentityChain(projectPath, file.directoryChain); + assertStableFileStats(file, await handle.stat()); + await assertDirectoryIdentityChain(projectPath, file.directoryChain); + const data = await handle.readFile(); + await assertDirectoryIdentityChain(projectPath, file.directoryChain); + assertStableFileStats(file, await handle.stat()); + await assertDirectoryIdentityChain(projectPath, file.directoryChain); + if (data.length !== file.size) { + throw new ProjectPackageError( + 'PROJECT_CHANGED_DURING_PACKAGING', + `打包过程中项目文件发生变化:${file.archivePath}`, + ); + } + return data; + } finally { + await handle.close(); + } +} + +function assertStableFileStats(file: PackageFile, stats: Stats): void { + if ( + !stats.isFile() + || stats.size !== file.size + || stats.dev !== file.device + || stats.ino !== file.inode + || stats.mtimeMs !== file.modifiedAtMs + || stats.ctimeMs !== file.changedAtMs + ) { + throw new ProjectPackageError( + 'PROJECT_CHANGED_DURING_PACKAGING', + `打包过程中项目文件发生变化:${file.archivePath}`, + ); + } +} + +function toArchivePath(value: string): string { + return sep === '/' ? value : value.split(sep).join('/'); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function formatBytes(value: number): string { + return value % (1024 * 1024) === 0 ? `${value / (1024 * 1024)}MB` : `${value} 字节`; +} diff --git a/src/components/works/ProjectPublishAction.tsx b/src/components/works/ProjectPublishAction.tsx new file mode 100644 index 0000000..886cdcc --- /dev/null +++ b/src/components/works/ProjectPublishAction.tsx @@ -0,0 +1,199 @@ +import { useEffect, useRef, useState } from 'react'; +import { CheckCircle2, Loader2, UploadCloud, XCircle } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + createDefaultWorksAppId, + describeWorksPublishFailure, + type WorksPublishFailure, +} from '@/lib/works-project-publish'; +import { + fetchCurrentWorksProjectStatus, + publishWorksProjectSource, + type WorksProjectMetadataInput, +} from '@/lib/works-square'; +import type { OpencodeProject } from '@/types/opencode'; + +type PublishPhase = 'idle' | 'submitting' | 'polling' | 'succeeded' | 'failed' | 'uncertain'; + +type BuildVersion = { + id: string; + build_status?: string | null; + build_error_code?: string | null; +}; + +type ProjectPublishActionProps = { + project: OpencodeProject; +}; + +const BUILD_POLL_INTERVAL_MS = 2_000; +const BUILD_POLL_ATTEMPTS = 300; + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => { + window.setTimeout(resolve, milliseconds); + }); +} + +function readErrorCode(error: unknown): string | null { + if (!error || typeof error !== 'object' || !('code' in error)) return null; + const value = (error as { code?: unknown }).code; + return typeof value === 'string' ? value : null; +} + +function readErrorStatus(error: unknown): number | undefined { + if (!error || typeof error !== 'object') return undefined; + const value = 'statusCode' in error + ? (error as { statusCode?: unknown }).statusCode + : ('status' in error ? (error as { status?: unknown }).status : undefined); + return typeof value === 'number' ? value : undefined; +} + +function failureFromVersion(version: BuildVersion): WorksPublishFailure { + const code = version.build_error_code + ?? (version.build_status === 'cancelled' ? 'BUILD_CANCELLED' : 'BUILD_FAILED'); + return describeWorksPublishFailure(code); +} + +function buttonLabel(phase: PublishPhase): string { + if (phase === 'submitting') return '正在检查并提交…'; + if (phase === 'polling') return '正在等待云端检查…'; + if (phase === 'succeeded') return '已提交,等待运营审核'; + if (phase === 'uncertain') return '已提交,请稍后查看'; + if (phase === 'failed') return '重新提交审核'; + return '一键提交审核'; +} + +export function ProjectPublishAction({ project }: ProjectPublishActionProps) { + const [phase, setPhase] = useState('idle'); + const [failure, setFailure] = useState(null); + const pollGeneration = useRef(0); + + useEffect(() => () => { + pollGeneration.current += 1; + }, []); + + const appId = createDefaultWorksAppId(project); + const title = project.name.trim().slice(0, 160) || 'Makelore 作品'; + const metadata: WorksProjectMetadataInput = { + app_id: appId, + title, + summary: `${title},由 Makelore 创建的作品。`, + cover_url: null, + category: 'web', + age_band: null, + difficulty: null, + }; + + async function pollBuildStatus(versionId: string, generation: number): Promise { + for (let attempt = 0; attempt < BUILD_POLL_ATTEMPTS; attempt += 1) { + await delay(BUILD_POLL_INTERVAL_MS); + if (pollGeneration.current !== generation) return; + + let status; + try { + status = await fetchCurrentWorksProjectStatus(appId); + } catch { + if (pollGeneration.current !== generation) return; + setPhase('uncertain'); + setFailure(describeWorksPublishFailure('BUILD_STATUS_UNAVAILABLE')); + return; + } + if (pollGeneration.current !== generation) return; + + const versions = status.versions as BuildVersion[]; + const latestVersion = status.latest_version as BuildVersion | null; + const version = versions.find((item) => item.id === versionId) + ?? (latestVersion?.id === versionId ? latestVersion : null); + if (!version) continue; + + if (version.build_status === 'succeeded') { + setPhase('succeeded'); + setFailure(null); + return; + } + if (version.build_status === 'failed' || version.build_status === 'cancelled') { + setPhase('failed'); + setFailure(failureFromVersion(version)); + return; + } + } + + if (pollGeneration.current === generation) { + setPhase('uncertain'); + setFailure(describeWorksPublishFailure('BUILD_STATUS_TIMEOUT')); + } + } + + async function handleSubmit(): Promise { + pollGeneration.current += 1; + const generation = pollGeneration.current; + setFailure(null); + setPhase('submitting'); + + try { + const result = await publishWorksProjectSource({ + projectId: project.id, + project: metadata, + }); + if (pollGeneration.current !== generation) return; + setPhase('polling'); + void pollBuildStatus(result.upload.version_id, generation); + } catch (error) { + if (pollGeneration.current !== generation) return; + setPhase('failed'); + setFailure(describeWorksPublishFailure(readErrorCode(error), readErrorStatus(error))); + } + } + + const locked = phase === 'submitting' + || phase === 'polling' + || phase === 'succeeded' + || phase === 'uncertain'; + const busy = phase === 'submitting' || phase === 'polling'; + + return ( +
+ + + {phase === 'submitting' || phase === 'polling' || phase === 'succeeded' ? ( +

+ {phase === 'submitting' + ? 'Makelore 正在自动检查、打包并提交项目,不需要准备 ZIP。' + : phase === 'polling' + ? '项目已提交,正在等待云端构建与自动浏览器检查。' + : '已提交,等待运营审核。审核通过后会直接发布。'} +

+ ) : null} + + {failure ? ( +
+

{failure.title}

+

{failure.reason}

+

下一步:{failure.nextStep}

+
+ ) : null} +
+ ); +} diff --git a/src/lib/works-project-publish.ts b/src/lib/works-project-publish.ts new file mode 100644 index 0000000..5c94e3f --- /dev/null +++ b/src/lib/works-project-publish.ts @@ -0,0 +1,255 @@ +export const WORKS_APP_ID_PATTERN = /^[a-z0-9][a-z0-9-]{2,79}$/; + +export type WorksPublishFailure = { + title: string; + reason: string; + nextStep: string; +}; + +const PUBLISH_FAILURES: Record = { + AUTH_REQUIRED: { + title: '需要重新登录', + reason: '当前登录状态已失效,无法提交作品。', + nextStep: '重新登录 Makelore 后再提交。', + }, + PROJECT_NOT_FOUND: { + title: '找不到项目文件夹', + reason: 'Makelore 无法读取当前项目。', + nextStep: '重新打开项目,确认文件夹仍然存在后再提交。', + }, + PROJECT_FILE_MISSING: { + title: '项目文件不完整', + reason: '发布所需的基础文件缺失。', + nextStep: '请让开发助手补齐 package.json、package-lock.json 和 index.html 后重试。', + }, + PROJECT_FILE_INVALID: { + title: '项目配置无法读取', + reason: 'package.json 或 package-lock.json 格式不正确。', + nextStep: '请让开发助手修复项目配置文件后重试。', + }, + VITE_NOT_DECLARED: { + title: '项目还不是可发布的 Vite 工程', + reason: 'package.json 中没有找到 Vite 依赖。', + nextStep: '请让开发助手补齐 Vite 配置并确认本地可以启动。', + }, + PACKAGE_MANAGER_UNSUPPORTED: { + title: '项目缺少 npm 锁文件', + reason: '当前自动提交只支持带 package-lock.json 的 npm 项目。', + nextStep: '请让开发助手使用 npm 生成并提交 package-lock.json。', + }, + LOCKFILE_UNSUPPORTED: { + title: 'npm 锁文件版本过旧', + reason: '当前 package-lock.json 不能用于安全构建。', + nextStep: '请让开发助手使用新版 npm 重新生成 package-lock.json。', + }, + INDEX_HTML_EMPTY: { + title: '项目入口是空的', + reason: '根目录的 index.html 没有可用内容。', + nextStep: '请让开发助手修复页面入口,并确认本地可以打开。', + }, + PROJECT_TOO_LARGE: { + title: '项目文件太大', + reason: '源码总大小超过平台允许范围。', + nextStep: '删除不需要的大文件和生成文件后重新提交。', + }, + ARCHIVE_TOO_LARGE: { + title: '项目压缩包太大', + reason: '安全打包后的文件仍超过平台限制。', + nextStep: '移除不需要的图片、音视频或其他大文件后重试。', + }, + PROJECT_TOO_MANY_FILES: { + title: '项目文件太多', + reason: '源码文件数量超过平台允许范围。', + nextStep: '清理缓存、生成文件和不再使用的素材后重试。', + }, + PROJECT_SYMLINK_UNSUPPORTED: { + title: '项目包含不安全的链接文件', + reason: '自动提交不会跟随符号链接读取其他位置的文件。', + nextStep: '将需要的文件复制到项目内,并移除符号链接后重试。', + }, + PROJECT_FILE_UNSUPPORTED: { + title: '项目包含不支持的文件', + reason: '部分文件类型无法安全打包。', + nextStep: '请让开发助手移除特殊文件后重试。', + }, + PROJECT_PATH_UNSUPPORTED: { + title: '项目包含不支持的文件名', + reason: '部分路径无法在云端安全解压。', + nextStep: '请让开发助手重命名异常文件或文件夹后重试。', + }, + PROJECT_CHANGED_DURING_PACKAGING: { + title: '项目正在发生变化', + reason: '打包时有文件仍在写入,无法保证提交内容完整。', + nextStep: '等待开发助手或其他程序保存完成后重新提交。', + }, + PROJECT_SUBMISSION_CONFLICT: { + title: '这个作品已有版本在处理中', + reason: '平台正在构建或审核上一版,暂时不能重复提交。', + nextStep: '等待当前版本完成审核或被驳回后再提交。', + }, + PROJECT_CREATE_REJECTED: { + title: '作品信息没有通过检查', + reason: '平台没有接受自动生成的作品信息。', + nextStep: '请联系运营人员检查作品信息。', + }, + PROJECT_OWNERSHIP_UNCONFIRMED: { + title: '作品标识已被占用', + reason: '自动生成的作品标识不属于当前账号。', + nextStep: '请联系运营人员处理作品归属。', + }, + SOURCE_PACKAGE_REJECTED: { + title: '项目没有通过平台检查', + reason: '云端发现项目包不符合安全构建要求。', + nextStep: '请让开发助手检查依赖、入口文件和敏感文件后重试。', + }, + PUBLISH_FORBIDDEN: { + title: '当前账号不能发布这个作品', + reason: '平台没有确认当前账号的作品权限。', + nextStep: '确认登录了正确账号;如果仍然失败,请联系运营人员。', + }, + WORKS_SQUARE_UNAVAILABLE: { + title: '发布服务暂时不可用', + reason: '平台当前没有完成这次提交。', + nextStep: '请稍后重新提交;自动重试不会创建重复版本。', + }, + BUILD_STATUS_UNAVAILABLE: { + title: '暂时无法读取构建进度', + reason: '作品已经提交,但 Makelore 暂时没有拿到最新状态。', + nextStep: '稍后重新打开项目查看,不需要重复提交。', + }, + BUILD_STATUS_TIMEOUT: { + title: '构建时间比平时更长', + reason: '作品仍可能在云端处理中。', + nextStep: '稍后重新打开项目查看,不需要重复提交。', + }, + DEPENDENCY_PREFETCH_FAILED: { + title: '暂时无法下载项目依赖', + reason: '云端没有成功准备 package-lock.json 中的依赖。', + nextStep: '请检查 package-lock.json 是否已提交,并确认依赖名称和版本有效后重试。', + }, + DEPENDENCY_PREFETCH_TIMEOUT: { + title: '下载项目依赖超时', + reason: '云端准备依赖的时间超过限制。', + nextStep: '稍后重试;如果持续失败,请让开发助手精简依赖。', + }, + BUILD_COMMAND_FAILED: { + title: '项目没有构建成功', + reason: '云端执行项目构建时发现代码或配置错误。', + nextStep: '请让开发助手运行 npm run build,修复错误后提交新版本。', + }, + BUILD_TIMEOUT: { + title: '项目构建超时', + reason: '云端构建时间超过平台限制。', + nextStep: '请让开发助手减少构建步骤或过大的依赖后重试。', + }, + BUILD_SANDBOX_UNAVAILABLE: { + title: '云端构建环境暂时不可用', + reason: '平台暂时无法启动安全构建环境。', + nextStep: '稍后重新提交,无需修改项目。', + }, + BUILD_PIPELINE_MISMATCH: { + title: '项目发布方式不匹配', + reason: '当前提交不是平台支持的 Makelore 静态项目格式。', + nextStep: '请从当前项目重新点击一键提交,不要手工修改发布包。', + }, + BUILD_PIPELINE_UNSUPPORTED: { + title: '这个项目暂不支持自动发布', + reason: '平台没有找到适合当前项目类型的安全构建方式。', + nextStep: '请联系运营人员确认项目类型。', + }, + VERSION_NOT_FOUND: { + title: '平台没有找到本次提交', + reason: '构建服务无法读取刚刚上传的版本。', + nextStep: '请重新提交;如果持续失败,请联系运营人员。', + }, + SOURCE_DIGEST_MISMATCH: { + title: '上传内容校验失败', + reason: '平台收到的项目内容与提交时不一致。', + nextStep: '重新提交一次;如果持续失败,请联系运营人员。', + }, + OUTPUT_MISSING: { + title: '没有生成可运行页面', + reason: '构建完成后没有找到发布所需的页面文件。', + nextStep: '请让开发助手检查 Vite 输出目录和 index.html。', + }, + OUTPUT_INVALID: { + title: '生成的页面无法发布', + reason: '构建输出缺少必要文件或包含不安全内容。', + nextStep: '请让开发助手检查 Vite 构建输出后重试。', + }, + OUTPUT_LIMIT_EXCEEDED: { + title: '生成的页面文件太大', + reason: '构建结果超过平台允许范围。', + nextStep: '请让开发助手压缩素材、拆分资源或删除无用输出。', + }, + RELEASE_STORE_FAILED: { + title: '平台暂时无法保存发布文件', + reason: '作品已经构建,但发布存储当前不可用。', + nextStep: '稍后重新提交,无需修改项目。', + }, + BUILD_STALE: { + title: '构建任务已中断', + reason: '云端构建任务长时间没有继续运行。', + nextStep: '重新提交一次;如果持续发生,请联系运营人员。', + }, + BROWSER_SMOKE_FAILED: { + title: '自动打开作品时发现问题', + reason: '云端浏览器检测到了白屏、运行错误或资源加载失败。', + nextStep: '请让开发助手运行 npm run build,并在浏览器中检查构建结果后重试。', + }, + BROWSER_SMOKE_TIMEOUT: { + title: '自动打开作品超时', + reason: '作品在规定时间内没有完成加载。', + nextStep: '请让开发助手检查首屏资源大小和启动逻辑,优化后重新提交。', + }, + BROWSER_SMOKE_UNAVAILABLE: { + title: '自动验收环境暂时不可用', + reason: '平台暂时无法启动用于检查作品的安全浏览器。', + nextStep: '稍后重新提交,无需修改项目。', + }, + BUILD_CANCELLED: { + title: '构建已取消', + reason: '平台没有继续处理这个版本。', + nextStep: '确认没有其他版本正在处理后重新提交。', + }, + BUILD_FAILED: { + title: '项目没有构建成功', + reason: '云端未能生成可审核的作品。', + nextStep: '请让开发助手先确认 npm run build 成功,再提交新版本。', + }, +}; + +export function describeWorksPublishFailure( + code: string | null, + statusCode?: number, +): WorksPublishFailure { + const knownCode = code?.trim().toUpperCase(); + if (knownCode && PUBLISH_FAILURES[knownCode]) { + return PUBLISH_FAILURES[knownCode]; + } + if (statusCode === 401) return PUBLISH_FAILURES.AUTH_REQUIRED; + if (statusCode === 403) return PUBLISH_FAILURES.PUBLISH_FORBIDDEN; + if (statusCode === 409) return PUBLISH_FAILURES.PROJECT_SUBMISSION_CONFLICT; + if (statusCode === 413) return PUBLISH_FAILURES.ARCHIVE_TOO_LARGE; + if (statusCode !== undefined && statusCode >= 500) { + return PUBLISH_FAILURES.WORKS_SQUARE_UNAVAILABLE; + } + if (statusCode === 400 || statusCode === 422) { + return PUBLISH_FAILURES.SOURCE_PACKAGE_REJECTED; + } + return { + title: '提交没有完成', + reason: 'Makelore 没有收到可确认的提交结果。', + nextStep: '请稍后重新提交;如果持续失败,请联系运营人员。', + }; +} + +export function createDefaultWorksAppId(project: { id: string; name?: string }): string { + const normalize = (value: string) => value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + const fromId = normalize(project.id); + const fallback = `makelore-${fromId || 'project'}`.slice(0, 80).replace(/-+$/g, ''); + return WORKS_APP_ID_PATTERN.test(fallback) ? fallback : 'makelore-project'; +} diff --git a/src/lib/works-square.ts b/src/lib/works-square.ts index 5f80aeb..54314c5 100644 --- a/src/lib/works-square.ts +++ b/src/lib/works-square.ts @@ -82,6 +82,8 @@ export type WorksProjectMetadataInput = { export type WorksProjectVersionUpload = { version_id: string; review_status: string; + build_job_id?: string | null; + build_status?: string | null; }; export type WorksProjectVersion = { @@ -90,6 +92,10 @@ export type WorksProjectVersion = { review_status: string; change_log: string; build_job_id: string | null; + build_status: string | null; + build_error_code: string | null; + release_id: string | null; + rejection_reason?: string | null; created_at: string; }; @@ -112,6 +118,36 @@ export type WorksProjectUploadInput = { zipFilePath: string; }; +export type WorksStaticPackageSummary = { + archiveName: string; + sha256: string; + fileCount: number; + sourceBytes: number; + archiveBytes: number; + excludedCount: number; + excludedPaths: string[]; + manifest: { + schema_version: 1; + kind: 'web'; + runtime: 'static'; + build: { + preset: 'vite'; + package_manager: 'npm'; + entry: 'index.html'; + }; + }; +}; + +export type WorksProjectSourcePublishInput = { + projectId: string; + project: WorksProjectMetadataInput; +}; + +export type WorksProjectSourcePublishResult = { + package: WorksStaticPackageSummary; + upload: WorksProjectVersionUpload; +}; + export type WorksSpeechTranscription = { text: string; model: string; @@ -152,6 +188,7 @@ export type PlazaCard = { type WorksActionResponse = { success: boolean; status?: number; + code?: string; error?: string; } & { [key in TField]?: TValue; @@ -177,11 +214,13 @@ type FetchMyWorksProjectsInput = { export class WorksSquareApiError extends Error { readonly statusCode?: number; + readonly code?: string; - constructor(message: string, statusCode?: number) { + constructor(message: string, statusCode?: number, code?: string) { super(message); this.name = 'WorksSquareApiError'; this.statusCode = statusCode; + this.code = code; } } @@ -191,7 +230,7 @@ function assertSuccess( fallback: string, ): TValue { if (!response.success || response[field] === undefined) { - throw new WorksSquareApiError(response.error || fallback, response.status); + throw new WorksSquareApiError(response.error || fallback, response.status, response.code); } return response[field] as TValue; } @@ -366,6 +405,15 @@ export async function fetchMyWorksProjectStatus( return assertSuccess(response, 'status', 'Failed to load Works Square project status'); } +export async function fetchCurrentWorksProjectStatus( + appId: string, +): Promise { + const response = await hostApiFetch>( + `/api/works/projects/mine/${encodeURIComponent(appId)}/status`, + ); + return assertSuccess(response, 'status', 'Failed to load Works Square project status'); +} + export async function uploadWorksProjectZip( input: WorksProjectUploadInput, ): Promise { @@ -385,6 +433,33 @@ export async function uploadWorksProjectZip( return assertSuccess(response, 'upload', 'Failed to upload Works Square project zip'); } +export async function publishWorksProjectSource( + input: WorksProjectSourcePublishInput, +): Promise { + const response = await hostApiFetch<{ + success: boolean; + status?: number; + code?: string; + error?: string; + package?: WorksStaticPackageSummary; + upload?: WorksProjectVersionUpload; + }>('/api/works/projects/publish-source', { + method: 'POST', + body: JSON.stringify({ + projectId: input.projectId, + project: input.project, + }), + }); + if (!response.success || !response.package || !response.upload) { + throw new WorksSquareApiError( + response.error || 'Failed to package and upload Works Square project', + response.status, + response.code, + ); + } + return { package: response.package, upload: response.upload }; +} + export async function armWorksCloudDeployment(projectId: string): Promise { const response = await hostApiFetch>( `/api/opencode/projects/${encodeURIComponent(projectId)}/works-cloud-deploy`, diff --git a/src/pages/ProjectConfiguration/index.tsx b/src/pages/ProjectConfiguration/index.tsx index f561538..b757af1 100644 --- a/src/pages/ProjectConfiguration/index.tsx +++ b/src/pages/ProjectConfiguration/index.tsx @@ -13,6 +13,7 @@ import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ import { Switch } from '@/components/ui/switch'; import { Textarea } from '@/components/ui/textarea'; import { AgentCreationDialog, type AgentCreationInput } from '@/components/opencode/AgentCreationDialog'; +import { ProjectPublishAction } from '@/components/works/ProjectPublishAction'; import { hostApiFetch } from '@/lib/host-api'; import { buildConfiguredModelOptions, type ConfiguredModelOption } from '@/lib/model-options'; import { getSkillDisplayInfo } from '@/lib/skill-display'; @@ -319,9 +320,12 @@ export function ProjectConfiguration() { {!draft.initialized ?
新项目还没有联系人。点击“新增伙伴”,为它设置名字、头像、基础模型和职责后即可开始对话。
: null}
setDrawerMode('models')} /> setDrawerMode('skills')} /> setDrawerMode('knowledge')} />

我的伙伴

每个联系人只属于当前项目;一个联系人可以拥有多条独立会话。

{draft.agents.filter((agent) => !agent.archivedAt).map((agent) => { setActiveAgentId(agent.id); setDrawerMode('agent'); }} />)}{draft.agents.every((agent) => agent.archivedAt) ?
还没有联系人,点击右上角创建第一个。
: null}
{draft.agents.some((agent) => agent.archivedAt) ?

已归档

{draft.agents.filter((agent) => agent.archivedAt).map((agent) =>
{!agent.builtIn ? : null}
)}
: null}
-
+
- +
+ + +
{ +test.describe('Makelore Electron smoke flows', () => { test('shows the setup wizard on a fresh profile', async ({ page }) => { await expect(page.getByTestId('setup-page')).toBeVisible(); await expect(page.getByTestId('setup-welcome-step')).toBeVisible(); @@ -11,7 +11,7 @@ test.describe('NianCode Electron smoke flows', () => { await expect(page.getByTestId('setup-page')).toBeVisible(); await page.getByTestId('setup-skip-button').click(); - await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Continue in browser' })).toBeVisible(); await expect(page.getByTestId('main-layout')).toHaveCount(0); }); @@ -19,7 +19,7 @@ test.describe('NianCode Electron smoke flows', () => { const firstWindow = await electronApp.firstWindow(); await firstWindow.waitForLoadState('domcontentloaded'); await firstWindow.getByTestId('setup-skip-button').click(); - await expect(firstWindow.getByRole('button', { name: 'Sign in' })).toBeVisible(); + await expect(firstWindow.getByRole('button', { name: 'Continue in browser' })).toBeVisible(); await closeElectronApp(electronApp); @@ -28,7 +28,7 @@ test.describe('NianCode Electron smoke flows', () => { const relaunchedWindow = await relaunchedApp.firstWindow(); await relaunchedWindow.waitForLoadState('domcontentloaded'); - await expect(relaunchedWindow.getByRole('button', { name: 'Sign in' })).toBeVisible(); + await expect(relaunchedWindow.getByRole('button', { name: 'Continue in browser' })).toBeVisible(); await expect(relaunchedWindow.getByTestId('setup-page')).toHaveCount(0); } finally { await closeElectronApp(relaunchedApp); diff --git a/tests/e2e/fixtures/electron.ts b/tests/e2e/fixtures/electron.ts new file mode 100644 index 0000000..5ada531 --- /dev/null +++ b/tests/e2e/fixtures/electron.ts @@ -0,0 +1,273 @@ +import electronBinaryPath from 'electron'; +import { + _electron as electron, + expect, + test as base, + type ElectronApplication, + type Page, +} from '@playwright/test'; +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { createServer } from 'node:net'; +import { tmpdir } from 'node:os'; +import { basename, join, resolve } from 'node:path'; + +export type LaunchElectronOptions = { + skipSetup?: boolean; +}; + +type ElectronTestResources = { + apps: Set; + defaultSkipSetup: boolean; + homeDir: string; + rootDir: string; + userDataDir: string; +}; + +type ElectronFixtures = { + electronApp: ElectronApplication; + electronTestResources: ElectronTestResources; + launchElectronApp: (options?: LaunchElectronOptions) => Promise; + page: Page; +}; + +const repoRoot = resolve(process.cwd()); +const electronEntry = join(repoRoot, 'dist-electron/main/index.js'); +const PASSTHROUGH_ENVIRONMENT_KEYS = [ + 'PATH', + 'Path', + 'PATHEXT', + 'SystemRoot', + 'SYSTEMROOT', + 'WINDIR', + 'COMSPEC', + 'TEMP', + 'TMP', + 'TMPDIR', + 'LANG', + 'LC_ALL', + 'DISPLAY', + 'XAUTHORITY', + 'WAYLAND_DISPLAY', + 'XDG_RUNTIME_DIR', + 'DBUS_SESSION_BUS_ADDRESS', + 'CI', +] as const; + +async function allocatePort(): Promise { + return await new Promise((resolvePort, reject) => { + const server = createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(() => reject(new Error('Failed to allocate an ephemeral port'))); + return; + } + + server.close((error) => { + if (error) { + reject(error); + return; + } + resolvePort(address.port); + }); + }); + }); +} + +function buildElectronEnvironment( + resources: ElectronTestResources, + skipSetup: boolean, + hostApiPort: number, +): Record { + const env: Record = {}; + for (const key of PASSTHROUGH_ENVIRONMENT_KEYS) { + const value = process.env[key]; + if (value !== undefined) { + env[key] = value; + } + } + + Object.assign(env, { + HOME: resources.homeDir, + USERPROFILE: resources.homeDir, + APPDATA: join(resources.homeDir, 'AppData', 'Roaming'), + LOCALAPPDATA: join(resources.homeDir, 'AppData', 'Local'), + XDG_CONFIG_HOME: join(resources.homeDir, '.config'), + NIANCODE_E2E: '1', + NIANCODE_USER_DATA_DIR: resources.userDataDir, + NIANCODE_PORT_NIANCODE_HOST_API: String(hostApiPort), + }); + + delete env.NIANCODE_E2E_SKIP_SETUP; + if (skipSetup) { + env.NIANCODE_E2E_SKIP_SETUP = '1'; + } + if (process.platform === 'linux') { + env.ELECTRON_DISABLE_SANDBOX = '1'; + } + + return env; +} + +async function launchMakeloreElectron( + resources: ElectronTestResources, + skipSetup: boolean, +): Promise { + const hostApiPort = await allocatePort(); + const app = await electron.launch({ + executablePath: electronBinaryPath as unknown as string, + args: [electronEntry], + env: buildElectronEnvironment(resources, skipSetup, hostApiPort), + timeout: 90_000, + }); + + resources.apps.add(app); + app.once('close', () => { + resources.apps.delete(app); + }); + return app; +} + +export async function getStableWindow(app: ElectronApplication): Promise { + const deadline = Date.now() + 30_000; + let page = await app.firstWindow(); + + while (Date.now() < deadline) { + const openWindows = app.windows().filter((candidate) => !candidate.isClosed()); + const currentWindow = openWindows.at(-1) ?? page; + + if (!currentWindow.isClosed()) { + try { + await currentWindow.waitForLoadState('domcontentloaded', { timeout: 2_000 }); + return currentWindow; + } catch (error) { + if (!String(error).includes('has been closed')) { + throw error; + } + } + } + + try { + page = await app.waitForEvent('window', { timeout: 2_000 }); + } catch { + // Keep polling until a stable window is available or the deadline expires. + } + } + + throw new Error('No stable Electron window became available'); +} + +export async function closeElectronApp( + app: ElectronApplication, + timeoutMs = 5_000, +): Promise { + let closed = false; + + await Promise.race([ + (async () => { + const [closeResult] = await Promise.allSettled([ + app.waitForEvent('close', { timeout: timeoutMs }), + app.evaluate(({ app: electronApp }) => { + electronApp.quit(); + }), + ]); + closed = closeResult.status === 'fulfilled'; + })(), + new Promise((resolveTimeout) => setTimeout(resolveTimeout, timeoutMs)), + ]); + + if (closed) { + return; + } + + const playwrightClosed = await Promise.race([ + app.close().then(() => true).catch(() => false), + new Promise((resolveTimeout) => { + setTimeout(() => resolveTimeout(false), timeoutMs); + }), + ]); + if (playwrightClosed) { + return; + } + + const childProcess = app.process(); + try { + childProcess.kill('SIGKILL'); + } catch { + // The process may already have exited during teardown. + } + + if (childProcess.exitCode === null) { + await Promise.race([ + new Promise((resolveExit) => { + childProcess.once('exit', () => resolveExit()); + }), + new Promise((resolveTimeout) => { + setTimeout(resolveTimeout, timeoutMs); + }), + ]); + } +} + +export const test = base.extend({ + electronTestResources: async ({ browserName: _browserName }, provideResources, testInfo) => { + const rootDir = await mkdtemp(join(tmpdir(), 'makelore-e2e-')); + const homeDir = join(rootDir, 'home'); + const userDataDir = join(rootDir, 'user-data'); + await Promise.all([ + mkdir(join(homeDir, '.config'), { recursive: true }), + mkdir(join(homeDir, 'AppData', 'Local'), { recursive: true }), + mkdir(join(homeDir, 'AppData', 'Roaming'), { recursive: true }), + mkdir(userDataDir, { recursive: true }), + ]); + + const resources: ElectronTestResources = { + apps: new Set(), + defaultSkipSetup: ![ + 'app-smoke.spec.ts', + 'language-russian.spec.ts', + ].includes(basename(testInfo.file)), + homeDir, + rootDir, + userDataDir, + }; + + try { + await provideResources(resources); + } finally { + await Promise.allSettled([...resources.apps].map((app) => closeElectronApp(app))); + await rm(rootDir, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 200, + }); + } + }, + + launchElectronApp: async ({ electronTestResources }, provideLauncher) => { + await provideLauncher(async (options = {}) => { + const skipSetup = options.skipSetup ?? electronTestResources.defaultSkipSetup; + return await launchMakeloreElectron(electronTestResources, skipSetup); + }); + }, + + electronApp: async ({ launchElectronApp }, provideElectronApp) => { + await provideElectronApp(await launchElectronApp()); + }, + + page: async ({ electronApp }, providePage) => { + await providePage(await getStableWindow(electronApp)); + }, +}); + +export async function completeSetup(page: Page): Promise { + const setupPage = page.getByTestId('setup-page'); + if (await setupPage.isVisible()) { + await page.getByTestId('setup-skip-button').click(); + } + await expect(page.getByTestId('main-layout')).toBeVisible(); +} + +export { expect }; diff --git a/tests/e2e/language-russian.spec.ts b/tests/e2e/language-russian.spec.ts index 5b5220e..3a378c8 100644 --- a/tests/e2e/language-russian.spec.ts +++ b/tests/e2e/language-russian.spec.ts @@ -52,6 +52,8 @@ test.describe('Russian language localization', () => { test('Russian language persists after skipping setup', async ({ launchElectronApp }) => { const app = await launchElectronApp(); + let relaunchedApp: Awaited> | null = null; + let firstAppClosed = false; try { const page = await getStableWindow(app); @@ -64,17 +66,27 @@ test.describe('Russian language localization', () => { // Skip setup await page.getByTestId('setup-skip-button').click(); - await expect(page.getByTestId('main-layout')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Continue in browser' })).toBeVisible(); - // Navigate to Settings to verify language persistence - await openSettingsFromSidebarAccountMenu(page); - await expect(page.getByTestId('settings-page')).toBeVisible(); + await closeElectronApp(app); + firstAppClosed = true; + + relaunchedApp = await launchElectronApp({ skipSetup: true }); + const relaunchedPage = await getStableWindow(relaunchedApp); + await expect(relaunchedPage.getByTestId('main-layout')).toBeVisible(); + + // Bypass authentication only for the relaunch so the persisted setting + // can be verified without weakening the production login gate. + await openSettingsFromSidebarAccountMenu(relaunchedPage); + await expect(relaunchedPage).toHaveURL(/\/settings$/); + await expect(relaunchedPage.getByRole('heading', { name: 'Настройки', exact: true })).toBeVisible(); // Verify sidebar shows Russian text (not English) // "Настройки" is Russian-only, English is "Settings" - await expectSidebarSettingsLabel(page, 'Настройки'); + await expectSidebarSettingsLabel(relaunchedPage, 'Настройки'); } finally { - await closeElectronApp(app); + if (relaunchedApp) await closeElectronApp(relaunchedApp); + if (!firstAppClosed) await closeElectronApp(app); } }); @@ -88,11 +100,11 @@ test.describe('Russian language localization', () => { // Navigate to Settings (in English by default after skipSetup) await openSettingsFromSidebarAccountMenu(page); - await expect(page.getByTestId('settings-page')).toBeVisible(); + await expect(page).toHaveURL(/\/settings$/); - // Click Russian language button - const russianButton = page.locator('button', { hasText: 'Русский' }); - await russianButton.click(); + // Select Russian from the current settings control. + await page.locator('#language').selectOption('ru'); + await expect(page.getByRole('heading', { name: 'Настройки', exact: true })).toBeVisible(); // Verify sidebar switched to Russian // "Настройки" is Russian-only, English is "Settings" diff --git a/tests/e2e/project-superpowers-toggle.spec.ts b/tests/e2e/project-superpowers-toggle.spec.ts index 4cd2733..094a4d7 100644 --- a/tests/e2e/project-superpowers-toggle.spec.ts +++ b/tests/e2e/project-superpowers-toggle.spec.ts @@ -21,15 +21,16 @@ test.describe('Project-level Superpowers setting', () => { await expect(page.getByLabel('项目路径')).toHaveValue(parentPath); await page.getByRole('button', { name: '确认创建' }).click(); - await expect(page.getByTestId('sidebar-nav-project-config')).toBeVisible(); + await expect(page.getByTestId('project-configuration-page')).toBeVisible(); await expect(page.getByTestId('sidebar-nav-publish')).toHaveCount(0); - await expect(page.getByText(/还没有联系人/)).toBeVisible(); - await expect(page.getByRole('button', { name: '新增联系人' })).toBeVisible(); + await expect(page.getByText(/新项目还没有联系人/)).toBeVisible(); + await expect(page.getByRole('button', { name: '新增伙伴' })).toBeVisible(); await page.getByTestId('resource-card-skills').click(); await expect(page.getByRole('switch', { name: '启用 Superpowers' })).toHaveAttribute('aria-checked', 'false'); await expect(page.getByText('已关闭')).toBeVisible(); await page.getByRole('button', { name: '关闭' }).click(); await expect(page.getByTestId('resource-card-publish')).toHaveCount(0); + await expect(page.getByRole('button', { name: '一键提交审核' })).toBeVisible(); } finally { await closeElectronApp(app); await rm(parentPath, { recursive: true, force: true }); diff --git a/tests/unit/project-configuration-page.test.tsx b/tests/unit/project-configuration-page.test.tsx index f17b98f..0dbd973 100644 --- a/tests/unit/project-configuration-page.test.tsx +++ b/tests/unit/project-configuration-page.test.tsx @@ -79,6 +79,7 @@ describe('ProjectConfiguration', () => { render(); expect(await screen.findByText('配置你的项目空间')).toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: '一键提交审核' })).toHaveLength(1); expect(screen.getByText(/新项目还没有联系人/)).toBeInTheDocument(); expect(screen.getByText('还没有联系人,点击右上角创建第一个。')).toBeInTheDocument(); expect(screen.queryAllByTestId(/^project-agent-/)).toHaveLength(0); diff --git a/tests/unit/project-packager.test.ts b/tests/unit/project-packager.test.ts new file mode 100644 index 0000000..2beffa4 --- /dev/null +++ b/tests/unit/project-packager.test.ts @@ -0,0 +1,298 @@ +// @vitest-environment node + +import { createRequire } from 'node:module'; +import { + mkdir, + mkdtemp, + readFile, + rename, + rm, + symlink, + utimes, + writeFile, +} from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + createStaticProjectPackage, + ProjectPackageError, +} from '@electron/services/project-packager'; + +const openRaceHook = vi.hoisted(() => ({ + beforeOpen: null as null | ((path: string) => Promise), +})); + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + open: async (...args: Parameters) => { + await openRaceHook.beforeOpen?.(String(args[0])); + return await actual.open(...args); + }, + }; +}); + +const require = createRequire(import.meta.url); +const AdmZip = require('adm-zip') as typeof import('adm-zip'); + +const tempDirectories: string[] = []; + +async function createProject(): Promise { + const projectPath = await mkdtemp(join(tmpdir(), 'makelore-project-packager-')); + tempDirectories.push(projectPath); + await mkdir(join(projectPath, 'src'), { recursive: true }); + await mkdir(join(projectPath, 'public'), { recursive: true }); + await writeFile( + join(projectPath, 'package.json'), + JSON.stringify({ + name: 'space-cleaner', + private: true, + packageManager: 'npm@10.9.2', + devDependencies: { vite: '^7.0.0' }, + }), + 'utf8', + ); + await writeFile( + join(projectPath, 'package-lock.json'), + JSON.stringify({ + name: 'space-cleaner', + lockfileVersion: 3, + requires: true, + packages: {}, + }), + 'utf8', + ); + await writeFile(join(projectPath, 'index.html'), '
', 'utf8'); + await writeFile(join(projectPath, 'src', 'main.ts'), 'console.log("ready")\n', 'utf8'); + await writeFile(join(projectPath, 'public', 'icon.txt'), 'icon\n', 'utf8'); + return projectPath; +} + +afterEach(async () => { + openRaceHook.beforeOpen = null; + await Promise.all( + tempDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +describe('project packager', () => { + it('generates a deterministic static manifest zip and excludes local or sensitive files', async () => { + const projectPath = await createProject(); + await mkdir(join(projectPath, 'node_modules', 'vite'), { recursive: true }); + await mkdir(join(projectPath, '.git'), { recursive: true }); + await mkdir(join(projectPath, 'dist'), { recursive: true }); + await mkdir(join(projectPath, '.niancode'), { recursive: true }); + for (const directory of ['.ssh', '.aws', '.gnupg', '.kube', '.docker', 'secrets']) { + await mkdir(join(projectPath, directory), { recursive: true }); + await writeFile(join(projectPath, directory, 'credential'), 'private', 'utf8'); + } + await writeFile(join(projectPath, 'node_modules', 'vite', 'index.js'), 'generated', 'utf8'); + await writeFile(join(projectPath, '.git', 'config'), 'private', 'utf8'); + await writeFile(join(projectPath, 'dist', 'index.html'), 'built', 'utf8'); + await writeFile(join(projectPath, '.niancode', 'project.json'), '{}', 'utf8'); + await writeFile(join(projectPath, '.env'), 'TOKEN=secret', 'utf8'); + await writeFile(join(projectPath, '.env.example'), 'TOKEN=replace-me', 'utf8'); + await writeFile(join(projectPath, '.npmrc'), '//registry/:_authToken=secret', 'utf8'); + await writeFile(join(projectPath, 'debug.log'), 'local log', 'utf8'); + await writeFile(join(projectPath, 'niancode.yml'), 'runtime: compose\n', 'utf8'); + for (const filename of [ + '.netrc', + '_netrc', + '.git-credentials', + 'id_rsa', + 'id_dsa', + 'id_ecdsa', + 'id_ed25519', + 'service-account.json', + 'service_account.prod.json', + 'firebase-adminsdk-project.json', + 'application_default_credentials.json', + 'terraform.tfstate', + 'terraform.tfstate.backup', + 'production.tfvars', + 'secrets.auto.tfvars.json', + 'signing.ppk', + 'auth-key.p8', + ]) { + await writeFile(join(projectPath, filename), 'private', 'utf8'); + } + + const outputDirectory = await mkdtemp(join(tmpdir(), 'makelore-project-archives-')); + tempDirectories.push(outputDirectory); + const firstArchive = join(outputDirectory, 'first.zip'); + const secondArchive = join(outputDirectory, 'second.zip'); + + const first = await createStaticProjectPackage({ projectPath, archivePath: firstArchive }); + await utimes(join(projectPath, 'src', 'main.ts'), new Date(), new Date()); + const second = await createStaticProjectPackage({ projectPath, archivePath: secondArchive }); + + expect(await readFile(firstArchive)).toEqual(await readFile(secondArchive)); + expect(first.sha256).toBe(second.sha256); + expect(first.archiveBytes).toBe(second.archiveBytes); + expect(first.fileCount).toBe(6); + expect(first.excludedPaths).toEqual(expect.arrayContaining([ + '.env', + '.env.example', + '.git/', + '.git-credentials', + '.gnupg/', + '.niancode/', + '.npmrc', + '.ssh/', + '.aws/', + '.docker/', + '.kube/', + '.netrc', + '_netrc', + 'debug.log', + 'dist/', + 'firebase-adminsdk-project.json', + 'id_rsa', + 'node_modules/', + 'production.tfvars', + 'secrets.auto.tfvars.json', + 'secrets/', + 'service-account.json', + 'terraform.tfstate.backup', + ])); + + const archive = new AdmZip(firstArchive); + const entries = archive.getEntries(); + expect(entries.map((entry) => entry.entryName)).toEqual([ + 'index.html', + 'niancode.yml', + 'package-lock.json', + 'package.json', + 'public/icon.txt', + 'src/main.ts', + ]); + expect(archive.readAsText('niancode.yml')).toBe( + 'schema_version: 1\n' + + 'kind: web\n' + + 'runtime: static\n' + + 'build:\n' + + ' preset: vite\n' + + ' package_manager: npm\n' + + ' entry: index.html\n', + ); + expect(entries.every((entry) => entry.header.time.getFullYear() === 1980)).toBe(true); + await expect(readFile(join(projectPath, 'niancode.yml'), 'utf8')).resolves.toBe('runtime: compose\n'); + }); + + it('rejects a project that is not a locked npm Vite project', async () => { + const projectPath = await createProject(); + await writeFile( + join(projectPath, 'package.json'), + JSON.stringify({ + name: 'space-cleaner', + packageManager: 'pnpm@10.0.0', + devDependencies: { vite: '^7.0.0' }, + }), + 'utf8', + ); + const outputDirectory = await mkdtemp(join(tmpdir(), 'makelore-project-archives-')); + tempDirectories.push(outputDirectory); + + await expect(createStaticProjectPackage({ + projectPath, + archivePath: join(outputDirectory, 'project.zip'), + })).rejects.toMatchObject({ + code: 'PACKAGE_MANAGER_UNSUPPORTED', + message: '当前仅支持带 package-lock.json 的 npm 项目', + }); + }); + + it('returns a precise validation error when a required root file is missing', async () => { + const projectPath = await createProject(); + await rm(join(projectPath, 'index.html')); + const outputDirectory = await mkdtemp(join(tmpdir(), 'makelore-project-archives-')); + tempDirectories.push(outputDirectory); + + await expect(createStaticProjectPackage({ + projectPath, + archivePath: join(outputDirectory, 'project.zip'), + })).rejects.toMatchObject({ + code: 'PROJECT_FILE_MISSING', + message: '项目根目录缺少 index.html', + }); + }); + + it('does not follow a linked directory outside the project root', async () => { + const projectPath = await createProject(); + const externalPath = await mkdtemp(join(tmpdir(), 'makelore-project-external-')); + tempDirectories.push(externalPath); + await writeFile(join(externalPath, 'secret.txt'), 'must not be archived', 'utf8'); + await symlink(externalPath, join(projectPath, 'linked-external'), 'junction'); + const outputDirectory = await mkdtemp(join(tmpdir(), 'makelore-project-archives-')); + tempDirectories.push(outputDirectory); + + await expect(createStaticProjectPackage({ + projectPath, + archivePath: join(outputDirectory, 'project.zip'), + })).rejects.toMatchObject({ + code: 'PROJECT_SYMLINK_UNSUPPORTED', + }); + }); + + it('rejects a parent directory replaced by a junction after the pre-open identity check', async () => { + const projectPath = await createProject(); + const externalPath = await mkdtemp(join(tmpdir(), 'makelore-project-external-')); + tempDirectories.push(externalPath); + await writeFile(join(externalPath, 'main.ts'), 'console.log("external secret")\n', 'utf8'); + const sourcePath = join(projectPath, 'src'); + const sourceFilePath = join(sourcePath, 'main.ts'); + let replaced = false; + openRaceHook.beforeOpen = async (path) => { + if (replaced || path !== sourceFilePath) return; + replaced = true; + await rename(sourcePath, join(projectPath, 'src-original')); + await symlink(externalPath, sourcePath, 'junction'); + }; + const outputDirectory = await mkdtemp(join(tmpdir(), 'makelore-project-archives-')); + tempDirectories.push(outputDirectory); + + await expect(createStaticProjectPackage({ + projectPath, + archivePath: join(outputDirectory, 'project.zip'), + })).rejects.toMatchObject({ + code: 'PROJECT_SYMLINK_UNSUPPORTED', + }); + expect(replaced).toBe(true); + }); + + it('fails before allocating an oversized source package', async () => { + const projectPath = await createProject(); + await writeFile(join(projectPath, 'large.bin'), Buffer.alloc(32)); + const outputDirectory = await mkdtemp(join(tmpdir(), 'makelore-project-archives-')); + tempDirectories.push(outputDirectory); + + await expect(createStaticProjectPackage({ + projectPath, + archivePath: join(outputDirectory, 'project.zip'), + limits: { + maxFiles: 20, + maxSourceBytes: 16, + maxArchiveBytes: 1024, + }, + })).rejects.toMatchObject({ code: 'PROJECT_TOO_LARGE' }); + }); + + it('rejects an oversized required root file before reading it into memory', async () => { + const projectPath = await createProject(); + await writeFile(join(projectPath, 'package-lock.json'), Buffer.alloc(512, 0x20)); + const outputDirectory = await mkdtemp(join(tmpdir(), 'makelore-project-archives-')); + tempDirectories.push(outputDirectory); + + await expect(createStaticProjectPackage({ + projectPath, + archivePath: join(outputDirectory, 'project.zip'), + limits: { + maxFiles: 20, + maxSourceBytes: 256, + maxArchiveBytes: 1024, + }, + })).rejects.toMatchObject({ code: 'PROJECT_TOO_LARGE' }); + }); +}); diff --git a/tests/unit/project-publish-action.test.tsx b/tests/unit/project-publish-action.test.tsx new file mode 100644 index 0000000..2ea9a08 --- /dev/null +++ b/tests/unit/project-publish-action.test.tsx @@ -0,0 +1,210 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ProjectPublishAction } from '@/components/works/ProjectPublishAction'; + +const publishWorksProjectSourceMock = vi.hoisted(() => vi.fn()); +const fetchCurrentWorksProjectStatusMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/works-square', () => ({ + publishWorksProjectSource: (...args: unknown[]) => publishWorksProjectSourceMock(...args), + fetchCurrentWorksProjectStatus: (...args: unknown[]) => fetchCurrentWorksProjectStatusMock(...args), +})); + +const project = { + id: 'prj_space_cleaner', + path: 'D:/repo/space-cleaner', + name: 'space-cleaner', + createdAt: '', + updatedAt: '', + lastOpenedAt: '', +}; + +const upload = { + version_id: 'version-1', + review_status: 'building', + build_job_id: 'job-1', + build_status: 'queued', +}; + +function projectStatus(buildStatus: string, buildErrorCode: string | null = null) { + return { + project: { + app_id: 'space-cleaner', + title: 'space-cleaner', + summary: 'A project', + }, + latest_version: { + id: 'version-1', + version_name: 'v1.0.0', + review_status: buildStatus === 'succeeded' ? 'pending_review' : 'build_failed', + change_log: '首次提交', + build_job_id: 'job-1', + build_status: buildStatus, + build_error_code: buildErrorCode, + build_error_message: 'Traceback: /srv/private/builder.py token=secret', + release_id: buildStatus === 'succeeded' ? 'release-1' : null, + created_at: '2026-08-07T12:00:00Z', + }, + versions: [], + }; +} + +async function flushSubmission() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +async function advancePoll() { + await act(async () => { + await vi.advanceTimersByTimeAsync(2_000); + }); +} + +describe('ProjectPublishAction', () => { + beforeEach(() => { + vi.useFakeTimers(); + publishWorksProjectSourceMock.mockReset(); + fetchCurrentWorksProjectStatusMock.mockReset(); + publishWorksProjectSourceMock.mockResolvedValue({ package: {}, upload }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('submits automatic metadata and locks after Builder succeeds', async () => { + fetchCurrentWorksProjectStatusMock.mockResolvedValue(projectStatus('succeeded')); + render(); + + fireEvent.click(screen.getByRole('button', { name: '一键提交审核' })); + expect(screen.getByRole('button', { name: '正在检查并提交…' })).toBeDisabled(); + await flushSubmission(); + + expect(publishWorksProjectSourceMock).toHaveBeenCalledWith({ + projectId: 'prj_space_cleaner', + project: { + app_id: 'makelore-prj-space-cleaner', + title: 'space-cleaner', + summary: 'space-cleaner,由 Makelore 创建的作品。', + cover_url: null, + category: 'web', + age_band: null, + difficulty: null, + }, + }); + expect(fetchCurrentWorksProjectStatusMock).not.toHaveBeenCalled(); + + await advancePoll(); + + expect(fetchCurrentWorksProjectStatusMock).toHaveBeenCalledWith( + 'makelore-prj-space-cleaner', + ); + expect(screen.getByRole('button', { name: '已提交,等待运营审核' })).toBeDisabled(); + expect(screen.getByTestId('project-publish-status')).toHaveTextContent('已提交,等待运营审核'); + }); + + it('shows a friendly Builder failure without raw details', async () => { + fetchCurrentWorksProjectStatusMock.mockResolvedValue( + projectStatus('failed', 'BROWSER_SMOKE_FAILED'), + ); + render(); + + fireEvent.click(screen.getByRole('button', { name: '一键提交审核' })); + await flushSubmission(); + await advancePoll(); + + const failure = screen.getByTestId('project-publish-failure'); + expect(failure).toHaveTextContent('自动打开作品时发现问题'); + expect(failure).toHaveTextContent('下一步:请让开发助手运行 npm run build'); + expect(failure).not.toHaveTextContent('BROWSER_SMOKE_FAILED'); + expect(failure).not.toHaveTextContent('Traceback'); + expect(failure).not.toHaveTextContent('/srv/private'); + expect(screen.getByRole('button', { name: '重新提交审核' })).toBeEnabled(); + }); + + it('keeps an uncertain submission locked when status polling is interrupted', async () => { + fetchCurrentWorksProjectStatusMock.mockRejectedValue( + new Error('socket closed at /srv/private/status.py'), + ); + render(); + + fireEvent.click(screen.getByRole('button', { name: '一键提交审核' })); + await flushSubmission(); + await advancePoll(); + + const failure = screen.getByTestId('project-publish-failure'); + expect(failure).toHaveTextContent('暂时无法读取构建进度'); + expect(failure).toHaveTextContent('不需要重复提交'); + expect(failure).not.toHaveTextContent('socket closed'); + expect(screen.getByRole('button', { name: '已提交,请稍后查看' })).toBeDisabled(); + }); + + it('shows a safe timeout instead of inviting a duplicate submission', async () => { + const status = projectStatus('queued'); + status.latest_version.id = 'another-version'; + fetchCurrentWorksProjectStatusMock.mockResolvedValue(status); + render(); + + fireEvent.click(screen.getByRole('button', { name: '一键提交审核' })); + await flushSubmission(); + await act(async () => { + await vi.runAllTimersAsync(); + }); + + const failure = screen.getByTestId('project-publish-failure'); + expect(failure).toHaveTextContent('构建时间比平时更长'); + expect(failure).toHaveTextContent('不需要重复提交'); + expect(screen.getByRole('button', { name: '已提交,请稍后查看' })).toBeDisabled(); + expect(fetchCurrentWorksProjectStatusMock).toHaveBeenCalledTimes(300); + }); + + it('sanitizes local and HTTP failures before rendering', async () => { + publishWorksProjectSourceMock.mockRejectedValue(Object.assign( + new Error('Traceback: failed at /srv/private/package.ts token=secret'), + { code: 'INTERNAL_DATABASE_FAILURE', statusCode: 503 }, + )); + render(); + + fireEvent.click(screen.getByRole('button', { name: '一键提交审核' })); + await flushSubmission(); + + const failure = screen.getByTestId('project-publish-failure'); + expect(failure).toHaveTextContent('发布服务暂时不可用'); + expect(failure).not.toHaveTextContent('Traceback'); + expect(failure).not.toHaveTextContent('INTERNAL_DATABASE_FAILURE'); + expect(fetchCurrentWorksProjectStatusMock).not.toHaveBeenCalled(); + }); + + it('turns a local package error into instructions for the user', async () => { + publishWorksProjectSourceMock.mockRejectedValue(Object.assign( + new Error('D:/repo/space-cleaner/package-lock.json does not exist'), + { code: 'PROJECT_FILE_MISSING' }, + )); + render(); + + fireEvent.click(screen.getByRole('button', { name: '一键提交审核' })); + await flushSubmission(); + + const failure = screen.getByTestId('project-publish-failure'); + expect(failure).toHaveTextContent('项目文件不完整'); + expect(failure).toHaveTextContent('请让开发助手补齐 package.json、package-lock.json 和 index.html'); + expect(failure).not.toHaveTextContent('D:/repo'); + expect(failure).not.toHaveTextContent('PROJECT_FILE_MISSING'); + expect(fetchCurrentWorksProjectStatusMock).not.toHaveBeenCalled(); + }); + + it('keeps generated metadata within the server limits', async () => { + const longName = `作品-${'x'.repeat(250)}`; + render(); + + fireEvent.click(screen.getByRole('button', { name: '一键提交审核' })); + await flushSubmission(); + + const input = publishWorksProjectSourceMock.mock.calls[0]?.[0]; + expect(input.project.app_id).toBe('makelore-prj-space-cleaner'); + expect(input.project.title).toHaveLength(160); + expect(input.project.summary).toBe(`${input.project.title},由 Makelore 创建的作品。`); + }); +}); diff --git a/tests/unit/works-project-publish.test.ts b/tests/unit/works-project-publish.test.ts new file mode 100644 index 0000000..0b906f7 --- /dev/null +++ b/tests/unit/works-project-publish.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { + createDefaultWorksAppId, + describeWorksPublishFailure, +} from '@/lib/works-project-publish'; + +describe('works project publish guidance', () => { + it.each([ + ['PROJECT_FILE_MISSING', '项目文件不完整', 'package-lock.json'], + ['DEPENDENCY_PREFETCH_FAILED', '暂时无法下载项目依赖', 'package-lock.json'], + ['OUTPUT_MISSING', '没有生成可运行页面', 'index.html'], + ['RELEASE_STORE_FAILED', '平台暂时无法保存发布文件', '无需修改项目'], + ['BUILD_PIPELINE_UNSUPPORTED', '这个项目暂不支持自动发布', '联系运营人员'], + ['BROWSER_SMOKE_FAILED', '自动打开作品时发现问题', 'npm run build'], + ['BROWSER_SMOKE_TIMEOUT', '自动打开作品超时', '首屏资源'], + ['BROWSER_SMOKE_UNAVAILABLE', '自动验收环境暂时不可用', '无需修改项目'], + ])('maps %s to actionable Chinese guidance', (code, title, nextStep) => { + const failure = describeWorksPublishFailure(code); + + expect(failure.title).toBe(title); + expect(failure.nextStep).toContain(nextStep); + }); + + it.each([ + [401, '需要重新登录'], + [403, '当前账号不能发布这个作品'], + [409, '这个作品已有版本在处理中'], + [413, '项目压缩包太大'], + [503, '发布服务暂时不可用'], + ])('maps HTTP %s without exposing a server response', (status, title) => { + const failure = describeWorksPublishFailure(null, status); + + expect(failure.title).toBe(title); + expect(JSON.stringify(failure)).not.toContain('Traceback'); + }); + + it('uses a safe fallback for an unknown internal code', () => { + const failure = describeWorksPublishFailure('INTERNAL_DATABASE_FAILURE'); + + expect(failure.title).toBe('提交没有完成'); + expect(failure.nextStep).toContain('联系运营人员'); + expect(JSON.stringify(failure)).not.toContain('INTERNAL_DATABASE_FAILURE'); + }); + + it('creates a valid fallback app id for a non-Latin project name', () => { + expect(createDefaultWorksAppId({ id: 'prj_7f86a', name: '我的小游戏' })) + .toBe('makelore-prj-7f86a'); + }); + + it('keeps the app id stable when the project is renamed', () => { + expect(createDefaultWorksAppId({ id: 'prj_7f86a', name: '旧名称' })) + .toBe(createDefaultWorksAppId({ id: 'prj_7f86a', name: '新名称' })); + }); +}); diff --git a/tests/unit/works-routes.test.ts b/tests/unit/works-routes.test.ts index 1e5bb00..bf67715 100644 --- a/tests/unit/works-routes.test.ts +++ b/tests/unit/works-routes.test.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'node:events'; import type { IncomingMessage, ServerResponse } from 'http'; -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -8,6 +8,7 @@ import { handleWorksRoutes } from '@electron/api/routes/works'; const readWorksPublishFileMock = vi.hoisted(() => vi.fn()); const readWorksDeployCheckMock = vi.hoisted(() => vi.fn()); +const getValidWorksSquareAccessTokenMock = vi.hoisted(() => vi.fn()); vi.mock('@electron/opencode/works-publish-file', () => ({ readWorksPublishFile: (...args: unknown[]) => readWorksPublishFileMock(...args), @@ -17,6 +18,10 @@ vi.mock('@electron/opencode/works-square-deploy-check', () => ({ readWorksDeployCheck: (...args: unknown[]) => readWorksDeployCheckMock(...args), })); +vi.mock('@electron/services/works-square-session', () => ({ + getValidWorksSquareAccessToken: (...args: unknown[]) => getValidWorksSquareAccessTokenMock(...args), +})); + function createResponse() { const chunks: string[] = []; const res = { @@ -50,6 +55,26 @@ function createRequest(method: string, body?: unknown, headers: Record { + await mkdir(join(projectPath, 'src'), { recursive: true }); + await writeFile(join(projectPath, 'package.json'), JSON.stringify({ + name: 'space-cleaner', + private: true, + packageManager: 'npm@10.9.2', + scripts: { build: 'vite build' }, + devDependencies: { vite: '^7.0.0' }, + }), 'utf8'); + await writeFile(join(projectPath, 'package-lock.json'), JSON.stringify({ + name: 'space-cleaner', + lockfileVersion: 3, + requires: true, + packages: {}, + }), 'utf8'); + await writeFile(join(projectPath, 'index.html'), '
', 'utf8'); + await writeFile(join(projectPath, 'src', 'main.ts'), 'console.log("ready")\n', 'utf8'); + await writeFile(join(projectPath, 'VERSION.md'), '# Version\n\nCurrent: v2.3.4\n', 'utf8'); +} + describe('works square host api routes', () => { let tempDir: string | null = null; @@ -57,6 +82,8 @@ describe('works square host api routes', () => { vi.restoreAllMocks(); readWorksPublishFileMock.mockReset(); readWorksDeployCheckMock.mockReset(); + getValidWorksSquareAccessTokenMock.mockReset(); + getValidWorksSquareAccessTokenMock.mockResolvedValue('main-owned-access-token'); }); afterEach(async () => { @@ -675,6 +702,7 @@ describe('works square host api routes', () => { updated_at: '2026-06-20T22:55:37.790408+08:00', playable: false, runtime_url: null, + owner_email: 'private@example.com', }, latest_version: { id: 'version-new', @@ -682,9 +710,15 @@ describe('works square host api routes', () => { review_status: 'building', change_log: 'Add score board', build_job_id: 'job-new', + build_status: 'failed', + build_error_code: 'BUILD_COMMAND_FAILED', + build_error_message: 'Traceback: C:\\private\\builder.ts token=secret', + release_id: null, created_at: '2026-06-21T10:30:00+08:00', + internal_trace: 'must not reach renderer', }, versions: [], + database_debug: 'must not reach renderer', }), { status: 200 }), ); vi.stubGlobal('fetch', fetchMock); @@ -721,11 +755,18 @@ describe('works square host api routes', () => { review_status: 'building', change_log: 'Add score board', build_job_id: 'job-new', + build_status: 'failed', + build_error_code: 'BUILD_COMMAND_FAILED', + release_id: null, + rejection_reason: null, created_at: '2026-06-21T10:30:00+08:00', }, versions: [], }, }); + expect(JSON.stringify(response.json())).not.toContain('private@example.com'); + expect(JSON.stringify(response.json())).not.toContain('token=secret'); + expect(JSON.stringify(response.json())).not.toContain('internal_trace'); expect(fetchMock).toHaveBeenCalledWith( 'https://square.nianxx.cn/api/projects/mine/space-cleaner/status', { @@ -737,6 +778,125 @@ describe('works square host api routes', () => { ); }); + it('loads current project status with the Main-owned session when no renderer token is sent', async () => { + const status = { + project: { + app_id: 'space-cleaner', + title: 'Space Cleaner', + summary: 'Catch space trash', + }, + latest_version: { + id: 'version-new', + version_name: 'v1.1.0', + review_status: 'building', + change_log: '通过 Makelore 一键提交', + build_job_id: 'job-new', + build_status: 'failed', + build_error_code: 'BUILD_COMMAND_FAILED', + build_error_message: 'npm run build 执行失败', + release_id: null, + created_at: '2026-08-07T10:30:00+08:00', + worker_debug: 'private worker hostname', + }, + versions: [], + }; + const fetchMock = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify(status), { status: 200 }), + ); + vi.stubGlobal('fetch', fetchMock); + const response = createResponse(); + + const handled = await handleWorksRoutes( + createRequest('GET'), + response.res, + new URL('http://127.0.0.1/api/works/projects/mine/space-cleaner/status'), + {} as never, + ); + + expect(handled).toBe(true); + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + success: true, + status: { + project: status.project, + latest_version: { + id: 'version-new', + version_name: 'v1.1.0', + review_status: 'building', + change_log: '通过 Makelore 一键提交', + build_job_id: 'job-new', + build_status: 'failed', + build_error_code: 'BUILD_COMMAND_FAILED', + release_id: null, + rejection_reason: null, + created_at: '2026-08-07T10:30:00+08:00', + }, + versions: [], + }, + }); + expect(JSON.stringify(response.json())).not.toContain('build_error_message'); + expect(JSON.stringify(response.json())).not.toContain('worker_debug'); + expect(getValidWorksSquareAccessTokenMock).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledWith( + 'https://square.nianxx.cn/api/projects/mine/space-cleaner/status', + { + method: 'GET', + headers: { Authorization: 'Bearer main-owned-access-token' }, + }, + ); + }); + + it('does not expose Main session refresh errors from the current project status route', async () => { + getValidWorksSquareAccessTokenMock.mockRejectedValueOnce( + new Error('refresh failed at C:\\private\\session.ts token=secret'), + ); + const response = createResponse(); + + await handleWorksRoutes( + createRequest('GET'), + response.res, + new URL('http://127.0.0.1/api/works/projects/mine/space-cleaner/status'), + {} as never, + ); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + success: false, + status: 503, + code: 'PROJECT_STATUS_UNAVAILABLE', + error: '暂时无法获取作品处理状态,请稍后重试。', + }); + expect(JSON.stringify(response.json())).not.toContain('token=secret'); + }); + + it('fails safely when a successful status response does not match the allowlisted schema', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ + project: { app_id: 'space-cleaner', title: 'Space Cleaner', summary: 'Catch trash' }, + latest_version: { build_error_message: 'private traceback token=secret' }, + versions: 'not-an-array', + internal: 'database coordinates', + }), { status: 200 }), + )); + const response = createResponse(); + + await handleWorksRoutes( + createRequest('GET'), + response.res, + new URL('http://127.0.0.1/api/works/projects/mine/space-cleaner/status'), + {} as never, + ); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + success: false, + status: 502, + code: 'PROJECT_STATUS_UNAVAILABLE', + error: '暂时无法获取作品处理状态,请稍后重试。', + }); + expect(JSON.stringify(response.json())).not.toContain('token=secret'); + }); + it.each(['pass', 'warning'] as const)('uploads a zip version when the deployment check is %s', async (deployStatus) => { tempDir = await mkdtemp(join(tmpdir(), 'niancode-works-upload-')); const zipPath = join(tempDir, 'project.zip'); @@ -857,6 +1017,275 @@ describe('works square host api routes', () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it('packages and submits source with Main-owned credentials and automatic release metadata', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-publish-')); + await writePublishableProject(tempDir); + const project = { id: 'project-1', path: tempDir, name: 'space-cleaner' }; + const projectMetadata = { + app_id: 'space-cleaner', + title: '太空清洁队', + summary: '收集漂浮垃圾的小游戏。', + category: 'game', + }; + const fetchMock = vi.fn() + .mockResolvedValueOnce(new Response(JSON.stringify(projectMetadata), { status: 201 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + version_id: 'version-1', + review_status: 'building', + build_job_id: 'job-1', + build_status: 'queued', + build_error_message: 'private builder path token=secret', + internal_trace: 'private worker hostname', + }), { status: 201 })); + vi.stubGlobal('fetch', fetchMock); + const response = createResponse(); + + const handled = await handleWorksRoutes( + createRequest('POST', { projectId: project.id, project: projectMetadata }), + response.res, + new URL('http://127.0.0.1/api/works/projects/publish-source'), + { opencodeProjectStore: { listProjects: vi.fn(async () => [project]) } } as never, + ); + + expect(handled).toBe(true); + expect(response.statusCode).toBe(201); + expect(response.json()).toMatchObject({ + success: true, + package: { + archiveName: 'project.zip', + fileCount: 5, + manifest: { + schema_version: 1, + kind: 'web', + runtime: 'static', + build: { preset: 'vite', package_manager: 'npm', entry: 'index.html' }, + }, + }, + upload: { + version_id: 'version-1', + review_status: 'building', + }, + }); + expect(response.json().upload).toEqual({ + version_id: 'version-1', + review_status: 'building', + build_job_id: 'job-1', + build_status: 'queued', + }); + expect(JSON.stringify(response.json())).not.toContain('build_error_message'); + expect(JSON.stringify(response.json())).not.toContain('internal_trace'); + expect(response.json().package).not.toHaveProperty('archivePath'); + expect(getValidWorksSquareAccessTokenMock).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0]).toEqual([ + 'https://square.nianxx.cn/api/projects', + { + method: 'POST', + headers: { + Authorization: 'Bearer main-owned-access-token', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(projectMetadata), + }, + ]); + const [, uploadInit] = fetchMock.mock.calls[1] as [string, RequestInit]; + expect(uploadInit.headers).toMatchObject({ + Authorization: 'Bearer main-owned-access-token', + 'Idempotency-Key': expect.stringMatching(/^makelore-/), + }); + const form = uploadInit.body as FormData; + expect(form.get('version_name')).toBe('v2.3.4'); + expect(form.get('change_log')).toBe('通过 Makelore 一键提交'); + const archive = form.get('archive'); + expect(archive).toBeInstanceOf(File); + expect((archive as File).name).toBe('project.zip'); + expect((archive as File).size).toBeGreaterThan(0); + }); + + it('uses a bounded timestamp version when VERSION.md is oversized', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-version-limit-')); + await writePublishableProject(tempDir); + await writeFile(join(tempDir, 'VERSION.md'), Buffer.alloc(65 * 1024, 0x31)); + const project = { id: 'project-1', path: tempDir, name: 'space-cleaner' }; + const fetchMock = vi.fn() + .mockResolvedValueOnce(new Response('{}', { status: 201 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + version_id: 'version-1', + review_status: 'building', + }), { status: 201 })); + vi.stubGlobal('fetch', fetchMock); + const response = createResponse(); + + await handleWorksRoutes( + createRequest('POST', { + projectId: project.id, + project: { + app_id: 'space-cleaner', + title: '太空清洁队', + summary: '收集漂浮垃圾的小游戏。', + }, + }), + response.res, + new URL('http://127.0.0.1/api/works/projects/publish-source'), + { opencodeProjectStore: { listProjects: vi.fn(async () => [project]) } } as never, + ); + + expect(response.json().success).toBe(true); + const uploadInit = fetchMock.mock.calls[1]?.[1] as RequestInit; + expect((uploadInit.body as FormData).get('version_name')).toMatch(/^v\d{14}$/); + }); + + it('retries transient status and network failures with stable bytes, then rotates the key per Host request', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-retry-')); + await writePublishableProject(tempDir); + const project = { id: 'project-1', path: tempDir, name: 'space-cleaner' }; + const projectMetadata = { + app_id: 'space-cleaner', + title: '太空清洁队', + summary: '收集漂浮垃圾的小游戏。', + }; + const retryResponse = new Response('sensitive upstream detail', { status: 503 }); + const cancelSpy = vi.spyOn(retryResponse.body!, 'cancel'); + const uploadPayload = JSON.stringify({ version_id: 'version-1', review_status: 'building' }); + const fetchMock = vi.fn() + .mockResolvedValueOnce(new Response('{}', { status: 201 })) + .mockResolvedValueOnce(retryResponse) + .mockResolvedValueOnce(new Response(uploadPayload, { status: 201 })) + .mockResolvedValueOnce(new Response('{}', { status: 201 })) + .mockRejectedValueOnce(new Error('socket reset with secret upstream detail')) + .mockResolvedValueOnce(new Response(uploadPayload, { status: 201 })); + vi.stubGlobal('fetch', fetchMock); + const ctx = { opencodeProjectStore: { listProjects: vi.fn(async () => [project]) } } as never; + + for (let index = 0; index < 2; index += 1) { + const response = createResponse(); + await handleWorksRoutes( + createRequest('POST', { projectId: project.id, project: projectMetadata }), + response.res, + new URL('http://127.0.0.1/api/works/projects/publish-source'), + ctx, + ); + expect(response.json().success).toBe(true); + } + + const uploadCalls = fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/versions/upload')) as [string, RequestInit][]; + expect(uploadCalls).toHaveLength(4); + const keys = uploadCalls.map(([, init]) => (init.headers as Record)['Idempotency-Key']); + expect(keys[0]).toBe(keys[1]); + expect(keys[2]).toBe(keys[3]); + expect(keys[2]).not.toBe(keys[0]); + const archiveBytes = await Promise.all(uploadCalls.map(async ([, init]) => { + const archive = (init.body as FormData).get('archive') as File; + return Buffer.from(await archive.arrayBuffer()); + })); + expect(archiveBytes[1]).toEqual(archiveBytes[0]); + expect(archiveBytes[2]).toEqual(archiveBytes[0]); + expect(archiveBytes[3]).toEqual(archiveBytes[0]); + expect(cancelSpy).toHaveBeenCalledOnce(); + }); + + it('turns a malformed successful upload response into a safe retryable failure', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-invalid-success-')); + await writePublishableProject(tempDir); + const project = { id: 'project-1', path: tempDir, name: 'space-cleaner' }; + const fetchMock = vi.fn() + .mockResolvedValueOnce(new Response('{}', { status: 201 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + review_status: 'building', + build_error_message: 'private traceback token=secret', + internal_trace: 'private worker hostname', + }), { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + const response = createResponse(); + + await handleWorksRoutes( + createRequest('POST', { + projectId: project.id, + project: { + app_id: 'space-cleaner', + title: '太空清洁队', + summary: '收集漂浮垃圾的小游戏。', + }, + }), + response.res, + new URL('http://127.0.0.1/api/works/projects/publish-source'), + { opencodeProjectStore: { listProjects: vi.fn(async () => [project]) } } as never, + ); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + success: false, + status: 502, + code: 'WORKS_SQUARE_UNAVAILABLE', + error: '发布服务返回结果异常,请稍后重试。', + }); + expect(JSON.stringify(response.json())).not.toContain('token=secret'); + }); + + it('returns a safe structured error without exposing an upstream response body', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-safe-error-')); + await writePublishableProject(tempDir); + const project = { id: 'project-1', path: tempDir, name: 'space-cleaner' }; + const upstream = new Response(JSON.stringify({ + detail: 'internal database table and stack trace', + token: 'secret-token', + }), { status: 422 }); + const cancelSpy = vi.spyOn(upstream.body!, 'cancel'); + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(upstream)); + const response = createResponse(); + + await handleWorksRoutes( + createRequest('POST', { + projectId: project.id, + project: { + app_id: 'space-cleaner', + title: '太空清洁队', + summary: '收集漂浮垃圾的小游戏。', + }, + }), + response.res, + new URL('http://127.0.0.1/api/works/projects/publish-source'), + { opencodeProjectStore: { listProjects: vi.fn(async () => [project]) } } as never, + ); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + success: false, + status: 422, + code: 'PROJECT_CREATE_REJECTED', + error: '平台没有接受作品信息,请修改后重试。', + }); + expect(JSON.stringify(response.json())).not.toContain('database'); + expect(JSON.stringify(response.json())).not.toContain('secret-token'); + expect(cancelSpy).toHaveBeenCalledOnce(); + }); + + it('fails safely before packaging when the Main session is unavailable', async () => { + getValidWorksSquareAccessTokenMock.mockResolvedValueOnce(null); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const response = createResponse(); + + await handleWorksRoutes( + createRequest('POST', { + projectId: 'project-1', + project: { app_id: 'space-cleaner', title: 'Space Cleaner', summary: 'Catch trash' }, + }), + response.res, + new URL('http://127.0.0.1/api/works/projects/publish-source'), + { opencodeProjectStore: { listProjects: vi.fn() } } as never, + ); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + success: false, + status: 401, + code: 'AUTH_REQUIRED', + error: '登录状态已失效,请重新登录。', + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it('transcribes uploaded speech through the Works Square API', async () => { const fetchMock = vi.fn().mockResolvedValueOnce( new Response(JSON.stringify({ diff --git a/tests/unit/works-square.test.ts b/tests/unit/works-square.test.ts index 5b628f5..52999e9 100644 --- a/tests/unit/works-square.test.ts +++ b/tests/unit/works-square.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createWorksProject, downloadWorksAssetToProject, + fetchCurrentWorksProjectStatus, fetchMyWorksProjectStatus, fetchMyWorksProjects, fetchWorksAsset, @@ -9,6 +10,7 @@ import { fetchWorksTokenUsage, fetchWorksProjectVersions, fetchWorksProjects, + publishWorksProjectSource, toPlazaCard, uploadWorksProjectZip, WorksSquareApiError, @@ -275,6 +277,34 @@ describe('works square client', () => { ); }); + it('loads the current project status without exposing a Works Square token', async () => { + const status = { + project: { + app_id: 'space-cleaner', + title: 'Space Cleaner', + summary: 'Catch space trash', + }, + latest_version: { + id: 'version-new', + version_name: 'v1.1.0', + review_status: 'building', + change_log: '通过 Makelore 一键提交', + build_job_id: 'job-new', + build_status: 'failed', + build_error_code: 'BUILD_COMMAND_FAILED', + release_id: null, + created_at: '2026-08-07T10:30:00+08:00', + }, + versions: [], + }; + hostApiFetchMock.mockResolvedValueOnce({ success: true, status }); + + await expect(fetchCurrentWorksProjectStatus('space cleaner')).resolves.toEqual(status); + expect(hostApiFetchMock).toHaveBeenCalledWith( + '/api/works/projects/mine/space%20cleaner/status', + ); + }); + it('exposes an api error class for status-based recovery', () => { const error = new WorksSquareApiError('Conflict', 409); @@ -320,6 +350,87 @@ describe('works square client', () => { ); }); + it('asks Main to package and submit source without renderer-owned secrets or archive fields', async () => { + const publishResult = { + package: { + archiveName: 'project.zip', + sha256: 'a'.repeat(64), + fileCount: 18, + sourceBytes: 42_000, + archiveBytes: 12_000, + excludedCount: 3, + excludedPaths: ['.env', 'dist/', 'node_modules/'], + manifest: { + schema_version: 1, + kind: 'web', + runtime: 'static', + build: { + preset: 'vite', + package_manager: 'npm', + entry: 'index.html', + }, + }, + }, + upload: { + version_id: 'version-1', + review_status: 'building', + build_job_id: 'job-1', + build_status: 'queued', + }, + }; + hostApiFetchMock.mockResolvedValueOnce({ success: true, ...publishResult }); + + const result = await publishWorksProjectSource({ + projectId: 'project-1', + project: { + app_id: 'space-cleaner', + title: '太空清洁队', + summary: '收集漂浮垃圾的小游戏。', + category: 'game', + }, + }); + + expect(result).toEqual(publishResult); + expect(hostApiFetchMock).toHaveBeenCalledWith('/api/works/projects/publish-source', { + method: 'POST', + body: JSON.stringify({ + projectId: 'project-1', + project: { + app_id: 'space-cleaner', + title: '太空清洁队', + summary: '收集漂浮垃圾的小游戏。', + category: 'game', + }, + }), + }); + expect(hostApiFetchMock.mock.calls[0]?.[1]?.body).not.toContain('accessToken'); + expect(hostApiFetchMock.mock.calls[0]?.[1]?.body).not.toContain('zipFilePath'); + expect(hostApiFetchMock.mock.calls[0]?.[1]?.body).not.toContain('versionName'); + }); + + it('keeps the safe Main error code for understandable publish failures', async () => { + hostApiFetchMock.mockResolvedValueOnce({ + success: false, + status: 400, + code: 'PROJECT_FILE_MISSING', + error: '项目根目录缺少 package-lock.json', + }); + + await expect(publishWorksProjectSource({ + projectId: 'project-1', + project: { + app_id: 'space-cleaner', + title: '太空清洁队', + summary: '收集漂浮垃圾的小游戏。', + }, + })).rejects.toMatchObject({ + name: 'WorksSquareApiError', + code: 'PROJECT_FILE_MISSING', + statusCode: 400, + message: '项目根目录缺少 package-lock.json', + }); + }); + it('loads uploaded versions with the current access token', async () => { hostApiFetchMock.mockResolvedValueOnce({ success: true,