在 Makelore 构建并预检静态发布产物

This commit is contained in:
2026-08-12 21:19:39 +08:00
parent 3a80625fe2
commit 5b44864265
26 changed files with 1275 additions and 217 deletions

View File

@@ -1,57 +1,28 @@
import { createServer } from 'node:http';
import { test, expect } from './fixtures/electron';
test('the production local preview preflight isolates requests and cleans up its temporary renderers', async ({ electronApp }) => {
let externalRequests = 0;
let externalPageLoads = 0;
const externalServer = createServer((_request, response) => {
externalRequests += 1;
response.writeHead(200, { 'Content-Type': 'application/javascript' });
response.end('globalThis.externalLoaded = true;');
});
await new Promise<void>((resolve, reject) => {
externalServer.once('error', reject);
externalServer.listen(0, '127.0.0.1', () => resolve());
});
const externalAddress = externalServer.address();
if (!externalAddress || typeof externalAddress === 'string') throw new Error('Expected an ephemeral TCP port');
const server = createServer((request, response) => {
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
if (request.url === '/external') externalPageLoads += 1;
const externalScript = request.url === '/external' && externalPageLoads > 1
? `<script src="http://127.0.0.1:${externalAddress.port}/blocked.js"></script>`
: '';
response.end(`<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><main style="width:100vw;height:100vh">Playable</main>${externalScript}`);
});
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => resolve());
});
const address = server.address();
if (!address || typeof address === 'string') throw new Error('Expected an ephemeral TCP port');
try {
const initialWebContentsCount = await electronApp.evaluate(({ webContents }) => webContents.getAllWebContents().length);
const runPreflight = async (url: string) => await electronApp.evaluate(async (_electron, targetUrl) => {
const initialWebContentsCount = await electronApp.evaluate(({ webContents }) => webContents.getAllWebContents().length);
const runPreflight = async (scenario: 'success' | 'external') => await electronApp.evaluate(async (_electron, value) => {
const mainGlobal = globalThis as typeof globalThis & {
__niancodeRunLocalPreviewPreflightE2E?: (value: string) => Promise<{ ok: true }>;
__niancodeRunLocalPreviewPreflightE2E?: (scenario: 'success' | 'external') => Promise<{
ok: boolean; externalRequests: number; code?: string;
}>;
};
if (!mainGlobal.__niancodeRunLocalPreviewPreflightE2E) throw new Error('E2E preflight seam is unavailable');
return await mainGlobal.__niancodeRunLocalPreviewPreflightE2E(targetUrl);
}, url);
return await mainGlobal.__niancodeRunLocalPreviewPreflightE2E(value);
}, scenario);
await expect(runPreflight(`http://127.0.0.1:${address.port}/external`)).rejects.toThrow();
expect(externalRequests).toBe(0);
const blocked = await runPreflight('external');
expect(blocked).toEqual({
ok: false,
externalRequests: 0,
code: 'PUBLISH_PREFLIGHT_RUNTIME_ERROR',
});
const result = await runPreflight(`http://127.0.0.1:${address.port}/`);
const result = await runPreflight('success');
expect(result).toEqual({ ok: true });
await expect.poll(
async () => await electronApp.evaluate(({ webContents }) => webContents.getAllWebContents().length),
).toBe(initialWebContentsCount);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
await new Promise<void>((resolve) => externalServer.close(() => resolve()));
}
expect(result).toEqual({ ok: true, externalRequests: 0 });
await expect.poll(
async () => await electronApp.evaluate(({ webContents }) => webContents.getAllWebContents().length),
).toBe(initialWebContentsCount);
});

View File

