合并客户端一键发布链路
需求:将 Main-owned 打包提交、状态轮询和安全错误投影并入登录集成候选。 实现:合并已验证的发布功能提交,冲突保留登录续期与发布安全边界。 # Conflicts: # README.md
This commit is contained in:
@@ -79,6 +79,7 @@ describe('ProjectConfiguration', () => {
|
||||
render(<MemoryRouter><ProjectConfiguration /></MemoryRouter>);
|
||||
|
||||
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);
|
||||
|
||||
298
tests/unit/project-packager.test.ts
Normal file
298
tests/unit/project-packager.test.ts
Normal file
@@ -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<void>),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>();
|
||||
return {
|
||||
...actual,
|
||||
open: async (...args: Parameters<typeof actual.open>) => {
|
||||
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<string> {
|
||||
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'), '<div id="app"></div>', '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<ProjectPackageError>({
|
||||
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<ProjectPackageError>({
|
||||
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<ProjectPackageError>({
|
||||
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<ProjectPackageError>({
|
||||
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<ProjectPackageError>({ 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<ProjectPackageError>({ code: 'PROJECT_TOO_LARGE' });
|
||||
});
|
||||
});
|
||||
210
tests/unit/project-publish-action.test.tsx
Normal file
210
tests/unit/project-publish-action.test.tsx
Normal file
@@ -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(<ProjectPublishAction project={project} />);
|
||||
|
||||
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(<ProjectPublishAction project={project} />);
|
||||
|
||||
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(<ProjectPublishAction project={project} />);
|
||||
|
||||
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(<ProjectPublishAction project={project} />);
|
||||
|
||||
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(<ProjectPublishAction project={project} />);
|
||||
|
||||
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(<ProjectPublishAction project={project} />);
|
||||
|
||||
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(<ProjectPublishAction project={{ ...project, name: longName }} />);
|
||||
|
||||
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 创建的作品。`);
|
||||
});
|
||||
});
|
||||
54
tests/unit/works-project-publish.test.ts
Normal file
54
tests/unit/works-project-publish.test.ts
Normal file
@@ -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: '新名称' }));
|
||||
});
|
||||
});
|
||||
@@ -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<string, s
|
||||
return req as IncomingMessage;
|
||||
}
|
||||
|
||||
async function writePublishableProject(projectPath: string): Promise<void> {
|
||||
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'), '<div id="app"></div>', '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<string, string>)['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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user