在 Makelore 构建并预检静态发布产物
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
71
tests/unit/project-release-builder.test.ts
Normal file
71
tests/unit/project-release-builder.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
82
tests/unit/static-release-server.test.ts
Normal file
82
tests/unit/static-release-server.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user