@@ -16,6 +16,7 @@ import {
agentBrowserPartition,
} from '@electron/agent-browser/module';
import { AgentBrowserPayloadStore } from '@electron/agent-browser/payload-store';
import { createStaticArtifactSnapshot } from '@electron/services/static-release-server';
class FakeDebugger implements AgentBrowserDebuggerPort {
readonly events = new EventEmitter();
@@ -252,6 +253,32 @@ async function openBrowser(adapter = new FakeAdapter()) {
}
describe('AgentBrowserModule', () => {
it('preflights a Main-owned static artifact without requiring or mutating a browser record', async () => {
const artifact = createStaticArtifactSnapshot([{ path: 'index.html', bytes: Buffer.from('<main>ok</main>') }]);
const adapter = new FakeAdapter();
const module = new AgentBrowserModule(adapter);
try {
await expect(module.preflightStaticArtifact(artifact)).resolves.toEqual({ ok: true });
expect(await module.getSnapshot()).toMatchObject({ state: 'closed', browserId: null });
expect(adapter.views.map((view) => view.bounds)).toEqual([
{ x: 0, y: 0, width: 1280, height: 720 },
{ x: 0, y: 0, width: 390, height: 844 },
]);
expect(adapter.destroyed).toBe(2);
expect(adapter.resetPartitions).toEqual(adapter.partitions);
} finally {
await module.dispose();
}
});
it('reports forged artifact snapshots as infrastructure unavailable', async () => {
const module = new AgentBrowserModule(new FakeAdapter());
await expect(module.preflightStaticArtifact({} as never)).rejects.toMatchObject({
code: 'PUBLISH_PREFLIGHT_UNAVAILABLE',
message: '暂时无法启动作品检查,请稍后重试。',
});
});
it('preflights desktop and mobile viewports in temporary non-persistent profiles', async () => {
const adapter = new FakeAdapter();
adapter.onCreate = (view) => {

View File

@@ -79,11 +79,11 @@ describe('ProjectPublishAction', () => {
render(<ProjectPublishAction project={project} projectType="mini_game" />);
fireEvent.click(screen.getByRole('button', { name: '一键提交审核' }));
expect(screen.getByRole('button', { name: '正在检查并提交…' })).toBeDisabled();
expect(screen.getByRole('button', { name: '正在生成本次构建结果…' })).toBeDisabled();
await flushSubmission();
expect(screen.getByTestId('project-publish-status')).toHaveTextContent(
'本地预览检查已完成,项目已上传,正在等待云端受控构建与平台校验。',
'本次构建结果已通过本地预览检查并上传,正在等待平台校验。',
);
expect(publishWorksProjectSourceMock).toHaveBeenCalledWith({
@@ -120,8 +120,8 @@ describe('ProjectPublishAction', () => {
await advancePoll();
const failure = screen.getByTestId('project-publish-failure');
expect(failure).toHaveTextContent('自动打开作品时发现问题');
expect(failure).toHaveTextContent('下一步:请让开发助手运行 npm run build');
expect(failure).toHaveTextContent('旧版提交无法继续处理');
expect(failure).toHaveTextContent('下一步:升级 Makelore 并重新提交');
expect(failure).not.toHaveTextContent('BROWSER_SMOKE_FAILED');
expect(failure).not.toHaveTextContent('Traceback');
expect(failure).not.toHaveTextContent('/srv/private');

View File

@@ -0,0 +1,71 @@
import { createHash } from 'node:crypto';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createProjectConfig } from '../../shared/project-config';
const runElectronNodeMock = vi.hoisted(() => vi.fn());
vi.mock('@electron/services/publish-runtime', () => ({
resolvePublishRuntime: async () => ({ nodeExecutable: 'electron', npmCli: 'npm-cli.js', nodeVersion: '22.0.0', npmVersion: '11.6.2' }),
runElectronNode: runElectronNodeMock,
PublishRuntimeError: class extends Error {},
}));
import { prepareProjectRelease } from '@electron/services/project-release-builder';
import { startStaticReleaseServer, staticArtifactSnapshotFiles } from '@electron/services/static-release-server';
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('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, '.niancode'), { recursive: true });
await writeFile(join(root, '.niancode', 'project.json'), JSON.stringify(createProjectConfig(new Date().toISOString(), 'mini_game')));
await writeFile(join(root, 'package.json'), JSON.stringify({ name: 'demo', packageManager: 'npm@11.6.2', devDependencies: { vite: '7.3.1' } }));
await writeFile(join(root, 'package-lock.json'), JSON.stringify({ name: 'demo', lockfileVersion: 3, requires: true, packages: {} }));
await writeFile(join(root, 'index.html'), '<main>source</main>');
runElectronNodeMock.mockImplementation(async (input: { args: string[]; cwd: string }) => {
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' }));
} else if (input.args.includes('build')) {
const output = input.args[input.args.indexOf('--outDir') + 1];
await mkdir(join(output, 'assets'), { recursive: true });
await writeFile(join(output, 'index.html'), '<main>built</main>');
await writeFile(join(output, 'assets', '中.js'), 'ok');
}
return { stdout: '', stderr: '' };
});
const release = await prepareProjectRelease({ projectPath: root, clientVersion: '2.0.0' });
try {
expect(release.contract).toMatchObject({
schema_version: 1, entry_path: 'index.html', security_profile: 'works-square-static-sandbox-v1',
toolchain: { client: 'makelore', client_version: '2.0.0', node: '22.0.0', npm: '11.6.2', vite: '7.3.1' },
});
expect(release.contract.files.map((file) => file.path)).toEqual(['assets/中.js', 'index.html']);
const canonical = JSON.stringify(release.contract.files.map(({ path, sha256, size }) => ({ path, sha256, size })));
expect(release.contract.artifact_digest).toBe(createHash('sha256').update(canonical).digest('hex'));
expect(release.contract.source_digest).toBe(createHash('sha256').update(await readFile(release.sourceArchive.path)).digest('hex'));
expect(release.contract.built_archive_digest).toBe(createHash('sha256').update(release.builtArchive.bytes).digest('hex'));
expect(runElectronNodeMock.mock.calls[0][0].args).toEqual([
'npm-cli.js', 'ci', '--ignore-scripts', '--no-audit', '--no-fund',
'--userconfig', expect.stringMatching(/empty-npmrc$/), '--cache', expect.stringMatching(/npm-cache$/),
]);
await rm(release.distRoot, { recursive: true, force: true });
expect(staticArtifactSnapshotFiles(release.staticArtifact).map(({ path, bytes }) => ({
path,
size: bytes.length,
sha256: createHash('sha256').update(bytes).digest('hex'),
}))).toEqual(release.contract.files);
const server = await startStaticReleaseServer(release.staticArtifact);
try {
expect(await (await fetch(server.entryUrl)).text()).toBe('<main>built</main>');
expect(await (await fetch(new URL('assets/%E4%B8%AD.js', server.entryUrl))).text()).toBe('ok');
} finally {
await server.close();
}
} finally { await release.dispose(); }
await expect(readFile(release.sourceArchive.path)).rejects.toThrow();
});
});

View File

@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import { createStaticArtifactSnapshot, startStaticReleaseServer } from '@electron/services/static-release-server';
function fixture() {
const source = [
{ path: 'index.html', bytes: Buffer.from('<main>ok</main>') },
{ path: 'assets/app.js', bytes: Buffer.from('globalThis.ok=true') },
];
return { snapshot: createStaticArtifactSnapshot(source), source };
}
describe('startStaticReleaseServer', () => {
it('serves only exact regular files under an unguessable root', async () => {
const { snapshot, source } = fixture();
source[0].bytes.fill(0);
const server = await startStaticReleaseServer(snapshot);
try {
const entry = new URL(server.entryUrl);
expect(entry.hostname).toBe('127.0.0.1');
expect(entry.pathname).toMatch(/^\/[a-f0-9]{48}\/index\.html$/);
const response = await fetch(server.entryUrl);
expect(await response.text()).toBe('<main>ok</main>');
expect(response.headers.get('cache-control')).toBe('no-store');
expect((await fetch(new URL('missing', server.entryUrl))).status).toBe(404);
expect((await fetch(new URL('.', server.entryUrl))).status).toBe(404);
expect((await fetch(server.entryUrl, { method: 'POST' })).status).toBe(405);
} finally {
await server.close();
}
await expect(fetch(server.entryUrl)).rejects.toThrow();
});
it('rejects traversal and backslashes while serving GET/HEAD from memory', async () => {
const server = await startStaticReleaseServer(fixture().snapshot);
try {
const entry = new URL(server.entryUrl);
const prefix = entry.pathname.slice(0, entry.pathname.lastIndexOf('/') + 1);
for (const path of [`${prefix}%2e%2e%2findex.html`, `${prefix}assets%5capp.js`]) {
const response = await fetch(`${entry.origin}${path}`);
expect(response.status).toBe(404);
}
const scriptUrl = `${entry.origin}${prefix}assets/app.js`;
expect((await fetch(scriptUrl)).headers.get('content-type')).toBe('text/javascript; charset=utf-8');
const head = await fetch(scriptUrl, { method: 'HEAD' });
expect(head.status).toBe(200);
expect(head.headers.get('content-length')).toBe(String(Buffer.byteLength('globalThis.ok=true')));
expect(await head.text()).toBe('');
} finally {
await server.close();
}
});
it('rejects forged, duplicate and unsafe snapshots', async () => {
await expect(startStaticReleaseServer({} as never)).rejects.toThrow('Main-owned');
for (const files of [
[{ path: '../index.html', bytes: Buffer.from('bad') }],
[{ path: 'index.html', bytes: Buffer.from('a') }, { path: 'INDEX.HTML', bytes: Buffer.from('b') }],
]) {
expect(() => createStaticArtifactSnapshot(files)).toThrow();
}
});
it.each([
['source.map', 'application/json; charset=utf-8'],
['sound.ogg', 'audio/ogg'],
['notes.txt', 'text/plain; charset=utf-8'],
])('serves %s with the production media type', async (path, expectedType) => {
const snapshot = createStaticArtifactSnapshot([
{ path: 'index.html', bytes: Buffer.from('<main>ok</main>') },
{ path, bytes: Buffer.from('data') },
]);
const server = await startStaticReleaseServer(snapshot);
try {
const entry = new URL(server.entryUrl);
const prefix = entry.pathname.slice(0, entry.pathname.lastIndexOf('/') + 1);
const response = await fetch(`${entry.origin}${prefix}${path}`);
expect(response.headers.get('content-type')).toBe(expectedType);
} finally {
await server.close();
}
});
});

View File

@@ -6,18 +6,19 @@ import {
describe('works project publish guidance', () => {
it.each([
['PREVIEW_REQUIRED', '请先打开项目预览', '内置浏览器'],
['PUBLISH_PREFLIGHT_RUNTIME_ERROR', '作品打开时发生错误', '项目预览'],
['PUBLISH_PREFLIGHT_BLANK', '作品打开后没有内容', '移动端布局'],
['PROJECT_FILE_MISSING', '项目文件不完整', '修复当前项目模板'],
['PROJECT_TYPE_UNPUBLISHABLE', '这个项目没有配置发布方式', '新建小游戏或小程序项目'],
['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', '自动验收环境暂时不可用', '无需修改项目'],
['DEPENDENCY_PREFETCH_FAILED', '旧版提交无法继续处理', '升级 Makelore'],
['OUTPUT_MISSING', '本次构建结果未通过平台校验', '本次构建结果'],
['RELEASE_STORE_FAILED', '平台校验任务暂未完成', '构建异常'],
['ARTIFACT_STORAGE_UNAVAILABLE', '平台校验任务暂未完成', '构建异常'],
['BUILD_STALE', '平台校验任务暂未完成', '构建异常'],
['BUILD_PIPELINE_UNSUPPORTED', '旧版提交无法继续处理', '升级 Makelore'],
['BROWSER_SMOKE_FAILED', '旧版提交无法继续处理', '升级 Makelore'],
['BROWSER_SMOKE_TIMEOUT', '旧版提交无法继续处理', '升级 Makelore'],
['BROWSER_SMOKE_UNAVAILABLE', '旧版提交无法继续处理', '升级 Makelore'],
])('maps %s to actionable Chinese guidance', (code, title, nextStep) => {
const failure = describeWorksPublishFailure(code);

View File

@@ -13,10 +13,31 @@ import {
import { createProjectConfig } from '../../shared/project-config';
const getValidWorksSquareAccessTokenMock = vi.hoisted(() => vi.fn());
const prepareProjectReleaseMock = vi.hoisted(() => vi.fn());
vi.mock('@electron/services/works-square-session', () => ({
getValidWorksSquareAccessToken: (...args: unknown[]) => getValidWorksSquareAccessTokenMock(...args),
}));
vi.mock('@electron/services/project-release-builder', async (importOriginal) => ({
...await importOriginal<typeof import('@electron/services/project-release-builder')>(),
prepareProjectRelease: prepareProjectReleaseMock,
}));
function preparedRelease(projectPath: string) {
const bytes = Buffer.from('zip');
return {
sourceArchive: { path: join(projectPath, 'private-source.zip'), name: 'project.zip', bytes, summary: {
archivePath: join(projectPath, 'private-source.zip'), archiveName: 'project.zip', sha256: 'a'.repeat(64), fileCount: 5,
sourceBytes: 10, archiveBytes: 3, excludedCount: 0, excludedPaths: [],
manifest: { schema_version: 1, project_type: 'mini_game', kind: 'web', runtime: 'static', build: { preset: 'vite', package_manager: 'npm', entry: 'index.html' } },
} },
builtArchive: { path: join(projectPath, 'private-built.zip'), name: 'built-project.zip', bytes: Buffer.from('built') },
distRoot: join(projectPath, 'private-dist'),
staticArtifact: { files: [{ path: 'index.html', bytes: Buffer.from('built') }] },
contract: { schema_version: 1, entry_path: 'index.html', source_digest: 'a'.repeat(64), built_archive_digest: 'b'.repeat(64), artifact_digest: 'c'.repeat(64), file_count: 1, total_bytes: 5, files: [{ path: 'index.html', size: 5, sha256: 'd'.repeat(64) }], security_profile: 'works-square-static-sandbox-v1', toolchain: { client: 'makelore', client_version: '2.0.0', node: '22', npm: '11.6.2', vite: '7.3.1' } },
dispose: vi.fn(async () => undefined),
};
}
function createResponse() {
@@ -89,6 +110,8 @@ describe('works square host api routes', () => {
beforeEach(() => {
vi.restoreAllMocks();
prepareProjectReleaseMock.mockReset();
prepareProjectReleaseMock.mockImplementation(async ({ projectPath }: { projectPath: string }) => preparedRelease(projectPath));
rotateRendererCapability();
getValidWorksSquareAccessTokenMock.mockReset();
getValidWorksSquareAccessTokenMock.mockResolvedValue('main-owned-access-token');
@@ -1083,7 +1106,9 @@ describe('works square host api routes', () => {
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
const recordSubmitted = vi.fn(async () => undefined);
const preflightCurrentProject = vi.fn(async () => ({ ok: true as const }));
const preflightStaticArtifact = vi.fn(async () => ({ ok: true as const }));
const release = preparedRelease(tempDir);
prepareProjectReleaseMock.mockResolvedValueOnce(release);
const handled = await handleWorksRoutes(
createRendererRequest('POST', { projectId: project.id, project: projectMetadata }),
@@ -1091,7 +1116,7 @@ describe('works square host api routes', () => {
new URL('http://127.0.0.1/api/works/projects/publish-source'),
{
opencodeProjectStore: { listProjects: vi.fn(async () => [project]) },
agentBrowser: { preflightCurrentProject },
agentBrowser: { preflightStaticArtifact },
worksSubmissionBinding: { recordSubmitted },
} as never,
);
@@ -1149,6 +1174,14 @@ describe('works square host api routes', () => {
expect(archive).toBeInstanceOf(File);
expect((archive as File).name).toBe('project.zip');
expect((archive as File).size).toBeGreaterThan(0);
const builtArchive = form.get('built_archive');
expect(builtArchive).toBeInstanceOf(File);
expect((builtArchive as File).name).toBe('built-project.zip');
expect(JSON.parse(String(form.get('artifact_contract')))).toMatchObject({
schema_version: 1,
source_digest: 'a'.repeat(64),
built_archive_digest: 'b'.repeat(64),
});
expect(recordSubmitted).toHaveBeenCalledWith(project.id, {
appId: 'space-cleaner',
versionId: 'version-1',
@@ -1156,13 +1189,18 @@ describe('works square host api routes', () => {
reviewStatus: 'building',
zipSha256: expect.stringMatching(/^[a-f0-9]{64}$/),
});
expect(preflightCurrentProject).toHaveBeenCalledWith(tempDir);
expect(preflightStaticArtifact).toHaveBeenCalledWith(release.staticArtifact);
expect(response.json()).not.toHaveProperty('contract');
expect(JSON.stringify(response.json())).not.toContain('private-dist');
expect(JSON.stringify(response.json())).not.toContain('private-source.zip');
});
it('stops before creating or uploading when the local browser preflight fails', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-preflight-failure-'));
await writePublishableProject(tempDir);
const preflightCurrentProject = vi.fn(async () => {
const release = preparedRelease(tempDir);
prepareProjectReleaseMock.mockResolvedValueOnce(release);
const preflightStaticArtifact = vi.fn(async () => {
throw Object.assign(new Error(`${tempDir} token=secret`), {
code: 'PUBLISH_PREFLIGHT_BLANK',
});
@@ -1186,7 +1224,7 @@ describe('works square host api routes', () => {
opencodeProjectStore: {
listProjects: vi.fn(async () => [{ id: 'project-1', path: tempDir, name: 'space-cleaner' }]),
},
agentBrowser: { preflightCurrentProject },
agentBrowser: { preflightStaticArtifact },
} as never,
);
@@ -1198,10 +1236,31 @@ describe('works square host api routes', () => {
error: '作品打开后没有可见内容。',
});
expect(fetchMock).not.toHaveBeenCalled();
expect(release.dispose).toHaveBeenCalledOnce();
expect(JSON.stringify(response.json())).not.toContain(tempDir);
expect(JSON.stringify(response.json())).not.toContain('token=secret');
});
it('stops before preflight or upload and disposes when the local build fails', async () => {
prepareProjectReleaseMock.mockRejectedValueOnce(Object.assign(new Error('private path token=secret'), {
name: 'ProjectReleaseBuildError',
code: 'LOCAL_BUILD_FAILED',
}));
const preflightStaticArtifact = vi.fn();
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
await handleWorksRoutes(
createRendererRequest('POST', { projectId: 'project-1', project: { app_id: 'space-cleaner', title: 'Space', summary: 'Clean' } }),
response.res,
new URL('http://127.0.0.1/api/works/projects/publish-source'),
{ opencodeProjectStore: { listProjects: vi.fn(async () => [{ id: 'project-1', path: 'private-project' }]) }, agentBrowser: { preflightStaticArtifact } } as never,
);
expect(preflightStaticArtifact).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
expect(JSON.stringify(response.json())).not.toContain('token=secret');
});
it('keeps a confirmed submission successful when the local preview mapping cannot be saved', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-mapping-failure-'));
await writePublishableProject(tempDir);
@@ -1228,7 +1287,7 @@ describe('works square host api routes', () => {
new URL('http://127.0.0.1/api/works/projects/publish-source'),
{
opencodeProjectStore: { listProjects: vi.fn(async () => [project]) },
agentBrowser: { preflightCurrentProject: vi.fn(async () => ({ ok: true })) },
agentBrowser: { preflightStaticArtifact: vi.fn(async () => ({ ok: true })) },
worksSubmissionBinding: {
recordSubmitted: vi.fn(async () => { throw new Error('disk unavailable'); }),
},
@@ -1275,7 +1334,7 @@ describe('works square host api routes', () => {
new URL('http://127.0.0.1/api/works/projects/publish-source'),
{
opencodeProjectStore: { listProjects: vi.fn(async () => [project]) },
agentBrowser: { preflightCurrentProject: vi.fn(async () => ({ ok: true })) },
agentBrowser: { preflightStaticArtifact: vi.fn(async () => ({ ok: true })) },
} as never,
);
@@ -1306,7 +1365,7 @@ describe('works square host api routes', () => {
vi.stubGlobal('fetch', fetchMock);
const ctx = {
opencodeProjectStore: { listProjects: vi.fn(async () => [project]) },
agentBrowser: { preflightCurrentProject: vi.fn(async () => ({ ok: true })) },
agentBrowser: { preflightStaticArtifact: vi.fn(async () => ({ ok: true })) },
} as never;
for (let index = 0; index < 2; index += 1) {
@@ -1363,7 +1422,7 @@ describe('works square host api routes', () => {
new URL('http://127.0.0.1/api/works/projects/publish-source'),
{
opencodeProjectStore: { listProjects: vi.fn(async () => [project]) },
agentBrowser: { preflightCurrentProject: vi.fn(async () => ({ ok: true })) },
agentBrowser: { preflightStaticArtifact: vi.fn(async () => ({ ok: true })) },
} as never,
);
@@ -1402,7 +1461,7 @@ describe('works square host api routes', () => {
new URL('http://127.0.0.1/api/works/projects/publish-source'),
{
opencodeProjectStore: { listProjects: vi.fn(async () => [project]) },
agentBrowser: { preflightCurrentProject: vi.fn(async () => ({ ok: true })) },
agentBrowser: { preflightStaticArtifact: vi.fn(async () => ({ ok: true })) },
} as never,
);
@@ -1418,6 +1477,22 @@ describe('works square host api routes', () => {
expect(cancelSpy).toHaveBeenCalledOnce();
});
it('projects the nested FastAPI client protocol error without exposing its detail', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'makelore-protocol-required-'));
await writePublishableProject(tempDir);
const upstream = new Response(JSON.stringify({ detail: { code: 'CLIENT_BUILD_PROTOCOL_REQUIRED', message: 'private token=secret' } }), { status: 422 });
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(upstream));
const response = createResponse();
await handleWorksRoutes(
createRendererRequest('POST', { projectId: 'project-1', project: { app_id: 'space-cleaner', title: 'Space', summary: 'Clean' } }),
response.res,
new URL('http://127.0.0.1/api/works/projects/publish-source'),
{ opencodeProjectStore: { listProjects: vi.fn(async () => [{ id: 'project-1', path: tempDir }]) }, agentBrowser: { preflightStaticArtifact: vi.fn(async () => ({ ok: true })) } } as never,
);
expect(response.json()).toEqual({ success: false, status: 422, code: 'CLIENT_BUILD_PROTOCOL_REQUIRED', error: '请升级 Makelore 并重新提交。' });
expect(JSON.stringify(response.json())).not.toContain('token=secret');
});
it('fails safely before packaging when the Main session is unavailable', async () => {
getValidWorksSquareAccessTokenMock.mockResolvedValueOnce(null);
const fetchMock = vi.fn();