1448 lines
48 KiB
TypeScript
1448 lines
48 KiB
TypeScript
import { EventEmitter } from 'node:events';
|
|
import type { IncomingMessage, ServerResponse } from 'http';
|
|
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';
|
|
import { handleWorksRoutes } from '@electron/api/routes/works';
|
|
import { createProjectConfig } from '../../shared/project-config';
|
|
|
|
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),
|
|
}));
|
|
|
|
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 = {
|
|
statusCode: 0,
|
|
setHeader: vi.fn(),
|
|
end: vi.fn((chunk?: string) => {
|
|
if (chunk) chunks.push(chunk);
|
|
}),
|
|
} as unknown as ServerResponse;
|
|
|
|
return {
|
|
res,
|
|
get statusCode() {
|
|
return res.statusCode;
|
|
},
|
|
json: () => JSON.parse(chunks.join('')) as unknown,
|
|
};
|
|
}
|
|
|
|
function createRequest(method: string, body?: unknown, headers: Record<string, string> = {}): IncomingMessage {
|
|
const req = new EventEmitter();
|
|
Object.assign(req, {
|
|
method,
|
|
headers: body === undefined ? headers : { 'content-type': 'application/json', ...headers },
|
|
[Symbol.asyncIterator]: async function* () {
|
|
if (body !== undefined) {
|
|
yield Buffer.from(JSON.stringify(body));
|
|
}
|
|
},
|
|
});
|
|
return req as IncomingMessage;
|
|
}
|
|
|
|
async function writePublishableProject(projectPath: string): Promise<void> {
|
|
await mkdir(join(projectPath, 'src'), { recursive: true });
|
|
await mkdir(join(projectPath, '.niancode'), { recursive: true });
|
|
await writeFile(
|
|
join(projectPath, '.niancode', 'project.json'),
|
|
JSON.stringify(createProjectConfig('2026-08-09T00:00:00.000Z', 'mini_game')),
|
|
'utf8',
|
|
);
|
|
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;
|
|
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
readWorksPublishFileMock.mockReset();
|
|
readWorksDeployCheckMock.mockReset();
|
|
getValidWorksSquareAccessTokenMock.mockReset();
|
|
getValidWorksSquareAccessTokenMock.mockResolvedValue('main-owned-access-token');
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (tempDir) {
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
tempDir = null;
|
|
}
|
|
});
|
|
|
|
it('lists public projects through the Works Square API', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValueOnce(
|
|
new Response(JSON.stringify({
|
|
items: [
|
|
{
|
|
app_id: 'space-cleaner',
|
|
title: '太空清洁队',
|
|
summary: '收集漂浮垃圾的小游戏。',
|
|
cover_url: null,
|
|
category: 'game',
|
|
age_band: '8-12',
|
|
difficulty: 'beginner',
|
|
updated_at: '2026-06-20T22:55:37.790408+08:00',
|
|
},
|
|
],
|
|
next_cursor: null,
|
|
limit: 24,
|
|
}), { 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?q=space&category=game&limit=24'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(handled).toBe(true);
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json()).toEqual({
|
|
success: true,
|
|
page: {
|
|
items: [
|
|
{
|
|
app_id: 'space-cleaner',
|
|
title: '太空清洁队',
|
|
summary: '收集漂浮垃圾的小游戏。',
|
|
cover_url: null,
|
|
category: 'game',
|
|
age_band: '8-12',
|
|
difficulty: 'beginner',
|
|
updated_at: '2026-06-20T22:55:37.790408+08:00',
|
|
},
|
|
],
|
|
next_cursor: null,
|
|
limit: 24,
|
|
},
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'https://square.nianxx.cn/api/projects?q=space&category=game&limit=24',
|
|
{ method: 'GET' },
|
|
);
|
|
});
|
|
|
|
it('lists public assets through the Works Square API', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValueOnce(
|
|
new Response(JSON.stringify({
|
|
items: [
|
|
{
|
|
slug: 'tiny-farm',
|
|
title: 'Tiny Farm',
|
|
summary: 'Farm pixel resource package',
|
|
category: '2D',
|
|
source: 'Kenney',
|
|
tags: ['farm', 'pixel'],
|
|
preview_url: 'https://example.com/tiny-farm.png',
|
|
image_urls: ['https://example.com/tiny-farm.png'],
|
|
archive_size_bytes: 204800,
|
|
extracted_file_count: 141,
|
|
published_at: '2026-07-09T00:00:00Z',
|
|
updated_at: '2026-07-09T08:30:00Z',
|
|
},
|
|
],
|
|
next_cursor: null,
|
|
limit: 12,
|
|
}), { 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/assets?q=farm&category=2D&tag=pixel&source=Kenney&limit=12'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(handled).toBe(true);
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json()).toEqual({
|
|
success: true,
|
|
assets: {
|
|
items: [
|
|
{
|
|
slug: 'tiny-farm',
|
|
title: 'Tiny Farm',
|
|
summary: 'Farm pixel resource package',
|
|
category: '2D',
|
|
source: 'Kenney',
|
|
tags: ['farm', 'pixel'],
|
|
preview_url: 'https://example.com/tiny-farm.png',
|
|
image_urls: ['https://example.com/tiny-farm.png'],
|
|
archive_size_bytes: 204800,
|
|
extracted_file_count: 141,
|
|
published_at: '2026-07-09T00:00:00Z',
|
|
updated_at: '2026-07-09T08:30:00Z',
|
|
},
|
|
],
|
|
next_cursor: null,
|
|
limit: 12,
|
|
},
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'https://square.nianxx.cn/api/assets?q=farm&category=2D&tag=pixel&source=Kenney&limit=12',
|
|
{ method: 'GET' },
|
|
);
|
|
});
|
|
|
|
it('loads a public asset detail through the Works Square API', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValueOnce(
|
|
new Response(JSON.stringify({
|
|
slug: 'tiny-farm',
|
|
title: 'Tiny Farm',
|
|
summary: 'Farm pixel resource package',
|
|
category: '2D',
|
|
source: 'Kenney',
|
|
tags: ['farm'],
|
|
preview_url: null,
|
|
image_urls: [],
|
|
archive_size_bytes: null,
|
|
extracted_file_count: null,
|
|
published_at: null,
|
|
updated_at: null,
|
|
}), { 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/assets/tiny-farm'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(handled).toBe(true);
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json()).toEqual({
|
|
success: true,
|
|
asset: {
|
|
slug: 'tiny-farm',
|
|
title: 'Tiny Farm',
|
|
summary: 'Farm pixel resource package',
|
|
category: '2D',
|
|
source: 'Kenney',
|
|
tags: ['farm'],
|
|
preview_url: null,
|
|
image_urls: [],
|
|
archive_size_bytes: null,
|
|
extracted_file_count: null,
|
|
published_at: null,
|
|
updated_at: null,
|
|
},
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'https://square.nianxx.cn/api/assets/tiny-farm',
|
|
{ method: 'GET' },
|
|
);
|
|
});
|
|
|
|
it('downloads a public asset zip into a selected local project', async () => {
|
|
tempDir = await mkdtemp(join(tmpdir(), 'niancode-works-asset-download-'));
|
|
const fetchMock = vi.fn()
|
|
.mockResolvedValueOnce(new Response(null, {
|
|
status: 307,
|
|
headers: {
|
|
Location: 'https://cdn.example.com/tiny-farm.zip',
|
|
},
|
|
}))
|
|
.mockResolvedValueOnce(new Response(new Uint8Array(Buffer.from('zip bytes')), { status: 200 }));
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const response = createResponse();
|
|
const listProjects = vi.fn().mockResolvedValue([
|
|
{
|
|
id: 'prj_123',
|
|
path: tempDir,
|
|
name: 'Tiny Game',
|
|
createdAt: '2026-07-09T00:00:00Z',
|
|
updatedAt: '2026-07-09T00:00:00Z',
|
|
lastOpenedAt: '2026-07-09T00:00:00Z',
|
|
},
|
|
]);
|
|
|
|
const handled = await handleWorksRoutes(
|
|
createRequest('POST', { projectId: 'prj_123' }),
|
|
response.res,
|
|
new URL('http://127.0.0.1/api/works/assets/tiny-farm/download'),
|
|
{ opencodeProjectStore: { listProjects } } as never,
|
|
);
|
|
|
|
const expectedFilePath = join(tempDir, 'assets', 'resource-square', 'tiny-farm.zip');
|
|
|
|
expect(handled).toBe(true);
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json()).toEqual({
|
|
success: true,
|
|
download: {
|
|
slug: 'tiny-farm',
|
|
filePath: expectedFilePath,
|
|
relativePath: 'assets/resource-square/tiny-farm.zip',
|
|
bytesWritten: 9,
|
|
},
|
|
});
|
|
expect(await readFile(expectedFilePath, 'utf8')).toBe('zip bytes');
|
|
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
1,
|
|
'https://square.nianxx.cn/api/assets/tiny-farm/download',
|
|
{ method: 'GET', redirect: 'manual' },
|
|
);
|
|
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
'https://cdn.example.com/tiny-farm.zip',
|
|
{ method: 'GET', redirect: 'manual' },
|
|
);
|
|
});
|
|
|
|
it('creates project metadata with the current SSO access token', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValueOnce(
|
|
new Response(JSON.stringify({
|
|
app_id: 'space-cleaner',
|
|
title: '太空清洁队',
|
|
summary: '收集漂浮垃圾的小游戏。',
|
|
cover_url: null,
|
|
category: 'game',
|
|
age_band: '8-12',
|
|
difficulty: 'beginner',
|
|
updated_at: '2026-06-20T22:55:37.790408+08:00',
|
|
}), { status: 201 }),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const response = createResponse();
|
|
|
|
const handled = await handleWorksRoutes(
|
|
createRequest('POST', {
|
|
accessToken: 'access-token',
|
|
project: {
|
|
app_id: 'space-cleaner',
|
|
title: '太空清洁队',
|
|
summary: '收集漂浮垃圾的小游戏。',
|
|
cover_url: null,
|
|
category: 'game',
|
|
age_band: '8-12',
|
|
difficulty: 'beginner',
|
|
},
|
|
}),
|
|
response.res,
|
|
new URL('http://127.0.0.1/api/works/projects'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(handled).toBe(true);
|
|
expect(response.statusCode).toBe(201);
|
|
expect(response.json()).toEqual({
|
|
success: true,
|
|
project: {
|
|
app_id: 'space-cleaner',
|
|
title: '太空清洁队',
|
|
summary: '收集漂浮垃圾的小游戏。',
|
|
cover_url: null,
|
|
category: 'game',
|
|
age_band: '8-12',
|
|
difficulty: 'beginner',
|
|
updated_at: '2026-06-20T22:55:37.790408+08:00',
|
|
},
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'https://square.nianxx.cn/api/projects',
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: 'Bearer access-token',
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
app_id: 'space-cleaner',
|
|
title: '太空清洁队',
|
|
summary: '收集漂浮垃圾的小游戏。',
|
|
cover_url: null,
|
|
category: 'game',
|
|
age_band: '8-12',
|
|
difficulty: 'beginner',
|
|
}),
|
|
},
|
|
);
|
|
});
|
|
|
|
it('lists the current user projects through the Works Square API', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValueOnce(
|
|
new Response(JSON.stringify({
|
|
items: [
|
|
{
|
|
app_id: 'space-cleaner',
|
|
title: 'Space Cleaner',
|
|
summary: 'Catch space trash',
|
|
cover_url: null,
|
|
category: 'game',
|
|
age_band: '8-12',
|
|
difficulty: 'beginner',
|
|
status: 'draft',
|
|
updated_at: '2026-06-20T22:55:37.790408+08:00',
|
|
playable: false,
|
|
runtime_url: null,
|
|
},
|
|
],
|
|
next_cursor: null,
|
|
limit: 50,
|
|
}), { status: 200 }),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const response = createResponse();
|
|
|
|
const handled = await handleWorksRoutes(
|
|
createRequest('GET', undefined, { 'x-niancode-access-token': 'access-token' }),
|
|
response.res,
|
|
new URL('http://127.0.0.1/api/works/projects/mine?limit=50'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(handled).toBe(true);
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json()).toEqual({
|
|
success: true,
|
|
page: {
|
|
items: [
|
|
{
|
|
app_id: 'space-cleaner',
|
|
title: 'Space Cleaner',
|
|
summary: 'Catch space trash',
|
|
cover_url: null,
|
|
category: 'game',
|
|
age_band: '8-12',
|
|
difficulty: 'beginner',
|
|
status: 'draft',
|
|
updated_at: '2026-06-20T22:55:37.790408+08:00',
|
|
playable: false,
|
|
runtime_url: null,
|
|
},
|
|
],
|
|
next_cursor: null,
|
|
limit: 50,
|
|
},
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'https://square.nianxx.cn/api/projects/mine?limit=50',
|
|
{
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: 'Bearer access-token',
|
|
},
|
|
},
|
|
);
|
|
});
|
|
|
|
it('loads current token plan usage through the Works Square API', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValueOnce(
|
|
new Response(JSON.stringify({
|
|
five_hour_remaining_percent: 72.5,
|
|
weekly_remaining_percent: 48,
|
|
}), { status: 200 }),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const response = createResponse();
|
|
|
|
const handled = await handleWorksRoutes(
|
|
createRequest('GET', undefined, { 'x-niancode-access-token': 'access-token' }),
|
|
response.res,
|
|
new URL('http://127.0.0.1/api/works/billing/token-usage'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(handled).toBe(true);
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json()).toEqual({
|
|
success: true,
|
|
usage: {
|
|
five_hour_remaining_percent: 72.5,
|
|
weekly_remaining_percent: 48,
|
|
},
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'https://square.nianxx.cn/api/billing/token-usage',
|
|
{
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: 'Bearer access-token',
|
|
},
|
|
},
|
|
);
|
|
});
|
|
|
|
it('loads the current Agent Profile through the Works Square API', async () => {
|
|
const profile = {
|
|
display_name: '小泥',
|
|
age: 18,
|
|
gender: 'female',
|
|
share_age_with_agents: true,
|
|
share_gender_with_agents: true,
|
|
analysis_enabled: true,
|
|
completed: true,
|
|
version: 2,
|
|
updated_at: '2026-07-12T00:00:00Z',
|
|
};
|
|
const fetchMock = vi.fn().mockResolvedValueOnce(
|
|
new Response(JSON.stringify(profile), { status: 200 }),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const response = createResponse();
|
|
|
|
const handled = await handleWorksRoutes(
|
|
createRequest('GET', undefined, { 'x-niancode-access-token': 'access-token' }),
|
|
response.res,
|
|
new URL('http://127.0.0.1/api/works/user/agent-profile'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(handled).toBe(true);
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json()).toEqual({ success: true, profile });
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'https://square.nianxx.cn/api/user/agent-profile',
|
|
{
|
|
method: 'GET',
|
|
headers: { Authorization: 'Bearer access-token' },
|
|
},
|
|
);
|
|
});
|
|
|
|
it('keeps Agent Profile version-conflict detail available to the renderer', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValueOnce(
|
|
new Response(JSON.stringify({
|
|
detail: {
|
|
code: 'agent_profile_version_conflict',
|
|
current_version: 4,
|
|
},
|
|
}), { status: 409 }),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const response = createResponse();
|
|
|
|
const handled = await handleWorksRoutes(
|
|
createRequest('PUT', {
|
|
display_name: '小泥',
|
|
age: null,
|
|
gender: null,
|
|
share_age_with_agents: false,
|
|
share_gender_with_agents: false,
|
|
analysis_enabled: true,
|
|
version: 3,
|
|
}, { 'x-niancode-access-token': 'access-token' }),
|
|
response.res,
|
|
new URL('http://127.0.0.1/api/works/user/agent-profile'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(handled).toBe(true);
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json()).toEqual({
|
|
success: false,
|
|
status: 409,
|
|
error: 'Agent Profile request failed (409)',
|
|
detail: {
|
|
code: 'agent_profile_version_conflict',
|
|
current_version: 4,
|
|
},
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'https://square.nianxx.cn/api/user/agent-profile',
|
|
{
|
|
method: 'PUT',
|
|
headers: {
|
|
Authorization: 'Bearer access-token',
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
display_name: '小泥',
|
|
age: null,
|
|
gender: null,
|
|
share_age_with_agents: false,
|
|
share_gender_with_agents: false,
|
|
analysis_enabled: true,
|
|
version: 3,
|
|
}),
|
|
},
|
|
);
|
|
});
|
|
|
|
it('submits an image generation task through the Works Square AI gateway API', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValueOnce(
|
|
new Response(JSON.stringify({
|
|
task_id: 'task_1',
|
|
status: 'queued',
|
|
model: 'gpt-image-2',
|
|
result_urls: [],
|
|
}), { status: 202 }),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const response = createResponse();
|
|
|
|
const handled = await handleWorksRoutes(
|
|
createRequest('POST', {
|
|
prompt: '未来城市里的儿童编程课海报',
|
|
image_urls: [],
|
|
size: '1:1',
|
|
quality: 'medium',
|
|
resolution: '2K',
|
|
}, { 'x-niancode-access-token': 'access-token' }),
|
|
response.res,
|
|
new URL('http://127.0.0.1/api/works/ai-gateway/images/generations'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(handled).toBe(true);
|
|
expect(response.statusCode).toBe(202);
|
|
expect(response.json()).toEqual({
|
|
success: true,
|
|
job: {
|
|
task_id: 'task_1',
|
|
status: 'queued',
|
|
model: 'gpt-image-2',
|
|
result_urls: [],
|
|
},
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'https://square.nianxx.cn/api/ai-gateway/images/generations',
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: 'Bearer access-token',
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
prompt: '未来城市里的儿童编程课海报',
|
|
image_urls: [],
|
|
size: '1:1',
|
|
quality: 'medium',
|
|
resolution: '2K',
|
|
}),
|
|
},
|
|
);
|
|
});
|
|
|
|
it('loads an image generation task through the Works Square AI gateway API', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValueOnce(
|
|
new Response(JSON.stringify({
|
|
task_id: 'task_1',
|
|
status: 'succeeded',
|
|
model: 'gpt-image-2',
|
|
result_urls: ['https://example.com/result.png'],
|
|
}), { status: 200 }),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const response = createResponse();
|
|
|
|
const handled = await handleWorksRoutes(
|
|
createRequest('GET', undefined, { 'x-niancode-access-token': 'access-token' }),
|
|
response.res,
|
|
new URL('http://127.0.0.1/api/works/ai-gateway/images/tasks/task_1'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(handled).toBe(true);
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json()).toEqual({
|
|
success: true,
|
|
job: {
|
|
task_id: 'task_1',
|
|
status: 'succeeded',
|
|
model: 'gpt-image-2',
|
|
result_urls: ['https://example.com/result.png'],
|
|
},
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'https://square.nianxx.cn/api/ai-gateway/images/tasks/task_1',
|
|
{
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: 'Bearer access-token',
|
|
},
|
|
},
|
|
);
|
|
});
|
|
|
|
it('loads current user project status with uploaded versions', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValueOnce(
|
|
new Response(JSON.stringify({
|
|
project: {
|
|
app_id: 'space-cleaner',
|
|
title: 'Space Cleaner',
|
|
summary: 'Catch space trash',
|
|
cover_url: null,
|
|
category: 'game',
|
|
age_band: '8-12',
|
|
difficulty: 'beginner',
|
|
status: 'draft',
|
|
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',
|
|
version_name: 'v1.1.0',
|
|
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);
|
|
const response = createResponse();
|
|
|
|
const handled = await handleWorksRoutes(
|
|
createRequest('GET', undefined, { 'x-niancode-access-token': 'access-token' }),
|
|
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: {
|
|
app_id: 'space-cleaner',
|
|
title: 'Space Cleaner',
|
|
summary: 'Catch space trash',
|
|
cover_url: null,
|
|
category: 'game',
|
|
age_band: '8-12',
|
|
difficulty: 'beginner',
|
|
status: 'draft',
|
|
updated_at: '2026-06-20T22:55:37.790408+08:00',
|
|
playable: false,
|
|
runtime_url: null,
|
|
},
|
|
latest_version: {
|
|
id: 'version-new',
|
|
version_name: 'v1.1.0',
|
|
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',
|
|
{
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: 'Bearer access-token',
|
|
},
|
|
},
|
|
);
|
|
});
|
|
|
|
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');
|
|
await writeFile(zipPath, Buffer.from('zip bytes'));
|
|
const fetchMock = vi.fn().mockResolvedValueOnce(
|
|
new Response(JSON.stringify({
|
|
version_id: 'b2f6f1dd-cf4b-4f03-a4b8-a71e2c8dd5f1',
|
|
review_status: 'building',
|
|
}), { status: 201 }),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const response = createResponse();
|
|
const project = { id: 'project-1', path: tempDir, name: 'space-cleaner' };
|
|
readWorksPublishFileMock.mockResolvedValue({
|
|
status: 'ready',
|
|
filePath: join(tempDir, 'works-publish.json'),
|
|
publish: {
|
|
app_id: 'space-cleaner',
|
|
title: '太空清洁队',
|
|
summary: '收集漂浮垃圾的小游戏。',
|
|
category: 'game',
|
|
age_band: '8-12',
|
|
difficulty: 'beginner',
|
|
version_name: 'v1.0.0',
|
|
change_log: 'First submitted version',
|
|
zip_file_path: zipPath,
|
|
},
|
|
});
|
|
readWorksDeployCheckMock.mockResolvedValue({
|
|
status: deployStatus,
|
|
filePath: join(tempDir, 'works-deploy-check.json'),
|
|
checks: {},
|
|
});
|
|
|
|
const handled = await handleWorksRoutes(
|
|
createRequest('POST', {
|
|
accessToken: 'access-token',
|
|
projectId: project.id,
|
|
versionName: 'v1.0.0',
|
|
changeLog: 'First submitted version',
|
|
zipFilePath: zipPath,
|
|
}),
|
|
response.res,
|
|
new URL('http://127.0.0.1/api/works/projects/space-cleaner/versions/upload'),
|
|
{ opencodeProjectStore: { listProjects: vi.fn(async () => [project]) } } as never,
|
|
);
|
|
|
|
expect(handled).toBe(true);
|
|
expect(response.statusCode).toBe(201);
|
|
expect(response.json()).toEqual({
|
|
success: true,
|
|
upload: {
|
|
version_id: 'b2f6f1dd-cf4b-4f03-a4b8-a71e2c8dd5f1',
|
|
review_status: 'building',
|
|
},
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledOnce();
|
|
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
expect(url).toBe('https://square.nianxx.cn/api/projects/space-cleaner/versions/upload');
|
|
expect(init.method).toBe('POST');
|
|
expect(init.headers).toEqual({ Authorization: 'Bearer access-token' });
|
|
expect(init.body).toBeInstanceOf(FormData);
|
|
|
|
const form = init.body as FormData;
|
|
expect(form.get('version_name')).toBe('v1.0.0');
|
|
expect(form.get('change_log')).toBe('First submitted version');
|
|
const archive = form.get('archive');
|
|
expect(archive).toBeInstanceOf(File);
|
|
expect((archive as File).name).toBe('project.zip');
|
|
expect((archive as File).type).toBe('application/zip');
|
|
});
|
|
|
|
it('blocks upload when the deployment check is not PASS', async () => {
|
|
tempDir = await mkdtemp(join(tmpdir(), 'niancode-works-upload-blocked-'));
|
|
const zipPath = join(tempDir, 'project.zip');
|
|
await writeFile(zipPath, Buffer.from('zip bytes'));
|
|
const project = { id: 'project-blocked', path: tempDir, name: 'blocked' };
|
|
readWorksPublishFileMock.mockResolvedValue({
|
|
status: 'ready',
|
|
filePath: join(tempDir, 'works-publish.json'),
|
|
publish: {
|
|
app_id: 'blocked-app',
|
|
title: '阻断测试',
|
|
summary: '阻断测试',
|
|
category: 'web',
|
|
age_band: '10-12',
|
|
difficulty: 'beginner',
|
|
version_name: 'v1.0.0',
|
|
change_log: '阻断测试',
|
|
zip_file_path: zipPath,
|
|
},
|
|
});
|
|
readWorksDeployCheckMock.mockResolvedValue({
|
|
status: 'blocked',
|
|
filePath: join(tempDir, 'works-deploy-check.json'),
|
|
error: 'HTTP smoke 失败',
|
|
});
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const response = createResponse();
|
|
|
|
const handled = await handleWorksRoutes(
|
|
createRequest('POST', {
|
|
accessToken: 'access-token',
|
|
projectId: project.id,
|
|
versionName: 'v1.0.0',
|
|
changeLog: '阻断测试',
|
|
zipFilePath: zipPath,
|
|
}),
|
|
response.res,
|
|
new URL('http://127.0.0.1/api/works/projects/blocked-app/versions/upload'),
|
|
{ opencodeProjectStore: { listProjects: vi.fn(async () => [project]) } } as never,
|
|
);
|
|
|
|
expect(handled).toBe(true);
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.json()).toEqual({ success: false, error: 'BLOCKED: HTTP smoke 失败' });
|
|
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 recordSubmitted = vi.fn(async () => undefined);
|
|
|
|
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]) },
|
|
worksCloudDeployment: { recordSubmitted },
|
|
} 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);
|
|
expect(recordSubmitted).toHaveBeenCalledWith(project.id, {
|
|
appId: 'space-cleaner',
|
|
versionId: 'version-1',
|
|
versionName: 'v2.3.4',
|
|
reviewStatus: 'building',
|
|
zipSha256: expect.stringMatching(/^[a-f0-9]{64}$/),
|
|
});
|
|
});
|
|
|
|
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);
|
|
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]) },
|
|
worksCloudDeployment: {
|
|
recordSubmitted: vi.fn(async () => { throw new Error('disk unavailable'); }),
|
|
},
|
|
} as never,
|
|
);
|
|
|
|
expect(response.statusCode).toBe(201);
|
|
expect(response.json()).toMatchObject({
|
|
success: true,
|
|
upload: { version_id: 'version-1', review_status: 'building' },
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
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({
|
|
text: 'open the file',
|
|
model: 'gpt-4o-mini-transcribe',
|
|
}), { status: 200 }),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const response = createResponse();
|
|
|
|
const handled = await handleWorksRoutes(
|
|
createRequest('POST', {
|
|
accessToken: 'access-token',
|
|
audioBase64: Buffer.from('wav bytes').toString('base64'),
|
|
language: 'zh',
|
|
prompt: 'NianCode command',
|
|
}),
|
|
response.res,
|
|
new URL('http://127.0.0.1/api/works/speech/transcriptions'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(handled).toBe(true);
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json()).toEqual({
|
|
success: true,
|
|
transcription: {
|
|
text: 'open the file',
|
|
model: 'gpt-4o-mini-transcribe',
|
|
},
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledOnce();
|
|
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
expect(url).toBe('https://square.nianxx.cn/api/speech/transcriptions');
|
|
expect(init.method).toBe('POST');
|
|
expect(init.headers).toEqual({ Authorization: 'Bearer access-token' });
|
|
expect(init.body).toBeInstanceOf(FormData);
|
|
|
|
const form = init.body as FormData;
|
|
expect(form.get('language')).toBe('zh');
|
|
expect(form.get('prompt')).toBe('NianCode command');
|
|
const audio = form.get('audio');
|
|
expect(audio).toBeInstanceOf(File);
|
|
expect((audio as File).name).toBe('voice.wav');
|
|
expect((audio as File).type).toBe('audio/wav');
|
|
expect(await (audio as File).text()).toBe('wav bytes');
|
|
});
|
|
|
|
it('lists uploaded versions for a project with the current SSO access token', async () => {
|
|
const fetchMock = vi.fn().mockResolvedValueOnce(
|
|
new Response(JSON.stringify({
|
|
items: [
|
|
{
|
|
id: 'ver_1',
|
|
version_name: 'v1.0.0',
|
|
review_status: 'building',
|
|
change_log: 'First submitted version',
|
|
build_job_id: 'job_1',
|
|
created_at: '2026-06-21T10:30:00+08:00',
|
|
},
|
|
],
|
|
}), { status: 200 }),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const response = createResponse();
|
|
|
|
const handled = await handleWorksRoutes(
|
|
createRequest('GET', undefined, { 'x-niancode-access-token': 'access-token' }),
|
|
response.res,
|
|
new URL('http://127.0.0.1/api/works/projects/space-cleaner/versions'),
|
|
{} as never,
|
|
);
|
|
|
|
expect(handled).toBe(true);
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json()).toEqual({
|
|
success: true,
|
|
versions: {
|
|
items: [
|
|
{
|
|
id: 'ver_1',
|
|
version_name: 'v1.0.0',
|
|
review_status: 'building',
|
|
change_log: 'First submitted version',
|
|
build_job_id: 'job_1',
|
|
created_at: '2026-06-21T10:30:00+08:00',
|
|
},
|
|
],
|
|
},
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'https://square.nianxx.cn/api/projects/space-cleaner/versions',
|
|
{
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: 'Bearer access-token',
|
|
},
|
|
},
|
|
);
|
|
});
|
|
});
|