From 0d6ac413dcaa8a04c6db6bfab3aca835e0460ef6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Wed, 14 Oct 2026 14:25:44 +0800 Subject: [PATCH] fix: submit the confirmed project work source directory --- ...61014-release-source-directory-3ccabc06.md | 68 +++++++ README.md | 2 + electron/services/project-packager.ts | 74 ++++++-- src/lib/works-project-publish.ts | 9 +- tests/unit/project-packager.test.ts | 177 +++++++++++++++++- tests/unit/project-publish-action.test.tsx | 34 +++- tests/unit/project-release-builder.test.ts | 83 +++++++- tests/unit/works-project-publish.test.ts | 14 +- 8 files changed, 433 insertions(+), 28 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20261014-release-source-directory-3ccabc06.md diff --git a/.project-docs/30-worklog/tasks/20261014-release-source-directory-3ccabc06.md b/.project-docs/30-worklog/tasks/20261014-release-source-directory-3ccabc06.md new file mode 100644 index 00000000..44635ecb --- /dev/null +++ b/.project-docs/30-worklog/tasks/20261014-release-source-directory-3ccabc06.md @@ -0,0 +1,68 @@ +# Task: Use confirmed work engineering directory for release submission + +## Identity + +- Task ID: 20261014-release-source-directory-3ccabc06 +- Mode: Feature +- Branch: codex/20261014-release-source-directory-3ccabc06-release-source-directory +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261014-release-source-directory-3ccabc06 +- Base commit: f34e651774beb6a2a213b5711f2ffb887d22d4b7 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Repair existing work submission source selection by reusing the validated saved work directory and constrained work discovery used by opening. Preserve registered project metadata/history identity and release validation; add focused regression coverage and accurate failure guidance. + +## Intent And Constraints + +- User explicitly requested repair after read-only diagnosis. Do not move/rewrite the user's game or metadata, create conversations, call a model, submit to the real service, restart/update the current trial, merge/push, or clean other worktrees as part of this source repair. +- Keep registered root configuration, project id, submission binding and build concurrency identity unchanged; distinguish source root from project root. Saved launch is a directory clue, not publish readiness. Keep existing root Vite projects publishable without requiring dev scripts. + +## Project Context Loaded + +Task context: +- Official start/status verified feature task id, branch, absolute worktree and base f34e651774beb6a2a213b5711f2ffb887d22d4b7 (latest fetched origin/main). Primary checkout has unrelated dirty task documentation and is untouched. +- All74 peer scopes/222 required sections read/hash checked; no related semantic conflict. Work-entry and preview recovery require project-level identity, bounded unique discovery, no arbitrary command replay and no automatic model/conversation. + +Read: +- Read-before-planning, memory-index, own record, planning-gate and AGENTS. Reused unchanged canonical context from the diagnosis on identical base f34e651; re-read positioning, decision/system/domain/evidence/reflection/commitment/stale indexes and relevant current-state sections, ADR008, project metadata and release/work-entry source. + +Relevant understanding: +- Main owns project configuration and release packaging/build/preflight; project creation generates metadata/knowledge only and optional scaffold never overwrites existing code. +- The real alice project registers the outer root but has a verified saved Vite launch.directory for the inner engineering folder. Current opening already uses it; packager validates/scans only registered root. +- Directory selection must not change project id or cloud contracts and must retain workspace-to-source ancestry checks during packaging. Production publishing and installed-package verification are not implied by local tests. +- Files: work-entry-store/work-launcher, project-packager, release builder tests, publish failure projection and README. Existing dormant gallery pages are not restored; only the current project-config submit action is repaired. + +Gate result: Passed. + +## Plan + +1. Select a source folder in the packager using the same trusted saved work-entry and bounded discovery helpers as opening; retain registered-root configuration and root-project compatibility. +2. Verify the whole registered-root-to-source directory chain throughout validation/scan/streamed reads; package only source-relative files. +3. Add nested-source, ambiguity, unchanged root, missing input and path-race regression tests; correct misleading template-damage guidance, run focused checks, build and independent review. + +## Outcome + +- Implemented source selection in the shared packager used by full and summary release builders, including utility builds. Reads project type/id from registered-root configuration, then prefers loadWorkEntry's validated launch.directory. With no saved directory, preserves existing root-package release behavior (including missing/complex dev scripts), otherwise uses opening's existing bounded unique discovery. No URL probe, startup, model, metadata write or new persisted schema. +- Only the selected source is validated/scanned/zipped with source-relative archive paths. Registered-root identity, release busy key, version naming and submission binding remain unchanged; an inner .makelore config cannot override the registered project. +- Retained and repeatedly checked the whole registered-root-to-source directory identity chain during validation, recursive scan and streamed reads. Invalid/copy/traversal/symlink entries cannot authorize outside content; configured private/generated source folders are rejected. An ambiguous discovery reports PROJECT_SOURCE_UNCONFIRMED rather than choosing a candidate. +- Kept mandatory package/lock/index, Vite declaration, source exclusions/limits, immutable build snapshot and browser preflight behavior. Missing inputs in a valid selected source do not fall back to another/root game. +- Corrected missing-file guidance to describe absent/unreadable publishing inputs without claiming template deletion/damage; added safe actionable directory-ambiguity guidance. Updated README and meaningful regressions. Independent review approved with no blocker. + +## Verification + +- Exact pinned pnpm10.33.4 used (initial global fallback version check found11.25.0; all verification commands explicitly used10.33.4). Dependency directory is an ignored link to existing installed primary dependencies; no dependency/lock changes or download committed. +- Standard typecheck, strict targeted Main module-closure tsc, scoped ESLint and Renderer/Main/Preload/release-utility production build all passed. Logs: /tmp/makelore-release-source-typecheck-3ccabc06.log, /tmp/makelore-release-source-main-types-3ccabc06.log, /tmp/makelore-release-source-lint-3ccabc06.log, /tmp/makelore-release-source-build-3ccabc06.log. +- 219 related unit tests passed: work-entry/launcher/preview/release-job/routes/scaffold156; packager22 and builder3; publish failure projection/component38. New full/summary builder checks inspect source archives and immutable build snapshots, with every fixture user file unchanged; builder test uses node environment for native ZIP behavior. +- Native Electron2/2 passed: saved work survives full restart and restarts a stopped service without new conversation/model; production local preflight isolates requests and cleans temporary renderers. Isolated fixture user-data/home used; running trial/account untouched. Log: /tmp/makelore-release-source-electron-3ccabc06.log; artifacts /tmp/makelore-release-source-electron-3ccabc06. +- Scoped tests include nested Unicode source, outer valid root as fallback trap, inner absent/custom config, no saved entry unique discovery, multiple candidates, unsafe/copied entries, missing selected lock and ancestor substitution between scan/stream. Test logs: /tmp/release-source-directory-tests-final-3ccabc06.log and /tmp/makelore-release-source-regression-3ccabc06.log. +- No real-project file changes, paid model, dependency installation into the user's game, real submit/upload, app update, merge/push or other-worktree cleanup. Tests use controlled build runtime and do not claim actual production npm/build/review success. + +## Follow-ups + +- Ready for integration and temporary-app delivery when requested. Current running trial still contains the previous packager. No actual user project or real cloud submission was exercised. + +## Promotion Candidates + +- Target: ADR008 and architecture data-flow/module-map. Proposal: distinguish registered project root (identity/config/history/binding) from project-contained engineering source, reusing validated work entry and constrained discovery for submission while keeping release-specific validation and root compatibility. Evidence: product packager change,219 units,2 Electron cases and independent review. Future impact: prevent later opening/publishing features from reintroducing the root mismatch or using preview as release readiness proof. Semantic conflicts: none found in74 peer scopes; no cloud contract or metadata migration. Human confirmation: current repair direction explicitly authorized; canonical promotion still requires a serialized Integration task. diff --git a/README.md b/README.md index 02b1e42a..c6ed4c18 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 作品入口按项目记住已确认的本机页面和受限的打开方式。应用重启后先检查并复用原服务;服务停止时,可直接重新启动已有的 Vite 工程或普通静态网页,再显示实际可访问的页面,不要求当前对话选择模型,也不自动新建对话。旧项目可从已成功打开页面的工具回执恢复入口。空项目仍显示原有空状态;无法确定打开方式、缺少运行组件或入口改变时说明原因,由用户明确选择「让助手检查」后才调用编程助手。后台恢复只访问本机地址,保存的打开方式按项目真实目录校验,不保存或重放任意终端命令。 +提交作品优先使用打开作品时已记住并通过项目身份校验的工程目录;没有有效记录时,保留根目录工程的发布兼容性,或在项目内明确找到唯一的已有工程。游戏位于项目子目录时,只打包该工程中的源码,项目配置、对话身份和提交记录仍属于原登记项目。目录不明确时说明原因,不自动选择其他作品;确认目录后仍执行完整发布文件、依赖、构建与预检校验。 + 作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。新建 Code 项目只要求选择目录:Main 自动生成内部项目 ID,并以内部 `interactive_ai_app` 类型创建 `.makelore/project.json` 与 `knowledge/`,不再让用户选择或查看项目身份、项目类型和模板;缺少项目 ID 的旧项目在读取时由 Main 自动补全。从列表移除项目只取消登记,不删除磁盘文件;在“新建项目”中直接选择已有项目文件夹会重新打开,并保留原有项目身份、类型、智能体、对话和知识文件。“新建下级文件夹”仍拒绝已存在的同名目录。创建成功后直接进入对话工作区,未创建智能体时只显示可选的设置入口,不再用初始化门禁遮挡工作区。已有 `custom` 项目继续受支持;历史 `mini_game` / `mini_program` 配置在读取时归一为交互式 AI 应用,但不会因读取被改写。用户获取并为项目启用官方 bundled `makelore.project-scaffold` 插件后,每个父智能体都可按需明确调用 `makelore-project-scaffold` Skill,无需伙伴分配;它以不覆盖既有路径的方式生成固定六文件 Vite 起步工程,不是创建前置条件,也不安装依赖、不联网、不构建、不上传或提审。交互式 AI 应用的项目配置底部提供“一键提交审核”;Main 自动预检、安全打包并提交,构建通过后进入运营审核,审核通过即直接发布。首次创建必须选择 PNG、JPEG 或 WebP 项目封面,并通过 Main-owned multipart 原子接口同时保存资料与封面;已有 draft/published 只提交新版本并沿用平台现有资料与封面。项目成果预览 `/deliverables` 继续保留。 创建智能体时,如果本次新操作被服务明确拒绝,会显示失败原因、保留名称和头像,并允许修改后重新提交;不会留下“结果尚未确认”提醒。断网、响应异常或已有待确认操作的重试失败仍保留原创建请求,可重试确认结果,避免重复创建。当前创建表单一旦出现结果不确定,即使移除提醒后重试被拒绝,也继续保留原请求;移除提醒不代表取消创建。创建页面会及时刷新本机恢复记录。服务器的字段校验错误只转换为固定中文提示,不展示内部错误或提交原文。一句话介绍仍可留空;创建与保存需要配套的智能体云端服务支持。 diff --git a/electron/services/project-packager.ts b/electron/services/project-packager.ts index 8043ce90..b9622d0a 100644 --- a/electron/services/project-packager.ts +++ b/electron/services/project-packager.ts @@ -10,6 +10,8 @@ import { import { dirname, isAbsolute, join, relative, sep } from 'node:path'; import type { ProjectType } from '../../shared/project-config'; import { readCodingProjectConfigV2 } from '../coding-projects/project-config'; +import { loadWorkEntry } from '../coding-runtime/work-entry-store'; +import { discoverWorkLaunch } from '../coding-runtime/work-launcher'; import { writeStoredZip } from './streaming-zip'; type PublishableProjectType = Exclude; @@ -181,10 +183,21 @@ export async function createStaticProjectPackage(input: { limits?: ProjectPackageLimits; }): Promise { const limits = input.limits ?? DEFAULT_LIMITS; - const projectPath = await resolveProjectDirectory(input.projectPath); - const projectType = await readPublishableProjectType(projectPath); + const projectRoot = await resolveProjectDirectory(input.projectPath); + const rootIdentity = await readSafeDirectoryIdentity(projectRoot, projectRoot); + const { projectType, projectId } = await readPublishableProjectConfig(projectRoot); + const sourceDirectory = await resolveSourceDirectory(projectRoot, projectId); + await assertDirectoryIdentityChain(projectRoot, [rootIdentity]); + const sourceChain = [rootIdentity]; + let projectPath = projectRoot; + for (const part of sourceDirectory.split('/').filter((part) => part !== '.')) { + projectPath = join(projectPath, part); + sourceChain.push(await readSafeDirectoryIdentity(projectRoot, projectPath)); + } + await assertDirectoryIdentityChain(projectRoot, sourceChain); await validateViteProject(projectPath, limits); - const scan = await scanProject(projectPath, limits); + await assertDirectoryIdentityChain(projectRoot, sourceChain); + const scan = await scanProject(projectPath, limits, projectRoot, sourceChain); const manifestBytes = Buffer.from(generatedManifest(projectType), 'utf8'); const sourceBytes = scan.sourceBytes + manifestBytes.length; if (sourceBytes > limits.maxSourceBytes) { @@ -217,7 +230,7 @@ export async function createStaticProjectPackage(input: { return { path: archivePath, source: { filePath: file.absolutePath }, - beforeRead: async () => assertStablePackageFile(projectPath, file), + beforeRead: async () => assertStablePackageFile(projectRoot, file), afterRead: async ({ size }) => { if (size !== file.size) { throw new ProjectPackageError( @@ -225,7 +238,7 @@ export async function createStaticProjectPackage(input: { `打包过程中项目文件发生变化:${file.archivePath}`, ); } - await assertStablePackageFile(projectPath, file); + await assertStablePackageFile(projectRoot, file); }, }; }), @@ -268,7 +281,10 @@ export async function createStaticProjectPackage(input: { }; } -async function readPublishableProjectType(projectPath: string): Promise { +async function readPublishableProjectConfig(projectPath: string): Promise<{ + projectType: PublishableProjectType; + projectId?: string; +}> { const result = await readCodingProjectConfigV2(projectPath); if (result.status === 'invalid') { throw new ProjectPackageError( @@ -282,7 +298,31 @@ async function readPublishableProjectType(projectPath: string): Promise { + const saved = projectId ? await loadWorkEntry({ id: projectId, path: projectRoot }) : null; + let directory = saved?.launch?.directory; + if (directory === undefined) { + // Existing root projects may build without a replayable dev script. Their + // release validation remains authoritative; opening need not be supported. + const rootPackage = await lstat(join(projectRoot, 'package.json')).catch(() => null); + if (rootPackage) return '.'; + try { + directory = (await discoverWorkLaunch(projectRoot))?.directory ?? '.'; + } catch { + throw new ProjectPackageError('PROJECT_SOURCE_UNCONFIRMED', '无法唯一确认当前作品的工程目录'); + } + } + if (directory !== '.') { + validateArchivePath(directory); + if (directory.split('/').some((part) => part.startsWith('.') || EXCLUDED_DIRECTORY_NAMES.has(part.toLowerCase()))) { + throw new ProjectPackageError('PROJECT_SOURCE_UNCONFIRMED', '作品工程目录不能是本地配置或生成目录'); + } + } + return directory; } async function resolveProjectDirectory(projectPath: string): Promise { @@ -386,6 +426,8 @@ async function readRequiredFile( async function scanProject( projectPath: string, limits: ProjectPackageLimits, + projectRoot: string, + sourceChain: DirectoryIdentity[], ): Promise { const files: PackageFile[] = []; const excludedPaths: string[] = []; @@ -396,14 +438,14 @@ async function scanProject( directory: string, parentChain: DirectoryIdentity[], ): Promise { - await assertDirectoryIdentityChain(projectPath, parentChain); - const before = await readSafeDirectoryIdentity(projectPath, directory); + await assertDirectoryIdentityChain(projectRoot, parentChain); + const before = await readSafeDirectoryIdentity(projectRoot, directory); const directoryChain = [...parentChain, before]; const entries = await readdir(directory, { withFileTypes: true }); - await assertDirectoryIdentityChain(projectPath, directoryChain); + await assertDirectoryIdentityChain(projectRoot, directoryChain); entries.sort((left, right) => left.name.localeCompare(right.name, 'en')); for (const entry of entries) { - await assertDirectoryIdentityChain(projectPath, directoryChain); + await assertDirectoryIdentityChain(projectRoot, directoryChain); const absolutePath = join(directory, entry.name); const archivePath = toArchivePath(relative(projectPath, absolutePath)); const isDirectory = entry.isDirectory(); @@ -412,7 +454,7 @@ async function scanProject( if (excludedPaths.length < 100) { excludedPaths.push(isDirectory ? `${archivePath}/` : archivePath); } - await assertDirectoryIdentityChain(projectPath, directoryChain); + await assertDirectoryIdentityChain(projectRoot, directoryChain); continue; } if (entry.isSymbolicLink()) { @@ -424,7 +466,7 @@ async function scanProject( validateArchivePath(archivePath); if (isDirectory) { await walk(absolutePath, directoryChain); - await assertDirectoryIdentityChain(projectPath, directoryChain); + await assertDirectoryIdentityChain(projectRoot, directoryChain); continue; } if (!entry.isFile()) { @@ -463,12 +505,12 @@ async function scanProject( `项目文件数超过 ${limits.maxFiles} 个限制`, ); } - await assertDirectoryIdentityChain(projectPath, directoryChain); + await assertDirectoryIdentityChain(projectRoot, directoryChain); } - await assertDirectoryIdentityChain(projectPath, directoryChain); + await assertDirectoryIdentityChain(projectRoot, directoryChain); } - await walk(projectPath, []); + await walk(projectPath, sourceChain); return { files, sourceBytes, diff --git a/src/lib/works-project-publish.ts b/src/lib/works-project-publish.ts index 7d08ef15..ca8f4fde 100644 --- a/src/lib/works-project-publish.ts +++ b/src/lib/works-project-publish.ts @@ -87,8 +87,13 @@ const PUBLISH_FAILURES: Record = { }, PROJECT_FILE_MISSING: { title: '项目文件不完整', - reason: '项目模板中的发布文件被删除或损坏。', - nextStep: '请让开发助手修复当前项目模板后重试。', + reason: '当前作品工程缺少发布所需的文件,或文件无法读取。', + nextStep: '请让开发助手检查当前作品工程,补齐发布文件后重试。', + }, + PROJECT_SOURCE_UNCONFIRMED: { + title: '需要确认提交哪个作品', + reason: '当前项目中无法明确找到唯一的作品工程。', + nextStep: '请让开发助手确认作品的工程位置,打开作品后再提交。', }, PROJECT_TYPE_UNPUBLISHABLE: { title: '这个项目没有配置发布方式', diff --git a/tests/unit/project-packager.test.ts b/tests/unit/project-packager.test.ts index 838a88fe..64043221 100644 --- a/tests/unit/project-packager.test.ts +++ b/tests/unit/project-packager.test.ts @@ -12,7 +12,7 @@ import { utimes, writeFile, } from 'node:fs/promises'; -import { join } from 'node:path'; +import { join, relative } from 'node:path'; import { tmpdir } from 'node:os'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { @@ -41,6 +41,7 @@ const require = createRequire(import.meta.url); const AdmZip = require('adm-zip') as typeof import('adm-zip'); const tempDirectories: string[] = []; +const projectId = '11111111-1111-4111-8111-111111111111'; async function createProject(projectType: ProjectType = 'interactive_ai_app'): Promise { const projectPath = await mkdtemp(join(tmpdir(), 'makelore-project-packager-')); @@ -79,6 +80,40 @@ async function createProject(projectType: ProjectType = 'interactive_ai_app'): P return await realpath(projectPath); } +async function createNestedSource(projectPath: string, directory = 'games/Alice 的游戏'): Promise { + const source = join(projectPath, directory); + await mkdir(join(source, 'src'), { recursive: true }); + await writeFile(join(source, 'package.json'), JSON.stringify({ + name: 'selected-inner-game', packageManager: 'npm@10.9.2', + scripts: { dev: 'vite --host 0.0.0.0 --port 5179' }, devDependencies: { vite: '^7.0.0' }, + })); + await writeFile(join(source, 'package-lock.json'), JSON.stringify({ name: 'selected-inner-game', lockfileVersion: 3, packages: {} })); + await writeFile(join(source, 'index.html'), '
selected inner game
'); + await writeFile(join(source, 'src', 'main.ts'), 'console.log("inner source")\n'); + return source; +} + +async function saveSourceEntry(projectPath: string, directory: string, patch: Record = {}): Promise { + const configPath = join(projectPath, '.makelore', 'project.json'); + const config = JSON.parse(await readFile(configPath, 'utf8')); + await writeFile(configPath, JSON.stringify({ ...config, projectId })); + await writeFile(join(projectPath, '.makelore', 'work-entry.json'), JSON.stringify({ + version: 1, projectId, projectPath, url: 'http://127.0.0.1:5179/', + launch: { kind: 'vite', directory, args: [] }, ...patch, + })); +} + +async function archiveTarget(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'makelore-project-archives-')); + tempDirectories.push(directory); + return join(directory, 'project.zip'); +} + +async function removeOuterSource(projectPath: string): Promise { + await Promise.all(['package.json', 'package-lock.json', 'index.html', 'src', 'public'] + .map(name => rm(join(projectPath, name), { recursive: true, force: true }))); +} + afterEach(async () => { openRaceHook.beforeOpen = null; await Promise.all( @@ -87,6 +122,146 @@ afterEach(async () => { }); describe('project packager', () => { + it.each([false, true])('packages the saved inner source at zip root using only outer project configuration (inner custom config=%s)', async (innerConfig) => { + const projectPath = await createProject(); + const source = await createNestedSource(projectPath); + if (innerConfig) { + await mkdir(join(source, '.makelore')); + await writeFile(join(source, '.makelore', 'project.json'), JSON.stringify( + createCodingProjectConfigV2('2026-08-09T00:00:00.000Z', 'custom', '22222222-2222-4222-8222-222222222222'), + )); + } + await mkdir(join(projectPath, 'knowledge')); + await mkdir(join(projectPath, 'sibling-assets')); + await writeFile(join(projectPath, 'knowledge', 'notes.txt'), 'private knowledge'); + await writeFile(join(projectPath, 'sibling-assets', 'cover.png'), 'unrelated image'); + await writeFile(join(projectPath, 'private.txt'), 'outer private file'); + await saveSourceEntry(projectPath, 'games/Alice 的游戏'); + const outerConfig = await readFile(join(projectPath, '.makelore', 'project.json')); + const savedEntry = await readFile(join(projectPath, '.makelore', 'work-entry.json')); + const archivePath = await archiveTarget(); + + const summary = await createStaticProjectPackage({ projectPath, archivePath }); + + const archive = new AdmZip(archivePath); + expect(archive.getEntries().map(entry => entry.entryName)).toEqual([ + 'index.html', 'niancode.yml', 'package-lock.json', 'package.json', 'src/main.ts', + ]); + expect(archive.readAsText('index.html')).toBe('
selected inner game
'); + expect(JSON.parse(archive.readAsText('package.json')).name).toBe('selected-inner-game'); + expect(summary.manifest.project_type).toBe('interactive_ai_app'); + expect(summary.fileCount).toBe(5); + expect(await readFile(join(projectPath, '.makelore', 'project.json'))).toEqual(outerConfig); + expect(await readFile(join(projectPath, '.makelore', 'work-entry.json'))).toEqual(savedEntry); + expect(await readFile(join(projectPath, 'private.txt'), 'utf8')).toBe('outer private file'); + }); + + it('discovers a unique nested standard Vite source without saving or rewriting project metadata', async () => { + const projectPath = await createProject(); + await removeOuterSource(projectPath); + await createNestedSource(projectPath); + const outerConfig = await readFile(join(projectPath, '.makelore', 'project.json')); + const archivePath = await archiveTarget(); + + await createStaticProjectPackage({ projectPath, archivePath }); + + expect(new AdmZip(archivePath).readAsText('index.html')).toBe('
selected inner game
'); + expect(new AdmZip(archivePath).getEntry('games/Alice 的游戏/index.html')).toBeNull(); + expect(await readFile(join(projectPath, '.makelore', 'project.json'))).toEqual(outerConfig); + await expect(readFile(join(projectPath, '.makelore', 'work-entry.json'))).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('rejects multiple nested candidates without choosing a game or producing an archive', async () => { + const projectPath = await createProject(); + await removeOuterSource(projectPath); + await createNestedSource(projectPath, 'first'); + await createNestedSource(projectPath, 'second'); + const archivePath = await archiveTarget(); + + await expect(createStaticProjectPackage({ projectPath, archivePath })) + .rejects.toMatchObject({ code: 'PROJECT_SOURCE_UNCONFIRMED' }); + await expect(readFile(archivePath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it.each([undefined, 'vite && node custom-dev.js'])('retains root Vite package compatibility for dev script %s', async (dev) => { + const projectPath = await createProject(); + const packagePath = join(projectPath, 'package.json'); + const packageJson = JSON.parse(await readFile(packagePath, 'utf8')); + await writeFile(packagePath, JSON.stringify({ ...packageJson, ...(dev ? { scripts: { dev } } : {}) })); + const archivePath = await archiveTarget(); + + await createStaticProjectPackage({ projectPath, archivePath }); + + expect(new AdmZip(archivePath).readAsText('index.html')).toBe('
'); + expect(JSON.parse(new AdmZip(archivePath).readAsText('package.json'))).toEqual({ + ...packageJson, ...(dev ? { scripts: { dev } } : {}), + }); + }); + + it('reports a missing lock in the saved source without falling back to the valid root or another game', async () => { + const projectPath = await createProject(); + const source = await createNestedSource(projectPath, 'selected'); + await createNestedSource(projectPath, 'other'); + await saveSourceEntry(projectPath, 'selected'); + await rm(join(source, 'package-lock.json')); + const archivePath = await archiveTarget(); + + await expect(createStaticProjectPackage({ projectPath, archivePath })) + .rejects.toMatchObject({ code: 'PROJECT_FILE_MISSING', message: expect.stringContaining('package-lock.json') }); + await expect(readFile(archivePath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it.each(['traversal', 'copied-project-id', 'copied-root', 'symlink'] as const)('ignores an unsafe %s saved directory and never packages its files', async (kind) => { + const projectPath = await createProject(); + const outside = await createProject(); + await writeFile(join(outside, 'external-secret.txt'), 'external private data'); + let directory = '.makelore/copied-source'; + let patch: Record = {}; + if (kind === 'traversal') directory = relative(projectPath, outside).split('\\').join('/'); + else if (kind === 'symlink') { + directory = '.makelore/linked-source'; + await symlink(outside, join(projectPath, directory), 'junction'); + } else { + await createNestedSource(projectPath, directory); + patch = kind === 'copied-project-id' + ? { projectId: '22222222-2222-4222-8222-222222222222' } + : { projectPath: outside }; + } + await saveSourceEntry(projectPath, directory, patch); + const archivePath = await archiveTarget(); + + await createStaticProjectPackage({ projectPath, archivePath }); + + const archive = new AdmZip(archivePath); + expect(archive.readAsText('index.html')).toBe('
'); + expect(archive.getEntries().map(entry => entry.entryName)).toEqual([ + 'index.html', 'niancode.yml', 'package-lock.json', 'package.json', 'public/icon.txt', 'src/main.ts', + ]); + expect(archive.readAsText('src/main.ts')).toBe('console.log("ready")\n'); + }); + + it('rejects a saved source ancestor replaced by a symlink between scan and streamed file reads', async () => { + const projectPath = await createProject(); + const source = await createNestedSource(projectPath, 'games/selected'); + await saveSourceEntry(projectPath, 'games/selected'); + const outside = await createProject(); + await createNestedSource(outside, 'selected'); + await writeFile(join(outside, 'selected', 'src', 'main.ts'), 'external private data'); + const archivePath = await archiveTarget(); + let replaced = false; + openRaceHook.beforeOpen = async path => { + if (replaced || path !== join(source, 'src', 'main.ts')) return; + replaced = true; + await rename(join(projectPath, 'games'), join(projectPath, 'games-original')); + await symlink(outside, join(projectPath, 'games'), 'junction'); + }; + + await expect(createStaticProjectPackage({ projectPath, archivePath })) + .rejects.toMatchObject({ code: 'PROJECT_SYMLINK_UNSUPPORTED' }); + expect(replaced).toBe(true); + await expect(readFile(archivePath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + 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 }); diff --git a/tests/unit/project-publish-action.test.tsx b/tests/unit/project-publish-action.test.tsx index cf63175b..b8dd2680 100644 --- a/tests/unit/project-publish-action.test.tsx +++ b/tests/unit/project-publish-action.test.tsx @@ -297,10 +297,25 @@ describe('ProjectPublishAction', () => { expect(failure).not.toHaveTextContent('token=secret'); }); - it('turns a local package error into instructions for the user', async () => { + it.each([ + { + code: 'PROJECT_FILE_MISSING', + message: 'D:/repo/space-cleaner/package-lock.json does not exist', + title: '项目文件不完整', + reason: '当前作品工程缺少发布所需的文件,或文件无法读取。', + nextStep: '请让开发助手检查当前作品工程,补齐发布文件后重试。', + }, + { + code: 'PROJECT_SOURCE_UNCONFIRMED', + message: 'Ambiguous sources: D:/repo/space-cleaner/app-a, D:/repo/space-cleaner/app-b', + title: '需要确认提交哪个作品', + reason: '当前项目中无法明确找到唯一的作品工程。', + nextStep: '请让开发助手确认作品的工程位置,打开作品后再提交。', + }, + ])('turns $code into safe guidance and allows retry without polling', async ({ code, message, title, reason, nextStep }) => { publishWorksProjectSourceMock.mockRejectedValue(Object.assign( - new Error('D:/repo/space-cleaner/package-lock.json does not exist'), - { code: 'PROJECT_FILE_MISSING' }, + new Error(message), + { code, statusCode: 400 }, )); render(); @@ -308,10 +323,17 @@ describe('ProjectPublishAction', () => { await flushSubmission(); const failure = screen.getByTestId('project-publish-failure'); - expect(failure).toHaveTextContent('项目文件不完整'); - expect(failure).toHaveTextContent('请让开发助手修复当前项目模板'); + expect(failure).toHaveTextContent(title); + expect(failure).toHaveTextContent(reason); + expect(failure).toHaveTextContent(nextStep); + expect(failure).not.toHaveTextContent(/模板|删除|损坏/); expect(failure).not.toHaveTextContent('D:/repo'); - expect(failure).not.toHaveTextContent('PROJECT_FILE_MISSING'); + expect(failure).not.toHaveTextContent(code); + expect(screen.getByRole('button', { name: '重新提交审核' })).toBeEnabled(); + + await advancePoll(); + + expect(publishWorksProjectSourceMock).toHaveBeenCalledOnce(); expect(fetchCurrentWorksProjectStatusMock).toHaveBeenCalledOnce(); }); diff --git a/tests/unit/project-release-builder.test.ts b/tests/unit/project-release-builder.test.ts index 1ec24997..9ff34328 100644 --- a/tests/unit/project-release-builder.test.ts +++ b/tests/unit/project-release-builder.test.ts @@ -1,5 +1,8 @@ +// @vitest-environment node + import { createHash } from 'node:crypto'; -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -11,13 +14,89 @@ vi.mock('@electron/services/publish-runtime', () => ({ runElectronNode: runElectronNodeMock, PublishRuntimeError: class extends Error {}, })); -import { prepareProjectRelease } from '@electron/services/project-release-builder'; +import { prepareProjectRelease, prepareProjectReleaseSummary } from '@electron/services/project-release-builder'; import { startStaticReleaseServer, staticArtifactSnapshotFiles } from '@electron/services/static-release-server'; +const require = createRequire(import.meta.url); +const AdmZip = require('adm-zip') as typeof import('adm-zip'); + describe('project release builder', () => { let root: string | null = null; afterEach(async () => { if (root) await rm(root, { recursive: true, force: true }); root = null; vi.clearAllMocks(); }); + it.each(['full', 'summary'] as const)('builds a %s release from the saved inner source snapshot while preserving outer identity and all user files', async (mode) => { + root = await realpath(await mkdtemp(join(tmpdir(), 'release-builder-nested-'))); + const source = join(root, 'games', 'Alice 的游戏'); + const projectId = '11111111-1111-4111-8111-111111111111'; + await mkdir(join(root, '.makelore'), { recursive: true }); + await mkdir(join(root, 'knowledge')); + await mkdir(join(root, 'sibling-assets')); + await mkdir(join(source, 'src'), { recursive: true }); + await mkdir(join(source, '.makelore')); + const userFiles = new Map([ + [join(root, '.makelore', 'project.json'), JSON.stringify(createCodingProjectConfigV2('2026-08-09T00:00:00.000Z', 'interactive_ai_app', projectId))], + [join(root, '.makelore', 'work-entry.json'), JSON.stringify({ + version: 1, projectId, projectPath: root, url: 'http://127.0.0.1:5179/', + launch: { kind: 'vite', directory: 'games/Alice 的游戏', args: [] }, + })], + [join(root, 'knowledge', 'notes.txt'), 'outer private knowledge'], + [join(root, 'sibling-assets', 'unused.png'), 'unrelated image'], + [join(root, 'private.txt'), 'outer private file'], + [join(source, '.makelore', 'project.json'), JSON.stringify(createCodingProjectConfigV2('2026-08-09T00:00:00.000Z', 'custom', '22222222-2222-4222-8222-222222222222'))], + [join(source, 'package.json'), JSON.stringify({ name: 'inner-game', packageManager: 'npm@11.6.2', scripts: { dev: 'vite' }, devDependencies: { vite: '7.3.1' } })], + [join(source, 'package-lock.json'), JSON.stringify({ name: 'inner-game', lockfileVersion: 3, requires: true, packages: {} })], + [join(source, 'index.html'), '
inner source
'], + [join(source, 'src', 'main.ts'), 'console.log("user source")'], + ]); + for (const [path, content] of userFiles) await writeFile(path, content); + runElectronNodeMock.mockImplementation(async (input: { args: string[]; cwd: string }) => { + expect(input.cwd).not.toBe(root); + expect(input.cwd).not.toBe(source); + expect(await readFile(join(input.cwd, 'index.html'), 'utf8')).toBe('
inner source
'); + expect(JSON.parse(await readFile(join(input.cwd, 'package.json'), 'utf8')).name).toBe('inner-game'); + await expect(readFile(join(input.cwd, '.makelore', 'project.json'))).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(readFile(join(input.cwd, 'private.txt'))).rejects.toMatchObject({ code: 'ENOENT' }); + if (input.args.includes('ci')) { + await mkdir(join(input.cwd, 'node_modules', 'vite', 'bin'), { recursive: true }); + await writeFile(join(input.cwd, 'node_modules', 'vite', 'package.json'), JSON.stringify({ version: '7.3.1' })); + await writeFile(join(input.cwd, 'src', 'main.ts'), 'changed only inside build snapshot'); + } else if (input.args.includes('build')) { + const output = input.args[input.args.indexOf('--outDir') + 1]; + await mkdir(output, { recursive: true }); + await writeFile(join(output, 'index.html'), `
built ${await readFile(join(input.cwd, 'src', 'main.ts'), 'utf8')}
`); + } + return { stdout: '', stderr: '' }; + }); + + const prepare = mode === 'full' ? prepareProjectRelease : prepareProjectReleaseSummary; + const release = await prepare({ projectPath: root, clientVersion: '2.0.0' }); + try { + expect(runElectronNodeMock).toHaveBeenCalledTimes(2); + expect(runElectronNodeMock.mock.calls[0][0].cwd).toBe(runElectronNodeMock.mock.calls[1][0].cwd); + const archive = new AdmZip(release.sourceArchive.path); + expect(archive.getEntries().map(entry => entry.entryName)).toEqual([ + 'index.html', 'niancode.yml', 'package-lock.json', 'package.json', 'src/main.ts', + ]); + expect(archive.readAsText('src/main.ts')).toBe('console.log("user source")'); + expect(release.sourceArchive.summary.manifest.project_type).toBe('interactive_ai_app'); + expect(release.contract.source_digest).toBe(createHash('sha256').update(await readFile(release.sourceArchive.path)).digest('hex')); + expect(new AdmZip(release.builtArchive.path).readAsText('index.html')).toBe('
built changed only inside build snapshot
'); + if ('staticArtifact' in release) { + expect(staticArtifactSnapshotFiles(release.staticArtifact)[0].bytes.toString()).toBe('
built changed only inside build snapshot
'); + } else { + expect(release.sourceArchive).not.toHaveProperty('bytes'); + expect(release.builtArchive).not.toHaveProperty('bytes'); + } + for (const [path, content] of userFiles) expect(await readFile(path, 'utf8')).toBe(content); + await expect(readFile(join(source, 'node_modules', 'vite', 'package.json'))).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(readFile(join(source, 'dist', 'index.html'))).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + await release.dispose(); + } + await expect(readFile(release.sourceArchive.path)).rejects.toMatchObject({ code: 'ENOENT' }); + for (const [path, content] of userFiles) expect(await readFile(path, 'utf8')).toBe(content); + }); + it('builds from the exact source archive and returns the protocol-v1 canonical contract', async () => { root = await mkdtemp(join(tmpdir(), 'release-builder-test-')); await mkdir(join(root, '.makelore'), { recursive: true }); diff --git a/tests/unit/works-project-publish.test.ts b/tests/unit/works-project-publish.test.ts index a9f5a7ac..97b50e7a 100644 --- a/tests/unit/works-project-publish.test.ts +++ b/tests/unit/works-project-publish.test.ts @@ -8,7 +8,8 @@ describe('works project publish guidance', () => { it.each([ ['PUBLISH_PREFLIGHT_RUNTIME_ERROR', '作品打开时发生错误', '项目预览'], ['PUBLISH_PREFLIGHT_BLANK', '作品打开后没有内容', '移动端布局'], - ['PROJECT_FILE_MISSING', '项目文件不完整', '修复当前项目模板'], + ['PROJECT_FILE_MISSING', '项目文件不完整', '检查当前作品工程,补齐发布文件后重试'], + ['PROJECT_SOURCE_UNCONFIRMED', '需要确认提交哪个作品', '确认作品的工程位置,打开作品后再提交'], ['PROJECT_TYPE_UNPUBLISHABLE', '这个项目没有配置发布方式', '新建交互式 AI 应用项目'], ['DEPENDENCY_PREFETCH_FAILED', '旧版提交无法继续处理', '升级 Makelore'], ['OUTPUT_MISSING', '本次构建结果未通过平台校验', '本次构建结果'], @@ -26,6 +27,17 @@ describe('works project publish guidance', () => { expect(failure.nextStep).toContain(nextStep); }); + it.each([ + ['PROJECT_FILE_MISSING', '当前作品工程缺少发布所需的文件,或文件无法读取。'], + ['PROJECT_SOURCE_UNCONFIRMED', '当前项目中无法明确找到唯一的作品工程。'], + ])('explains %s without assuming that a template was deleted or damaged', (code, reason) => { + const failure = describeWorksPublishFailure(code, 400); + + expect(failure.reason).toBe(reason); + expect(JSON.stringify(failure)).not.toMatch(/模板|删除|损坏/); + expect(JSON.stringify(failure)).not.toContain(code); + }); + it.each([ [401, '需要重新登录'], [403, '当前账号不能发布这个作品'],