合并 master 并接入 DirectProject 新聊天架构
- 合并 origin/master(304 个提交:DirectProject 聊天容器重构、Project Supervisor 退役、策划附件导入、CI 隔离编译缓存等)。 - 接受 master 对 ProjectSupervisorView / SupervisorChatOnlyView 的退役与预览快捷测试收敛;发布入口改由 DirectProject 聊天头承载。 - DirectProjectChatHeader 新增「发布到游戏广场」入口(无回调不渲染、回合忙态禁用),DirectProjectChatView 透传 onRequestGamePublish。 - App.tsx 继续由工作台壳持有试玩包导出与 GameDistributionPublishPanel,沿用 project.export_package 权限确认队列;check-config 把该命令从 native-only 清单移回 App invoke。 - 后台游戏审核 API / 类型 / 路由测试与 master 新增的 AGC 模板管理按双方保留合并,并修掉拼接造成的接口与用例闭合缺陷。 - 修正 master 自带的 viteProxyConfig 断言:/api/creation-entry 属退役路由,测试改为断言不进入代理。 - 记录合并踩坑:语法结构内部的冲突不能简单按「双方保留」拼接,必须按某一侧骨架重建并跑 tsc 与单文件测试。 - 验证:全量 vitest 393 文件 / 4374 用例通过,root / AGC / admin-web 三端 typecheck,cargo check 与游戏分发 Rust 测试,encoding、doc-index、rustfmt、SpacetimeDB schema guard。
This commit is contained in:
@@ -3,8 +3,10 @@ import { afterEach, expect, test, vi } from 'vitest';
|
||||
import {
|
||||
createAdminAccount,
|
||||
executeAdminRechargeRefund,
|
||||
getAdminAgcTemplates,
|
||||
getAdminFeatureGateConfig,
|
||||
getAdminUserDetail,
|
||||
importAdminAgcTemplates,
|
||||
listAdminGameDistributionReviews,
|
||||
listAdminRechargeOrders,
|
||||
reconcileAdminUserConsumption,
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
reviewAdminGameDistributionVersion,
|
||||
suspendAdminGameDistributionGame,
|
||||
updateAdminAccount,
|
||||
updateAdminAgcTemplate,
|
||||
uploadAdminEditorShowcaseCampaignImage,
|
||||
upsertAdminFeatureGateConfig,
|
||||
upsertProfileWalletConfig,
|
||||
@@ -21,6 +24,93 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test('模板管理读取和更新复用认证封装,提交 revision 和封面但不提交 ZIP 或版本', async () => {
|
||||
const library = { revision: 'revision-new', writable: true, templates: [] };
|
||||
const fetchMock = vi.fn().mockImplementation(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ ok: true, data: library }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const controller = new AbortController();
|
||||
expect(await getAdminAgcTemplates('admin-token', controller.signal)).toEqual(
|
||||
library,
|
||||
);
|
||||
const update = {
|
||||
expectedRevision: 'revision-old',
|
||||
title: '空白模板',
|
||||
summary: '简介',
|
||||
tags: ['2D'],
|
||||
enabled: true,
|
||||
cover: { contentType: 'image/png', dataBase64: 'aW1hZ2U=' },
|
||||
};
|
||||
expect(
|
||||
await updateAdminAgcTemplate('admin-token', 'template/1', update),
|
||||
).toEqual(library);
|
||||
expect(fetchMock.mock.calls[0]).toEqual([
|
||||
'/admin/api/agc-templates',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
|
||||
}),
|
||||
]);
|
||||
expect(fetchMock.mock.calls[1]).toEqual([
|
||||
'/admin/api/agc-templates/template%2F1',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer admin-token',
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify(update),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test('模板批量导入走 multipart,不预设 JSON Content-Type', async () => {
|
||||
const imported = {
|
||||
revision: 'rev-2',
|
||||
writable: true,
|
||||
templates: [],
|
||||
imported: [
|
||||
{
|
||||
id: 'alpha',
|
||||
templateVersion: '0.1.0',
|
||||
zipSizeBytes: 4,
|
||||
zipSha256: 'a'.repeat(64),
|
||||
reusedObjects: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
const fetchMock = vi.fn().mockImplementation(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ ok: true, data: imported }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
'manifest',
|
||||
JSON.stringify({ expectedRevision: 'rev-1', templates: [] }),
|
||||
);
|
||||
form.append(
|
||||
'zip_0',
|
||||
new File([new Uint8Array([1])], 'alpha.zip', { type: 'application/zip' }),
|
||||
);
|
||||
|
||||
expect(await importAdminAgcTemplates('admin-token', form)).toEqual(imported);
|
||||
const [url, init] = fetchMock.mock.calls[0]!;
|
||||
expect(url).toBe('/admin/api/agc-templates/import');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.body).toBe(form);
|
||||
expect(init.headers).not.toHaveProperty('Content-Type');
|
||||
expect(init.headers.Authorization).toBe('Bearer admin-token');
|
||||
});
|
||||
|
||||
test('后台账号创建和更新同时携带 Tab 与独立操作权限', async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
AdminAccountListResponse,
|
||||
AdminAgcTemplateLibraryResponse,
|
||||
AdminConfirmEditorShowcaseCampaignImageUploadRequest,
|
||||
AdminCreateAccountRequest,
|
||||
AdminCreateAccountResponse,
|
||||
@@ -32,9 +33,11 @@ import type {
|
||||
AdminGameDistributionReviewListResponse,
|
||||
AdminGameDistributionReviewRequest,
|
||||
AdminGameDistributionReviewResponse,
|
||||
AdminImportAgcTemplatesResponse,
|
||||
AdminLoginResponse,
|
||||
AdminMeResponse,
|
||||
AdminOverviewResponse,
|
||||
AdminProjectSnapshotChannelsResponse,
|
||||
AdminProjectSnapshotListQuery,
|
||||
AdminProjectSnapshotListResponse,
|
||||
AdminRechargeOrderListQuery,
|
||||
@@ -50,6 +53,7 @@ import type {
|
||||
AdminTrackingEventListResponse,
|
||||
AdminUpdateAccountRequest,
|
||||
AdminUpdateAccountResponse,
|
||||
AdminUpdateAgcTemplateRequest,
|
||||
AdminUploadedEditorShowcaseCampaignImage,
|
||||
AdminUpsertEditorShowcaseCampaignRequest,
|
||||
AdminUpsertFeatureGateConfigRequest,
|
||||
@@ -88,6 +92,8 @@ interface AdminRequestOptions {
|
||||
method?: string;
|
||||
token?: string;
|
||||
body?: unknown;
|
||||
/** multipart 表单:交给浏览器自己带 boundary,不能预设 Content-Type。 */
|
||||
formData?: FormData;
|
||||
headers?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
@@ -175,6 +181,8 @@ export async function request<T>(
|
||||
if (typeof options.body !== 'undefined') {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
init.body = JSON.stringify(options.body);
|
||||
} else if (options.formData) {
|
||||
init.body = options.formData;
|
||||
}
|
||||
|
||||
const response = await fetch(buildRequestUrl(path), init);
|
||||
@@ -210,6 +218,7 @@ export function listAdminProjectSnapshots(
|
||||
) {
|
||||
const params = new URLSearchParams();
|
||||
if (query.cursor) params.set('cursor', query.cursor);
|
||||
if (query.channel) params.set('channel', query.channel);
|
||||
params.set('limit', String(query.limit ?? 20));
|
||||
return request<AdminProjectSnapshotListResponse>(
|
||||
`/admin/api/project-snapshots?${params.toString()}`,
|
||||
@@ -217,13 +226,27 @@ export function listAdminProjectSnapshots(
|
||||
);
|
||||
}
|
||||
|
||||
export function getAdminProjectSnapshotChannels(
|
||||
token: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return request<AdminProjectSnapshotChannelsResponse>(
|
||||
'/admin/api/project-snapshots/channels',
|
||||
{ token, signal },
|
||||
);
|
||||
}
|
||||
|
||||
export async function downloadAdminProjectSnapshot(
|
||||
token: string,
|
||||
channel: string,
|
||||
userId: string,
|
||||
projectId: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const path = `/admin/api/project-snapshots/${encodeURIComponent(userId)}/${encodeURIComponent(projectId)}/download`;
|
||||
const params = new URLSearchParams();
|
||||
if (channel) params.set('channel', channel);
|
||||
const query = params.toString();
|
||||
const path = `/admin/api/project-snapshots/${encodeURIComponent(userId)}/${encodeURIComponent(projectId)}/download${query ? `?${query}` : ''}`;
|
||||
const response = await fetch(buildRequestUrl(path), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.trim()}`,
|
||||
@@ -1248,3 +1271,33 @@ export function reviewAdminGameDistributionVersion(
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function getAdminAgcTemplates(token: string, signal?: AbortSignal) {
|
||||
return request<AdminAgcTemplateLibraryResponse>('/admin/api/agc-templates', {
|
||||
token,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAdminAgcTemplate(
|
||||
token: string,
|
||||
id: string,
|
||||
body: AdminUpdateAgcTemplateRequest,
|
||||
) {
|
||||
return request<AdminAgcTemplateLibraryResponse>(
|
||||
`/admin/api/agc-templates/${encodeURIComponent(id)}`,
|
||||
{ token, method: 'PUT', body },
|
||||
);
|
||||
}
|
||||
|
||||
/** 批量导入模板包:manifest 与 zip_N / cover_N 一起走 multipart,一批一次锁一次提交。 */
|
||||
export function importAdminAgcTemplates(token: string, formData: FormData) {
|
||||
return request<AdminImportAgcTemplatesResponse>(
|
||||
'/admin/api/agc-templates/import',
|
||||
{
|
||||
token,
|
||||
method: 'POST',
|
||||
formData,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -105,11 +105,15 @@ export interface AdminProjectSnapshotEntry {
|
||||
fileCount: number;
|
||||
totalBytes: number;
|
||||
status: 'ready' | 'partial' | 'unverified';
|
||||
channel: string;
|
||||
authorDisplayName?: string | null;
|
||||
authorPublicUserCode?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminProjectSnapshotListQuery {
|
||||
cursor?: string | null;
|
||||
limit?: number;
|
||||
channel?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminProjectSnapshotListResponse {
|
||||
@@ -117,6 +121,11 @@ export interface AdminProjectSnapshotListResponse {
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export interface AdminProjectSnapshotChannelsResponse {
|
||||
defaultChannel: string;
|
||||
channels: string[];
|
||||
}
|
||||
|
||||
export interface AdminErrorReportEntry {
|
||||
batchId: string;
|
||||
eventCount: number;
|
||||
@@ -144,6 +153,8 @@ export interface AdminErrorReportListResponse {
|
||||
}
|
||||
|
||||
export interface AdminErrorReportDetail extends AdminErrorReportEntry {
|
||||
firstFingerprint?: string;
|
||||
firstSource?: string;
|
||||
note?: string;
|
||||
events: Array<Record<string, unknown>>;
|
||||
logNames: string[];
|
||||
@@ -1076,3 +1087,67 @@ export interface AdminGameDistributionSuspendResponse {
|
||||
};
|
||||
replayed: boolean;
|
||||
}
|
||||
|
||||
export interface AdminAgcTemplatePayload {
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
tags: string[];
|
||||
runtime: string;
|
||||
engine: string;
|
||||
engineVersion: string;
|
||||
templateVersion: string;
|
||||
enabled: boolean;
|
||||
coverUrl: string;
|
||||
zipSizeBytes: number;
|
||||
}
|
||||
|
||||
export interface AdminAgcTemplateLibraryResponse {
|
||||
revision: string;
|
||||
writable: boolean;
|
||||
templates: AdminAgcTemplatePayload[];
|
||||
}
|
||||
|
||||
export interface AdminUpdateAgcTemplateRequest {
|
||||
expectedRevision: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
tags: string[];
|
||||
enabled: boolean;
|
||||
cover?: {
|
||||
contentType: string;
|
||||
dataBase64: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AdminImportAgcTemplateItemPayload {
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
tags: string[];
|
||||
runtime: string;
|
||||
engine: string;
|
||||
engineVersion: string;
|
||||
templateVersion: string;
|
||||
entry: string;
|
||||
zipField: string;
|
||||
coverField: string;
|
||||
}
|
||||
|
||||
export interface AdminImportAgcTemplatesManifest {
|
||||
expectedRevision: string;
|
||||
templates: AdminImportAgcTemplateItemPayload[];
|
||||
}
|
||||
|
||||
export interface AdminImportAgcTemplateResult {
|
||||
id: string;
|
||||
templateVersion: string;
|
||||
zipSizeBytes: number;
|
||||
zipSha256: string;
|
||||
reusedObjects: boolean;
|
||||
}
|
||||
|
||||
export interface AdminImportAgcTemplatesResponse
|
||||
extends AdminAgcTemplateLibraryResponse {
|
||||
imported: AdminImportAgcTemplateResult[];
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
downloadAdminProjectSnapshot,
|
||||
getAdminProjectSnapshotChannels,
|
||||
listAdminProjectSnapshots,
|
||||
} from './adminApiClient';
|
||||
|
||||
@@ -21,12 +22,12 @@ test('项目列表携带分页与后台授权,解析标准响应', async () =>
|
||||
expect(
|
||||
await listAdminProjectSnapshots(
|
||||
'admin-token',
|
||||
{ cursor: 'user/a+项目', limit: 20 },
|
||||
{ cursor: 'user/a+项目', limit: 20, channel: 'release' },
|
||||
controller.signal,
|
||||
),
|
||||
).toEqual(payload);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/admin/api/project-snapshots?cursor=user%2Fa%2B%E9%A1%B9%E7%9B%AE&limit=20',
|
||||
'/admin/api/project-snapshots?cursor=user%2Fa%2B%E9%A1%B9%E7%9B%AE&channel=release&limit=20',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
|
||||
signal: controller.signal,
|
||||
@@ -34,6 +35,23 @@ test('项目列表携带分页与后台授权,解析标准响应', async () =>
|
||||
);
|
||||
});
|
||||
|
||||
test('渠道列表按后台授权读取,解析本部署渠道', async () => {
|
||||
const payload = { defaultChannel: 'release', channels: ['dev', 'release'] };
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true, data: payload })),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
expect(await getAdminProjectSnapshotChannels('admin-token')).toEqual(payload);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/admin/api/project-snapshots/channels',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('ZIP 下载以授权请求读取并优先保留中文附件名', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response('PK\u0003\u0004', {
|
||||
@@ -48,6 +66,7 @@ test('ZIP 下载以授权请求读取并优先保留中文附件名', async () =
|
||||
const controller = new AbortController();
|
||||
const archive = await downloadAdminProjectSnapshot(
|
||||
'admin-token',
|
||||
'release',
|
||||
'user/a',
|
||||
'project/b',
|
||||
controller.signal,
|
||||
@@ -55,7 +74,7 @@ test('ZIP 下载以授权请求读取并优先保留中文附件名', async () =
|
||||
expect(archive.filename).toBe('三消-r2.zip');
|
||||
expect(archive.blob.type).toBe('application/zip');
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/admin/api/project-snapshots/user%2Fa/project%2Fb/download',
|
||||
'/admin/api/project-snapshots/user%2Fa/project%2Fb/download?channel=release',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer admin-token',
|
||||
@@ -81,7 +100,8 @@ test.each([
|
||||
vi.fn().mockResolvedValue(new Response('PK', { headers })),
|
||||
);
|
||||
expect(
|
||||
(await downloadAdminProjectSnapshot('token', 'user', 'project')).filename,
|
||||
(await downloadAdminProjectSnapshot('token', 'release', 'user', 'project'))
|
||||
.filename,
|
||||
).toBe(expected.replace('工程', 'project'));
|
||||
});
|
||||
|
||||
@@ -104,7 +124,7 @@ test.each([401, 403, 409, 500])(
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
downloadAdminProjectSnapshot('token', 'user', 'project'),
|
||||
downloadAdminProjectSnapshot('token', 'release', 'user', 'project'),
|
||||
).rejects.toMatchObject({
|
||||
status,
|
||||
code: 'SNAPSHOT_FAILURE',
|
||||
@@ -123,6 +143,6 @@ test('200 JSON 或 HTML 不能被保存为成功 ZIP', async () => {
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
downloadAdminProjectSnapshot('token', 'user', 'project'),
|
||||
downloadAdminProjectSnapshot('token', 'release', 'user', 'project'),
|
||||
).rejects.toMatchObject({ code: 'INVALID_PROJECT_ARCHIVE_RESPONSE' });
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '../auth/adminAuthStore';
|
||||
import { AdminAccountsPage } from '../pages/AdminAccountsPage';
|
||||
import { AdminAgcModelsPage } from '../pages/AdminAgcModelsPage';
|
||||
import { AdminAgcTemplatesPage } from '../pages/AdminAgcTemplatesPage';
|
||||
import { AdminDashboardPage } from '../pages/AdminDashboardPage';
|
||||
import { AdminDatabaseTablesPage } from '../pages/AdminDatabaseTablesPage';
|
||||
import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage';
|
||||
@@ -295,6 +296,12 @@ export function AdminApp() {
|
||||
{activeRouteId === 'agc-models' ? (
|
||||
<AdminAgcModelsPage token={token} onUnauthorized={handleUnauthorized} />
|
||||
) : null}
|
||||
{activeRouteId === 'agc-templates' ? (
|
||||
<AdminAgcTemplatesPage
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{activeRouteId === 'editor-showcase' ? (
|
||||
<AdminEditorShowcaseReviewPage
|
||||
token={token}
|
||||
|
||||
@@ -55,6 +55,7 @@ const routeIcons = {
|
||||
'project-snapshots': FolderArchive,
|
||||
accounts: Users,
|
||||
'agc-models': ListChecks,
|
||||
'agc-templates': Images,
|
||||
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
|
||||
|
||||
export function AdminShell({
|
||||
|
||||
@@ -168,3 +168,30 @@ test('member 可单独获得游戏审核 Tab 权限', () => {
|
||||
'game-distribution',
|
||||
);
|
||||
});
|
||||
|
||||
test('模板管理只对 owner 或具有 agc-templates 权限的 member 可见', () => {
|
||||
expect(adminRoutes).toContainEqual({
|
||||
id: 'agc-templates',
|
||||
label: '模板管理',
|
||||
hash: '#agc-templates',
|
||||
});
|
||||
expect(resolveAdminRoute('#agc-templates')).toBe('agc-templates');
|
||||
expect(routeHash('agc-templates')).toBe('#agc-templates');
|
||||
expect(
|
||||
getAccessibleAdminRoutes({ accountRole: 'owner', tabPermissions: [] }).some(
|
||||
(route) => route.id === 'agc-templates',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
getAccessibleAdminRoutes({
|
||||
accountRole: 'member',
|
||||
tabPermissions: ['agc-templates'],
|
||||
}).map((route) => route.id),
|
||||
).toEqual(['agc-templates']);
|
||||
expect(
|
||||
getAccessibleAdminRoutes({
|
||||
accountRole: 'member',
|
||||
tabPermissions: ['editor-assets'],
|
||||
}).some((route) => route.id === 'agc-templates'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ export type AdminRouteId =
|
||||
| 'editor-assets'
|
||||
| 'project-snapshots'
|
||||
| 'agc-models'
|
||||
| 'agc-templates'
|
||||
| 'accounts';
|
||||
|
||||
export type AdminTabPermission = Exclude<
|
||||
@@ -54,6 +55,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
||||
hash: '#editor-generation-pricing',
|
||||
},
|
||||
{ id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true },
|
||||
{ id: 'agc-templates', label: '模板管理', hash: '#agc-templates' },
|
||||
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
|
||||
{ id: 'game-distribution', label: '游戏审核', hash: '#game-distribution' },
|
||||
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import '@genarrative/shared/styles.css';
|
||||
import './styles/admin.css';
|
||||
|
||||
import { StrictMode } from 'react';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
formatAdminTimestamp,
|
||||
parseAdminTimestamp,
|
||||
} from './AdminErrorReportsPage';
|
||||
|
||||
describe('错误报告时间格式化', () => {
|
||||
it('解析 SpacetimeDB seconds.microsZ 时间', () => {
|
||||
expect(parseAdminTimestamp('1778207451.731746Z')).toBe(1778207451731);
|
||||
});
|
||||
|
||||
it('解析微秒字符串并拒绝无效时间', () => {
|
||||
expect(parseAdminTimestamp('1778207451731746')).toBe(1778207451731);
|
||||
expect(formatAdminTimestamp('not-a-date')).toBe('-');
|
||||
expect(formatAdminTimestamp('999999999999999999999999')).toBe('-');
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,57 @@ type Props = { token: string; onUnauthorized: (message?: string) => void };
|
||||
|
||||
const ADMIN_ERROR_REPORT_STATUSES = ['new', 'in-progress', 'resolved'] as const;
|
||||
const ERROR_REPORT_PAGE_SIZE = 50;
|
||||
const ERROR_REPORT_STATUS_LABELS: Record<string, string> = {
|
||||
new: '待处理',
|
||||
'in-progress': '处理中',
|
||||
resolved: '已解决',
|
||||
};
|
||||
|
||||
export function parseAdminTimestamp(value: string | null | undefined) {
|
||||
const normalized = value?.trim() ?? '';
|
||||
if (/^-?\d+\.\d{6}Z$/u.test(normalized)) {
|
||||
const [secondsText, microsText] = normalized.slice(0, -1).split('.');
|
||||
const seconds = Number(secondsText);
|
||||
const micros = Number(microsText);
|
||||
return Number.isFinite(seconds) && Number.isFinite(micros)
|
||||
? seconds * 1000 + Math.floor(micros / 1000)
|
||||
: Number.NaN;
|
||||
}
|
||||
if (/^-?\d+$/u.test(normalized)) {
|
||||
const numeric = Number(normalized);
|
||||
if (!Number.isFinite(numeric)) return Number.NaN;
|
||||
if (Math.abs(numeric) >= 1e14) return Math.floor(numeric / 1000);
|
||||
if (Math.abs(numeric) >= 1e11) return numeric;
|
||||
return numeric * 1000;
|
||||
}
|
||||
return Date.parse(normalized);
|
||||
}
|
||||
|
||||
export function formatAdminTimestamp(value: string | null | undefined) {
|
||||
const timestamp = parseAdminTimestamp(value);
|
||||
if (!Number.isFinite(timestamp)) return '-';
|
||||
const date = new Date(timestamp);
|
||||
if (!Number.isFinite(date.getTime())) return '-';
|
||||
try {
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
}).format(date);
|
||||
} catch {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
|
||||
function eventText(event: Record<string, unknown>, key: string) {
|
||||
const value = event[key];
|
||||
return typeof value === 'string' || typeof value === 'number'
|
||||
? String(value)
|
||||
: '';
|
||||
}
|
||||
|
||||
export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
||||
const [reports, setReports] = useState<AdminErrorReportEntry[]>([]);
|
||||
@@ -29,6 +80,24 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
||||
const [pageInfo, setPageInfo] = useState({ total: 0, hasMore: false });
|
||||
const openReportRequestId = useRef(0);
|
||||
const loadRequestId = useRef(0);
|
||||
const detailCloseButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
const previousFocus = document.activeElement as HTMLElement | null;
|
||||
const focusFrame = window.requestAnimationFrame(() => {
|
||||
detailCloseButtonRef.current?.focus();
|
||||
});
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setSelected(null);
|
||||
};
|
||||
window.addEventListener('keydown', handleEscape);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(focusFrame);
|
||||
window.removeEventListener('keydown', handleEscape);
|
||||
previousFocus?.focus?.();
|
||||
};
|
||||
}, [selected]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const requestId = ++loadRequestId.current;
|
||||
@@ -184,9 +253,15 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
||||
<td>{report.batchId}</td>
|
||||
<td>{report.eventCount}</td>
|
||||
<td>{report.source ?? '-'}</td>
|
||||
<td>{report.status}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`admin-error-report-status is-${report.status}`}
|
||||
>
|
||||
{ERROR_REPORT_STATUS_LABELS[report.status] ?? report.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>{report.userId}</td>
|
||||
<td>{new Date(report.createdAt).toLocaleString()}</td>
|
||||
<td>{formatAdminTimestamp(report.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -224,66 +299,173 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
||||
className="admin-detail-modal"
|
||||
role="dialog"
|
||||
aria-label="错误报告详情"
|
||||
aria-modal="true"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) setSelected(null);
|
||||
}}
|
||||
>
|
||||
<div className="admin-detail-modal__panel">
|
||||
<header>
|
||||
<h2>{selected.batchId}</h2>
|
||||
<button type="button" onClick={() => setSelected(null)}>
|
||||
关闭
|
||||
<div className="admin-detail-modal__panel admin-error-report-detail">
|
||||
<header className="admin-error-report-detail__header">
|
||||
<div className="admin-error-report-detail__heading">
|
||||
<span className="admin-error-report-detail__eyebrow">
|
||||
错误报告详情
|
||||
</span>
|
||||
<h2>{selected.batchId}</h2>
|
||||
<div className="admin-error-report-detail__badges">
|
||||
<span
|
||||
className={`admin-error-report-status is-${selected.status}`}
|
||||
>
|
||||
{ERROR_REPORT_STATUS_LABELS[selected.status] ??
|
||||
selected.status}
|
||||
</span>
|
||||
<span className="admin-error-report-detail__badge">
|
||||
{selected.firstSource ?? selected.source ?? '未知来源'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-error-report-detail__close"
|
||||
ref={detailCloseButtonRef}
|
||||
onClick={() => setSelected(null)}
|
||||
aria-label="关闭错误报告详情"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<p>
|
||||
用户:{selected.userId} · 事件:{selected.eventCount} · 日志:
|
||||
{selected.logCount}
|
||||
</p>
|
||||
<pre>{JSON.stringify(selected.events.slice(0, 20), null, 2)}</pre>
|
||||
{selected.userDescription ? (
|
||||
<p>用户描述:{selected.userDescription}</p>
|
||||
) : null}
|
||||
<label className="admin-detail-modal__note">
|
||||
处理备注
|
||||
<textarea
|
||||
value={selected.note ?? ''}
|
||||
onChange={(event) =>
|
||||
setSelected((current) =>
|
||||
current
|
||||
? { ...current, note: event.target.value }
|
||||
: current,
|
||||
)
|
||||
}
|
||||
maxLength={2000}
|
||||
rows={4}
|
||||
placeholder="记录处理结论或后续跟进事项"
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
<div className="admin-detail-modal__actions">
|
||||
<select
|
||||
value={selected.status}
|
||||
onChange={(event) => void saveStatus(event.target.value)}
|
||||
disabled={busy}
|
||||
>
|
||||
{ADMIN_ERROR_REPORT_STATUSES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveStatus(selected.status)}
|
||||
disabled={busy}
|
||||
>
|
||||
保存备注
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void download(selected.batchId)}
|
||||
disabled={downloading}
|
||||
>
|
||||
{downloading ? '下载中…' : '下载诊断包'}
|
||||
</button>
|
||||
<div className="admin-error-report-detail__summary">
|
||||
<div>
|
||||
<span>用户</span>
|
||||
<strong>{selected.userId}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>发生时间</span>
|
||||
<strong>{formatAdminTimestamp(selected.createdAt)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>错误事件</span>
|
||||
<strong>{selected.eventCount}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>应用日志</span>
|
||||
<strong>{selected.logCount}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-error-report-detail__body">
|
||||
<section className="admin-error-report-detail__section">
|
||||
<div className="admin-error-report-detail__section-heading">
|
||||
<div>
|
||||
<h3>错误事件</h3>
|
||||
<span>展示最近 20 条,完整内容请下载诊断包。</span>
|
||||
</div>
|
||||
<span className="admin-error-report-detail__count">
|
||||
{selected.events.length} 条
|
||||
</span>
|
||||
</div>
|
||||
<div className="admin-error-report-event-list">
|
||||
{selected.events.length === 0 ? (
|
||||
<p className="admin-error-report-detail__empty">
|
||||
归档中没有可展示的错误事件。
|
||||
</p>
|
||||
) : null}
|
||||
{selected.events.slice(0, 20).map((event, index) => {
|
||||
const message = eventText(event, 'message') || '未知错误';
|
||||
const stack = eventText(event, 'stack');
|
||||
const occurredAt = eventText(event, 'occurredAt');
|
||||
return (
|
||||
<article
|
||||
className="admin-error-report-event"
|
||||
key={`${index}-${message}`}
|
||||
>
|
||||
<div className="admin-error-report-event__topline">
|
||||
<span className="admin-error-report-event__index">
|
||||
#{index + 1}
|
||||
</span>
|
||||
<span>{eventText(event, 'source') || 'client'}</span>
|
||||
<span>{eventText(event, 'count') || '1'} 次</span>
|
||||
<time>{formatAdminTimestamp(occurredAt)}</time>
|
||||
</div>
|
||||
<p className="admin-error-report-event__message">
|
||||
{message}
|
||||
</p>
|
||||
{eventText(event, 'fingerprint') ? (
|
||||
<code>{eventText(event, 'fingerprint')}</code>
|
||||
) : null}
|
||||
{stack ? (
|
||||
<details>
|
||||
<summary>查看调用栈</summary>
|
||||
<pre>{stack}</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
{selected.userDescription ? (
|
||||
<section className="admin-error-report-detail__section">
|
||||
<div className="admin-error-report-detail__section-heading">
|
||||
<div>
|
||||
<h3>用户描述</h3>
|
||||
</div>
|
||||
</div>
|
||||
<p className="admin-error-report-detail__description">
|
||||
{selected.userDescription}
|
||||
</p>
|
||||
</section>
|
||||
) : null}
|
||||
<section className="admin-error-report-detail__section admin-error-report-detail__section--note">
|
||||
<label className="admin-detail-modal__note">
|
||||
<span>处理备注</span>
|
||||
<textarea
|
||||
value={selected.note ?? ''}
|
||||
onChange={(event) =>
|
||||
setSelected((current) =>
|
||||
current
|
||||
? { ...current, note: event.target.value }
|
||||
: current,
|
||||
)
|
||||
}
|
||||
maxLength={2000}
|
||||
rows={4}
|
||||
placeholder="记录处理结论或后续跟进事项"
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
</div>
|
||||
<footer className="admin-error-report-detail__footer">
|
||||
<label className="admin-error-report-detail__status-field">
|
||||
状态
|
||||
<select
|
||||
value={selected.status}
|
||||
onChange={(event) => void saveStatus(event.target.value)}
|
||||
disabled={busy}
|
||||
>
|
||||
{ADMIN_ERROR_REPORT_STATUSES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{ERROR_REPORT_STATUS_LABELS[value]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="admin-error-report-detail__footer-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveStatus(selected.status)}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? '保存中…' : '保存备注'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void download(selected.batchId)}
|
||||
disabled={downloading}
|
||||
>
|
||||
{downloading ? '下载中…' : '下载诊断包'}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
import {
|
||||
AdminApiError,
|
||||
downloadAdminProjectSnapshot,
|
||||
getAdminProjectSnapshotChannels,
|
||||
listAdminProjectSnapshots,
|
||||
} from '../api/adminApiClient';
|
||||
import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes';
|
||||
@@ -23,6 +24,7 @@ vi.mock('../api/adminApiClient', async () => ({
|
||||
'../api/adminApiClient',
|
||||
)),
|
||||
downloadAdminProjectSnapshot: vi.fn(),
|
||||
getAdminProjectSnapshotChannels: vi.fn(),
|
||||
listAdminProjectSnapshots: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -35,12 +37,18 @@ const entry: AdminProjectSnapshotEntry = {
|
||||
fileCount: 12,
|
||||
totalBytes: 2048,
|
||||
status: 'ready',
|
||||
channel: 'dev',
|
||||
authorDisplayName: '陶泥作者',
|
||||
authorPublicUserCode: 'SY-00000007',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(listAdminProjectSnapshots)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ items: [entry], nextCursor: null });
|
||||
vi.mocked(getAdminProjectSnapshotChannels)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ defaultChannel: 'dev', channels: ['dev'] });
|
||||
vi.mocked(downloadAdminProjectSnapshot).mockReset();
|
||||
});
|
||||
afterEach(() => {
|
||||
@@ -88,36 +96,166 @@ test('按项目展示完整性并限制未完成工程下载', async () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('加载更多合并项目,刷新失败保留列表和错误,重试从首页开始', async () => {
|
||||
test('按游标翻页回到上一页时复用已取得的游标', async () => {
|
||||
vi.mocked(listAdminProjectSnapshots)
|
||||
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
|
||||
.mockResolvedValueOnce({
|
||||
items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }],
|
||||
nextCursor: 'page-3',
|
||||
})
|
||||
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' });
|
||||
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
||||
await screen.findByText('三消工程');
|
||||
const pagination = await screen.findByRole('navigation', {
|
||||
name: '项目工程分页',
|
||||
});
|
||||
expect(pagination.textContent).toContain('第 1 页');
|
||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'token',
|
||||
{ cursor: null, limit: 20, channel: 'dev' },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('button', { name: '上一页' }).hasAttribute('disabled'),
|
||||
).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
||||
await screen.findByText('第二工程');
|
||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'token',
|
||||
{ cursor: 'page-2', limit: 20, channel: 'dev' },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(screen.queryByText('三消工程')).toBeNull();
|
||||
expect(pagination.textContent).toContain('第 2 页');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '下一页' }).hasAttribute('disabled'),
|
||||
).toBe(false);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '上一页' }));
|
||||
await screen.findByText('三消工程');
|
||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'token',
|
||||
{ cursor: null, limit: 20, channel: 'dev' },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(pagination.textContent).toContain('第 1 页');
|
||||
});
|
||||
|
||||
test('切换每页条数从第一页按新条数重新加载', async () => {
|
||||
vi.mocked(listAdminProjectSnapshots)
|
||||
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
|
||||
.mockResolvedValueOnce({
|
||||
items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }],
|
||||
nextCursor: 'page-3',
|
||||
})
|
||||
.mockResolvedValueOnce({ items: [entry], nextCursor: null });
|
||||
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
||||
await screen.findByText('三消工程');
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
||||
await screen.findByText('第二工程');
|
||||
const pagination = screen.getByRole('navigation', { name: '项目工程分页' });
|
||||
expect(pagination.textContent).toContain('第 2 页');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('每页条数'), {
|
||||
target: { value: '50' },
|
||||
});
|
||||
await screen.findByText('三消工程');
|
||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'token',
|
||||
{ cursor: null, limit: 50, channel: 'dev' },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
// 重新加载时页脚会重建,必须重新取节点再断言。
|
||||
expect(
|
||||
screen.getByRole('navigation', { name: '项目工程分页' }).textContent,
|
||||
).toContain('第 1 页');
|
||||
});
|
||||
|
||||
test('刷新重载当前页,翻页失败保留当前页并提示错误', async () => {
|
||||
vi.mocked(listAdminProjectSnapshots)
|
||||
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
|
||||
.mockResolvedValueOnce({
|
||||
items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }],
|
||||
nextCursor: null,
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('远端清单读取失败'))
|
||||
.mockResolvedValueOnce({ items: [], nextCursor: null });
|
||||
.mockRejectedValueOnce(new Error('翻页读取失败'));
|
||||
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '加载更多' }));
|
||||
await screen.findByText('三消工程');
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
||||
await screen.findByText('第二工程');
|
||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'token',
|
||||
{ cursor: 'page-2', limit: 20 },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(screen.getByText('三消工程')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
||||
await screen.findByRole('alert');
|
||||
expect(screen.getByText('第二工程')).toBeTruthy();
|
||||
expect(screen.queryByText('暂无已上传项目')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
||||
await screen.findByText('暂无已上传项目');
|
||||
expect(listAdminProjectSnapshots).toHaveBeenLastCalledWith(
|
||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'token',
|
||||
{ cursor: null, limit: 20 },
|
||||
{ cursor: 'page-2', limit: 20, channel: 'dev' },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
const pagination = screen.getByRole('navigation', { name: '项目工程分页' });
|
||||
expect(pagination.textContent).toContain('第 2 页');
|
||||
expect(screen.getByText('第二工程')).toBeTruthy();
|
||||
expect(screen.queryByText('暂无已上传项目')).toBeNull();
|
||||
|
||||
vi.mocked(listAdminProjectSnapshots).mockResolvedValueOnce({
|
||||
items: [],
|
||||
nextCursor: null,
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
||||
await screen.findByText('暂无已上传项目');
|
||||
expect(screen.queryByRole('alert')).toBeNull();
|
||||
});
|
||||
|
||||
test('用户列与素材查询同口径展示昵称、陶泥号和用户详情入口', async () => {
|
||||
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
||||
const row = (await screen.findByText('三消工程')).closest('tr')!;
|
||||
expect(within(row).getByText('陶泥作者')).toBeTruthy();
|
||||
expect(within(row).getByText('SY-00000007')).toBeTruthy();
|
||||
expect(
|
||||
within(row).getByRole('button', { name: '查看用户信息' }),
|
||||
).toBeTruthy();
|
||||
expect(within(row).queryByText('user-1')).toBeNull();
|
||||
});
|
||||
|
||||
test('默认查询本部署渠道,切换渠道后从第一页按该渠道重新查询', async () => {
|
||||
vi.mocked(getAdminProjectSnapshotChannels).mockResolvedValue({
|
||||
defaultChannel: 'release',
|
||||
channels: ['dev', 'release'],
|
||||
});
|
||||
vi.mocked(listAdminProjectSnapshots)
|
||||
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
|
||||
.mockResolvedValueOnce({ items: [entry], nextCursor: null })
|
||||
.mockResolvedValueOnce({
|
||||
items: [{ ...entry, channel: 'dev', projectName: 'dev 工程' }],
|
||||
nextCursor: null,
|
||||
});
|
||||
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
||||
await screen.findByText('三消工程');
|
||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'token',
|
||||
{ cursor: null, limit: 20, channel: 'release' },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
const channelSelect = screen.getByLabelText('项目工程渠道');
|
||||
expect((channelSelect as HTMLSelectElement).value).toBe('release');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
||||
await screen.findByText('第 2 页,本页 1 个项目');
|
||||
fireEvent.change(channelSelect, { target: { value: 'dev' } });
|
||||
await screen.findByText('dev 工程');
|
||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'token',
|
||||
{ cursor: null, limit: 20, channel: 'dev' },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(screen.getByText('第 1 页,本页 1 个项目')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('下载使用返回的中文文件名,随后释放对象 URL', async () => {
|
||||
@@ -154,6 +292,7 @@ test('下载使用返回的中文文件名,随后释放对象 URL', async () =
|
||||
expect(createObjectURL).toHaveBeenCalledWith(blob);
|
||||
expect(downloadAdminProjectSnapshot).toHaveBeenCalledWith(
|
||||
'token',
|
||||
'dev',
|
||||
'user-1',
|
||||
'project-1',
|
||||
expect.any(AbortSignal),
|
||||
@@ -164,7 +303,7 @@ test('下载使用返回的中文文件名,随后释放对象 URL', async () =
|
||||
|
||||
test('取消下载中止请求且不显示错误,卸载中止列表请求', async () => {
|
||||
vi.mocked(downloadAdminProjectSnapshot).mockImplementation(
|
||||
(_token, _user, _project, signal) =>
|
||||
(_token, _channel, _user, _project, signal) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
signal?.addEventListener('abort', () =>
|
||||
reject(new DOMException('Aborted', 'AbortError')),
|
||||
@@ -177,7 +316,7 @@ test('取消下载中止请求且不显示错误,卸载中止列表请求', as
|
||||
fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '取消下载' }));
|
||||
expect(
|
||||
vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[3]?.aborted,
|
||||
vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[4]?.aborted,
|
||||
).toBe(true);
|
||||
await waitFor(() => expect(screen.queryByRole('alert')).toBeNull());
|
||||
vi.mocked(listAdminProjectSnapshots).mockReturnValue(new Promise(() => {}));
|
||||
@@ -248,6 +387,10 @@ test('更换登录令牌丢弃旧列表和晚返回请求', async () => {
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>,
|
||||
);
|
||||
// 渠道确定之后才会发出列表请求,这里等到旧令牌的请求真的在途再换令牌。
|
||||
await waitFor(() =>
|
||||
expect(listAdminProjectSnapshots).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
const oldSignal = vi.mocked(listAdminProjectSnapshots).mock.calls[0]?.[2];
|
||||
view.rerender(
|
||||
<AdminProjectSnapshotsPage
|
||||
@@ -278,7 +421,7 @@ test('卸载后完成的下载不会创建浏览器文件', async () => {
|
||||
<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' }));
|
||||
const signal = vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[3];
|
||||
const signal = vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[4];
|
||||
view.unmount();
|
||||
expect(signal?.aborted).toBe(true);
|
||||
await act(async () => {
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { Download, RefreshCcw, X } from 'lucide-react';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Download,
|
||||
RefreshCcw,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
downloadAdminProjectSnapshot,
|
||||
getAdminProjectSnapshotChannels,
|
||||
listAdminProjectSnapshots,
|
||||
} from '../api/adminApiClient';
|
||||
import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes';
|
||||
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
|
||||
import { handlePageError } from './pageUtils';
|
||||
|
||||
interface AdminProjectSnapshotsPageProps {
|
||||
@@ -31,21 +39,32 @@ const snapshotStatuses = {
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
const PAGE_SIZE_OPTIONS = [20, 50, 100];
|
||||
|
||||
export function AdminProjectSnapshotsPage({
|
||||
token,
|
||||
onUnauthorized,
|
||||
}: AdminProjectSnapshotsPageProps) {
|
||||
const [items, setItems] = useState<AdminProjectSnapshotEntry[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [pageIndex, setPageIndex] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||
// null 表示渠道还没确定:先读完可选渠道再发列表请求,避免用错渠道白跑一次。
|
||||
const [channel, setChannel] = useState<string | null>(null);
|
||||
const [channelOptions, setChannelOptions] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [downloadingKey, setDownloadingKey] = useState<string | null>(null);
|
||||
const listController = useRef<AbortController | null>(null);
|
||||
const downloadController = useRef<AbortController | null>(null);
|
||||
// 远端按游标分页且不给总数:第 N 页的起始游标只能由前 N-1 页依次返回,
|
||||
// 因此按页记录已取得的游标,翻页只在这些游标之间移动。
|
||||
const pageCursors = useRef<(string | null)[]>([null]);
|
||||
|
||||
const loadPage = useCallback(
|
||||
async (cursor: string | null = null) => {
|
||||
async (cursor: string | null, limit: number, page: number) => {
|
||||
listController.current?.abort();
|
||||
const controller = new AbortController();
|
||||
listController.current = controller;
|
||||
@@ -54,23 +73,16 @@ export function AdminProjectSnapshotsPage({
|
||||
try {
|
||||
const response = await listAdminProjectSnapshots(
|
||||
token,
|
||||
{ cursor, limit: 20 },
|
||||
{ cursor, limit, channel },
|
||||
controller.signal,
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
setItems((current) => {
|
||||
if (!cursor) return response.items;
|
||||
const entries = new Map(
|
||||
current.map((entry) => [snapshotKey(entry), entry]),
|
||||
);
|
||||
response.items.forEach((entry) =>
|
||||
entries.set(snapshotKey(entry), entry),
|
||||
);
|
||||
return [...entries.values()];
|
||||
});
|
||||
setItems(response.items);
|
||||
setNextCursor(response.nextCursor);
|
||||
setPageIndex(page);
|
||||
setHasLoaded(true);
|
||||
} catch (error: unknown) {
|
||||
// 翻页或刷新失败时保留当前页,不把已看到的列表换成空表。
|
||||
if (!controller.signal.aborted)
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
@@ -80,22 +92,70 @@ export function AdminProjectSnapshotsPage({
|
||||
}
|
||||
}
|
||||
},
|
||||
[token, onUnauthorized],
|
||||
[token, onUnauthorized, channel],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// 换令牌或首次进入时取一次可选渠道;已选渠道保持不变,只在还没选时落到本部署渠道。
|
||||
const controller = new AbortController();
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await getAdminProjectSnapshotChannels(
|
||||
token,
|
||||
controller.signal,
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
setChannelOptions(response.channels);
|
||||
setChannel((current) => current ?? response.defaultChannel);
|
||||
} catch (error: unknown) {
|
||||
if (controller.signal.aborted) return;
|
||||
// 渠道列表失败不阻塞查询:不带渠道按本部署渠道查询,并提示失败原因。
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
setChannel((current) => current ?? '');
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [token, onUnauthorized]);
|
||||
|
||||
useEffect(() => {
|
||||
if (channel === null) return undefined;
|
||||
pageCursors.current = [null];
|
||||
setItems([]);
|
||||
setNextCursor(null);
|
||||
setPageIndex(1);
|
||||
setHasLoaded(false);
|
||||
setDownloadingKey(null);
|
||||
void loadPage();
|
||||
void loadPage(null, pageSize, 1);
|
||||
return () => {
|
||||
listController.current?.abort();
|
||||
listController.current = null;
|
||||
downloadController.current?.abort();
|
||||
downloadController.current = null;
|
||||
};
|
||||
}, [loadPage]);
|
||||
}, [loadPage, pageSize, channel]);
|
||||
|
||||
function goToNextPage() {
|
||||
if (!nextCursor) return;
|
||||
pageCursors.current[pageIndex] = nextCursor;
|
||||
void loadPage(nextCursor, pageSize, pageIndex + 1);
|
||||
}
|
||||
|
||||
function goToPreviousPage() {
|
||||
if (pageIndex <= 1) return;
|
||||
void loadPage(
|
||||
pageCursors.current[pageIndex - 2] ?? null,
|
||||
pageSize,
|
||||
pageIndex - 1,
|
||||
);
|
||||
}
|
||||
|
||||
function refreshCurrentPage() {
|
||||
void loadPage(
|
||||
pageCursors.current[pageIndex - 1] ?? null,
|
||||
pageSize,
|
||||
pageIndex,
|
||||
);
|
||||
}
|
||||
|
||||
async function downloadProject(entry: AdminProjectSnapshotEntry) {
|
||||
if (downloadController.current || entry.status === 'partial') return;
|
||||
@@ -106,6 +166,7 @@ export function AdminProjectSnapshotsPage({
|
||||
try {
|
||||
const archive = await downloadAdminProjectSnapshot(
|
||||
token,
|
||||
channel ?? '',
|
||||
entry.userId,
|
||||
entry.projectId,
|
||||
controller.signal,
|
||||
@@ -140,19 +201,42 @@ export function AdminProjectSnapshotsPage({
|
||||
setDownloadingKey(null);
|
||||
}
|
||||
|
||||
// 渠道列表读取失败时至少保留当前渠道,避免选择框空掉后看不出在查哪个渠道。
|
||||
const visibleChannelOptions = channelOptions.length
|
||||
? channelOptions
|
||||
: channel
|
||||
? [channel]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<section className="admin-page admin-page-wide">
|
||||
<div className="admin-page-heading">
|
||||
<h2>项目工程</h2>
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
disabled={isLoading}
|
||||
type="button"
|
||||
onClick={() => void loadPage()}
|
||||
>
|
||||
<RefreshCcw size={17} aria-hidden="true" />
|
||||
<span>{isLoading ? '加载中' : '刷新'}</span>
|
||||
</button>
|
||||
<div className="admin-action-row">
|
||||
<label className="admin-field admin-field-compact">
|
||||
<span>渠道</span>
|
||||
<select
|
||||
aria-label="项目工程渠道"
|
||||
value={channel ?? ''}
|
||||
onChange={(event) => setChannel(event.target.value)}
|
||||
>
|
||||
{visibleChannelOptions.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
disabled={isLoading}
|
||||
type="button"
|
||||
onClick={refreshCurrentPage}
|
||||
>
|
||||
<RefreshCcw size={17} aria-hidden="true" />
|
||||
<span>{isLoading ? '加载中' : '刷新'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{errorMessage ? (
|
||||
<div className="admin-alert" role="alert">
|
||||
@@ -169,7 +253,7 @@ export function AdminProjectSnapshotsPage({
|
||||
<thead>
|
||||
<tr>
|
||||
<th>项目</th>
|
||||
<th>用户 ID</th>
|
||||
<th>用户</th>
|
||||
<th>同步时间</th>
|
||||
<th>文件数</th>
|
||||
<th>体积</th>
|
||||
@@ -187,7 +271,22 @@ export function AdminProjectSnapshotsPage({
|
||||
<strong>{entry.projectName || entry.projectId}</strong>
|
||||
<small>{entry.projectId}</small>
|
||||
</td>
|
||||
<td data-label="用户 ID">{entry.userId}</td>
|
||||
<td data-label="用户">
|
||||
<div className="admin-inline-identity">
|
||||
<div>
|
||||
{projectOwnerDisplayName(entry)}
|
||||
<small>
|
||||
{entry.authorPublicUserCode?.trim() || '-'}
|
||||
</small>
|
||||
</div>
|
||||
<AdminUserReferenceButton
|
||||
token={token}
|
||||
userId={entry.userId}
|
||||
publicUserCode={entry.authorPublicUserCode}
|
||||
onUnauthorized={onUnauthorized}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="同步时间">
|
||||
<span>
|
||||
{new Date(entry.syncedAtMs).toLocaleString('zh-CN', {
|
||||
@@ -239,23 +338,66 @@ export function AdminProjectSnapshotsPage({
|
||||
{hasLoaded && items.length === 0 && !errorMessage ? (
|
||||
<p className="admin-muted-text">暂无已上传项目</p>
|
||||
) : null}
|
||||
{nextCursor ? (
|
||||
<div className="admin-action-row">
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
disabled={isLoading}
|
||||
type="button"
|
||||
onClick={() => void loadPage(nextCursor)}
|
||||
>
|
||||
{isLoading ? '加载中' : '加载更多'}
|
||||
</button>
|
||||
</div>
|
||||
{hasLoaded ? (
|
||||
<nav
|
||||
className="admin-action-row admin-project-snapshot-pagination"
|
||||
aria-label="项目工程分页"
|
||||
>
|
||||
<span className="admin-project-snapshot-pagination-info">
|
||||
第 {pageIndex} 页
|
||||
{items.length ? `,本页 ${items.length} 个项目` : ''}
|
||||
</span>
|
||||
<div className="admin-action-row">
|
||||
<label className="admin-field admin-field-compact">
|
||||
<span>每页</span>
|
||||
<select
|
||||
aria-label="每页条数"
|
||||
value={pageSize}
|
||||
onChange={(event) => setPageSize(Number(event.target.value))}
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
aria-label="上一页"
|
||||
className="admin-secondary-button"
|
||||
disabled={isLoading || pageIndex <= 1}
|
||||
title="上一页"
|
||||
type="button"
|
||||
onClick={goToPreviousPage}
|
||||
>
|
||||
<ChevronLeft size={17} aria-hidden="true" />
|
||||
<span>上一页</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label="下一页"
|
||||
className="admin-secondary-button"
|
||||
disabled={isLoading || !nextCursor}
|
||||
title="下一页"
|
||||
type="button"
|
||||
onClick={goToNextPage}
|
||||
>
|
||||
<span>下一页</span>
|
||||
<ChevronRight size={17} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
) : null}
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function projectOwnerDisplayName(entry: AdminProjectSnapshotEntry) {
|
||||
return (
|
||||
entry.authorDisplayName?.trim() || entry.authorPublicUserCode?.trim() || '-'
|
||||
);
|
||||
}
|
||||
|
||||
function snapshotKey(entry: AdminProjectSnapshotEntry) {
|
||||
return `${entry.userId}/${entry.projectId}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
attachCoverFiles,
|
||||
buildTemplateImportFormData,
|
||||
deriveTemplateUploadRow,
|
||||
parseTemplateTags,
|
||||
sanitizeTemplateId,
|
||||
TEMPLATE_IMPORT_MAX_BATCH,
|
||||
type TemplateUploadRow,
|
||||
validateTemplateUploadRows,
|
||||
} from './adminAgcTemplateUploadModel';
|
||||
|
||||
function zipFile(name: string) {
|
||||
return new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], name, {
|
||||
type: 'application/zip',
|
||||
});
|
||||
}
|
||||
|
||||
function coverFile(name: string) {
|
||||
return new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], name, {
|
||||
type: 'image/png',
|
||||
});
|
||||
}
|
||||
|
||||
function row(zipName: string, patch: Partial<TemplateUploadRow> = {}) {
|
||||
return { ...deriveTemplateUploadRow(zipFile(zipName)), ...patch };
|
||||
}
|
||||
|
||||
describe('sanitizeTemplateId', () => {
|
||||
it('lowercases, keeps whitelisted characters and drops traversal', () => {
|
||||
expect(sanitizeTemplateId('Cocos Empty 2D.zip')).toBe('cocos-empty-2d');
|
||||
expect(sanitizeTemplateId('../../evil.zip')).toBe('evil');
|
||||
expect(sanitizeTemplateId('a..b.zip')).toBe('a.b');
|
||||
expect(sanitizeTemplateId('__hidden__')).toBe('hidden__');
|
||||
expect(sanitizeTemplateId(`${'x'.repeat(90)}.zip`)).toHaveLength(64);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveTemplateUploadRow', () => {
|
||||
it('starts from safe defaults so a batch only needs covers attached', () => {
|
||||
const derived = row('my-template.zip');
|
||||
expect(derived.id).toBe('my-template');
|
||||
expect(derived.title).toBe('my-template');
|
||||
expect(derived.runtime).toBe('html');
|
||||
expect(derived.templateVersion).toBe('0.1.0');
|
||||
expect(derived.entry).toBe('index.html');
|
||||
expect(derived.coverFile).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('attachCoverFiles', () => {
|
||||
it('matches covers by template id and ignores non-image files', () => {
|
||||
const rows = [row('alpha.zip'), row('beta.zip')];
|
||||
const covers = [
|
||||
coverFile('alpha.png'),
|
||||
coverFile('beta.webp'),
|
||||
coverFile('notes.txt'),
|
||||
];
|
||||
|
||||
const next = attachCoverFiles(rows, covers);
|
||||
|
||||
expect(next[0]?.coverFile?.name).toBe('alpha.png');
|
||||
expect(next[1]?.coverFile?.name).toBe('beta.webp');
|
||||
});
|
||||
|
||||
it('keeps an already matched cover when the new selection has no partner', () => {
|
||||
const rows = [row('alpha.zip', { coverFile: coverFile('alpha.png') })];
|
||||
const next = attachCoverFiles(rows, [coverFile('other.png')]);
|
||||
expect(next[0]?.coverFile?.name).toBe('alpha.png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateTemplateUploadRows', () => {
|
||||
it('accepts a complete batch', () => {
|
||||
const rows = [
|
||||
row('alpha.zip', { coverFile: coverFile('alpha.png') }),
|
||||
row('beta.zip', { coverFile: coverFile('beta.png'), runtime: 'cocos' }),
|
||||
];
|
||||
expect(validateTemplateUploadRows(rows)).toEqual({});
|
||||
});
|
||||
|
||||
it('reports missing cover, duplicate id and invalid fields per row', () => {
|
||||
const rows = [
|
||||
row('alpha.zip'),
|
||||
row('alpha.zip', { coverFile: coverFile('alpha.png'), key: 'second' }),
|
||||
row('gamma.zip', {
|
||||
coverFile: coverFile('gamma.png'),
|
||||
runtime: 'docker',
|
||||
templateVersion: 'Bad Version',
|
||||
entry: '../escape.js',
|
||||
title: ' ',
|
||||
}),
|
||||
];
|
||||
const errors = validateTemplateUploadRows(rows);
|
||||
expect(errors['alpha.zip']).toContain('缺少封面');
|
||||
expect(errors.second).toContain('ID 在本批次内重复');
|
||||
expect(errors['gamma.zip']).toContain('名称必须是 1-80 个字符');
|
||||
});
|
||||
|
||||
it('rejects a batch larger than the server limit', () => {
|
||||
const rows = Array.from(
|
||||
{ length: TEMPLATE_IMPORT_MAX_BATCH + 1 },
|
||||
(_, index) =>
|
||||
row(`template-${index}.zip`, {
|
||||
coverFile: coverFile(`template-${index}.png`),
|
||||
}),
|
||||
);
|
||||
const errors = validateTemplateUploadRows(rows);
|
||||
expect(Object.keys(errors)).toHaveLength(TEMPLATE_IMPORT_MAX_BATCH + 1);
|
||||
expect(errors['template-0.zip']).toContain('单批最多上传');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTemplateImportFormData', () => {
|
||||
it('writes manifest field names and attaches zip / cover per row', () => {
|
||||
const rows = [
|
||||
row('alpha.zip', {
|
||||
coverFile: coverFile('alpha.png'),
|
||||
tags: '起步, 起步 2d',
|
||||
title: ' Alpha ',
|
||||
}),
|
||||
row('beta.zip', { coverFile: coverFile('beta.png') }),
|
||||
];
|
||||
|
||||
const form = buildTemplateImportFormData('a'.repeat(64), rows);
|
||||
const manifest = JSON.parse(String(form.get('manifest')));
|
||||
|
||||
expect(manifest.expectedRevision).toBe('a'.repeat(64));
|
||||
expect(manifest.templates).toHaveLength(2);
|
||||
expect(manifest.templates[0]).toMatchObject({
|
||||
id: 'alpha',
|
||||
title: 'Alpha',
|
||||
tags: ['起步', '2d'],
|
||||
zipField: 'zip_0',
|
||||
coverField: 'cover_0',
|
||||
});
|
||||
expect(manifest.templates[1]).toMatchObject({
|
||||
id: 'beta',
|
||||
zipField: 'zip_1',
|
||||
coverField: 'cover_1',
|
||||
});
|
||||
expect((form.get('zip_0') as File).name).toBe('alpha.zip');
|
||||
expect((form.get('cover_1') as File).name).toBe('beta.png');
|
||||
});
|
||||
|
||||
it('parses tags with dedupe and caps them at the server limit', () => {
|
||||
expect(parseTemplateTags(' a, a,b c ')).toEqual(['a', 'b', 'c']);
|
||||
const many = Array.from({ length: 20 }, (_, index) => `t${index}`).join(
|
||||
',',
|
||||
);
|
||||
expect(parseTemplateTags(many)).toHaveLength(16);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import type {
|
||||
AdminImportAgcTemplateItemPayload,
|
||||
AdminImportAgcTemplatesManifest,
|
||||
} from '../api/adminApiTypes';
|
||||
|
||||
/** 与服务端 module-assets 的导入上限保持一致;超出请分批或改走 CLI。 */
|
||||
export const TEMPLATE_IMPORT_MAX_BATCH = 20;
|
||||
export const TEMPLATE_UPLOAD_RUNTIMES = [
|
||||
'html',
|
||||
'unity',
|
||||
'godot',
|
||||
'cocos',
|
||||
] as const;
|
||||
const TEMPLATE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
|
||||
const TEMPLATE_VERSION_PATTERN = /^[a-z0-9][a-z0-9._-]{0,31}$/u;
|
||||
const ENTRY_PATTERN = /^(?![\\/:])(?!.*\.\.)[^\s\\:]+$/u;
|
||||
const COVER_EXTENSION_PATTERN = /\.(png|jpe?g|webp)$/iu;
|
||||
|
||||
export interface TemplateUploadRow {
|
||||
/** 稳定行标识:用 ZIP 文件名,重选文件后不会串行。 */
|
||||
key: string;
|
||||
zipFile: File;
|
||||
coverFile: File | null;
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
tags: string;
|
||||
runtime: string;
|
||||
engine: string;
|
||||
engineVersion: string;
|
||||
templateVersion: string;
|
||||
entry: string;
|
||||
}
|
||||
|
||||
/** 文件名 → 模板 ID:小写、只留白名单字符,并去掉 `..` 与开头的非字母数字。 */
|
||||
export function sanitizeTemplateId(value: string) {
|
||||
return value
|
||||
.replace(/\.zip$/iu, '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\.{2,}/gu, '.')
|
||||
.replace(/[^a-z0-9._-]+/gu, '-')
|
||||
.replace(/^[^a-z0-9]+/u, '')
|
||||
.slice(0, 64);
|
||||
}
|
||||
|
||||
export function deriveTemplateUploadRow(zipFile: File): TemplateUploadRow {
|
||||
const id = sanitizeTemplateId(zipFile.name);
|
||||
return {
|
||||
key: zipFile.name,
|
||||
zipFile,
|
||||
coverFile: null,
|
||||
id,
|
||||
title: id,
|
||||
summary: '',
|
||||
tags: '',
|
||||
runtime: 'html',
|
||||
engine: '',
|
||||
engineVersion: '',
|
||||
templateVersion: '0.1.0',
|
||||
entry: 'index.html',
|
||||
};
|
||||
}
|
||||
|
||||
/** 封面按「与模板 ID 同名的图片」匹配,一次多选即可覆盖整批。 */
|
||||
export function attachCoverFiles(
|
||||
rows: TemplateUploadRow[],
|
||||
coverFiles: File[],
|
||||
): TemplateUploadRow[] {
|
||||
const covers = new Map<string, File>();
|
||||
for (const file of coverFiles) {
|
||||
if (!COVER_EXTENSION_PATTERN.test(file.name)) continue;
|
||||
const stem = sanitizeTemplateId(
|
||||
file.name.replace(COVER_EXTENSION_PATTERN, ''),
|
||||
);
|
||||
if (!covers.has(stem)) covers.set(stem, file);
|
||||
}
|
||||
return rows.map((row) => {
|
||||
const matched = covers.get(row.id.trim().toLowerCase()) ?? null;
|
||||
return matched ? { ...row, coverFile: matched } : row;
|
||||
});
|
||||
}
|
||||
|
||||
export function parseTemplateTags(value: string) {
|
||||
const seen = new Set<string>();
|
||||
const tags: string[] = [];
|
||||
for (const raw of value.split(/[,,\s]+/u)) {
|
||||
const tag = raw.trim();
|
||||
if (!tag || seen.has(tag)) continue;
|
||||
seen.add(tag);
|
||||
tags.push(tag);
|
||||
}
|
||||
return tags.slice(0, 16);
|
||||
}
|
||||
|
||||
/** 逐行校验:返回 row.key → 错误文案;空对象表示整批可以提交。 */
|
||||
export function validateTemplateUploadRows(
|
||||
rows: TemplateUploadRow[],
|
||||
): Record<string, string> {
|
||||
const errors: Record<string, string> = {};
|
||||
const seen = new Set<string>();
|
||||
const fail = (row: TemplateUploadRow, message: string) => {
|
||||
if (!errors[row.key]) errors[row.key] = message;
|
||||
};
|
||||
if (rows.length === 0) return errors;
|
||||
if (rows.length > TEMPLATE_IMPORT_MAX_BATCH) {
|
||||
for (const row of rows) {
|
||||
fail(row, `单批最多上传 ${TEMPLATE_IMPORT_MAX_BATCH} 个模板`);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
for (const row of rows) {
|
||||
const id = row.id.trim();
|
||||
if (!TEMPLATE_ID_PATTERN.test(id)) {
|
||||
fail(
|
||||
row,
|
||||
'ID 必须是 1-64 位小写字母、数字、点、下划线或连字符,且以字母数字开头',
|
||||
);
|
||||
} else if (seen.has(id)) {
|
||||
fail(row, 'ID 在本批次内重复');
|
||||
} else {
|
||||
seen.add(id);
|
||||
}
|
||||
if (!row.title.trim() || row.title.trim().length > 80) {
|
||||
fail(row, '名称必须是 1-80 个字符');
|
||||
}
|
||||
if (row.summary.trim().length > 1000) {
|
||||
fail(row, '简介最多 1000 个字符');
|
||||
}
|
||||
if (!TEMPLATE_VERSION_PATTERN.test(row.templateVersion.trim())) {
|
||||
fail(row, '版本号必须是 1-32 位小写字母、数字、点、下划线或连字符');
|
||||
}
|
||||
if (
|
||||
!TEMPLATE_UPLOAD_RUNTIMES.includes(
|
||||
row.runtime as (typeof TEMPLATE_UPLOAD_RUNTIMES)[number],
|
||||
)
|
||||
) {
|
||||
fail(row, `运行时只能是 ${TEMPLATE_UPLOAD_RUNTIMES.join(' / ')}`);
|
||||
}
|
||||
if (!ENTRY_PATTERN.test(row.entry.trim())) {
|
||||
fail(row, 'entry 必须是模板包内的相对路径');
|
||||
}
|
||||
if (!row.coverFile) {
|
||||
fail(row, '缺少封面:请上传与模板 ID 同名的 PNG / JPEG / WebP');
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function buildTemplateImportManifest(
|
||||
expectedRevision: string,
|
||||
rows: TemplateUploadRow[],
|
||||
): AdminImportAgcTemplatesManifest {
|
||||
return {
|
||||
expectedRevision,
|
||||
templates: rows.map((row, index) => {
|
||||
const item: AdminImportAgcTemplateItemPayload = {
|
||||
id: row.id.trim(),
|
||||
title: row.title.trim(),
|
||||
summary: row.summary.trim(),
|
||||
tags: parseTemplateTags(row.tags),
|
||||
runtime: row.runtime,
|
||||
engine: row.engine.trim(),
|
||||
engineVersion: row.engineVersion.trim(),
|
||||
templateVersion: row.templateVersion.trim(),
|
||||
entry: row.entry.trim(),
|
||||
zipField: `zip_${index}`,
|
||||
coverField: `cover_${index}`,
|
||||
};
|
||||
return item;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTemplateImportFormData(
|
||||
expectedRevision: string,
|
||||
rows: TemplateUploadRow[],
|
||||
): FormData {
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
'manifest',
|
||||
JSON.stringify(buildTemplateImportManifest(expectedRevision, rows)),
|
||||
);
|
||||
rows.forEach((row, index) => {
|
||||
form.append(`zip_${index}`, row.zipFile, row.zipFile.name);
|
||||
if (row.coverFile) {
|
||||
form.append(`cover_${index}`, row.coverFile, row.coverFile.name);
|
||||
}
|
||||
});
|
||||
return form;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@
|
||||
"dev-stack": "node scripts/start-dev-stack.mjs",
|
||||
"build": "node scripts/build-release.mjs",
|
||||
"release:upload": "node scripts/release-upload.mjs",
|
||||
"nsis:prepare": "node scripts/ensure-nsis-toolset.mjs",
|
||||
"skill-pack:check": "node scripts/check-skill-pack.mjs",
|
||||
"skill-pack:sync": "node scripts/check-skill-pack.mjs --write",
|
||||
"skill-pack:test": "node --test scripts/check-skill-pack.test.mjs",
|
||||
@@ -66,15 +67,16 @@
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openai/codex": "0.147.0",
|
||||
"@openai/codex": "0.155.1",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@tauri-apps/cli": "^2.11.2",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/three": "^0.184.1",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@types/three": "^0.184.1",
|
||||
"jszip": "^3.10.1",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"typescript": "~5.8.2",
|
||||
"vitest": "^0.34.6"
|
||||
|
||||
@@ -185,7 +185,12 @@ test('nextVersion 只在 patch 位递增', () => {
|
||||
|
||||
test('ossutil 参数默认使用 v1 签名,并可按需带 region 与 v4', () => {
|
||||
const base = {
|
||||
args: ['cp', '--force', '/tmp/a.json', 'oss://agc-dev/agc/global-version.json'],
|
||||
args: [
|
||||
'cp',
|
||||
'--force',
|
||||
'/tmp/a.json',
|
||||
'oss://agc-dev/agc/global-version.json',
|
||||
],
|
||||
endpoint: 'oss-rg-china-mainland.aliyuncs.com',
|
||||
accessKeyId: 'id',
|
||||
accessKeySecret: 'secret',
|
||||
|
||||
@@ -20,7 +20,10 @@ import { createInterface } from 'node:readline/promises';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { inflateSync } from 'node:zlib';
|
||||
|
||||
export const appIdentifier = 'world.genarrative.ai-game-creator';
|
||||
import { AGC_APP_IDENTIFIER } from './channel-identity.mjs';
|
||||
|
||||
// 联调工具驱动的始终是默认渠道客户端:安装身份取渠道基线,不跟随发布渠道。
|
||||
export const appIdentifier = AGC_APP_IDENTIFIER;
|
||||
export const configFileName = 'game-creator.config.json';
|
||||
export const localConfigFileName = 'game-creator.config.local.json';
|
||||
export const runnerEndpointFileName = 'agent-runner.endpoint.json';
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
generateUpdateManifest,
|
||||
prepareReleaseVersion,
|
||||
resolveManifestPlatformKeys,
|
||||
resolveReleaseContext,
|
||||
resolveReleasePartition,
|
||||
runTauriBuild,
|
||||
} from './build-release.mjs';
|
||||
import { resolveChannelInstallIdentity } from './channel-identity.mjs';
|
||||
import { readReleaseDryRun, uploadReleaseArtifacts } from './release-oss.mjs';
|
||||
import {
|
||||
readUpdaterPubkey,
|
||||
verifyUpdaterSignature,
|
||||
} from './verify-updater-signature.mjs';
|
||||
|
||||
/**
|
||||
* AGC macOS 分区(`<channel>-mac`)发布入口:构建 arm64 单架构包 → arm64 隔离 smoke → 生成 arm64 DMG
|
||||
* → 生成分区清单 latest.json → 用产物内烘焙的公钥验签 → 按 dry-run 决定是否上传 OSS。
|
||||
*
|
||||
* 边界:
|
||||
* - 只出 Apple Silicon(arm64)单架构:清单只登记 `darwin-aarch64`。Intel 侧要可用,前提是随包 Node
|
||||
* 也能按架构各带一份(`stage-node-runtime.mjs` 对 universal 目标失败关闭);在实现之前**不得**
|
||||
* 把 arm64 产物登记成 `darwin-x86_64`,否则 Intel 客户端会装到跑不起来的包。
|
||||
* - Apple 签名与公证暂缺:本入口剥离 `APPLE_*` 凭据让 Tauri 跳过 Apple 签名,但**不能传
|
||||
* `--no-sign`** —— 该标志同时会跳过 updater 的 minisign 签名,产物就没有 `.sig`;
|
||||
* 未签名 + 未公证必须显式记录而非静默通过;
|
||||
* - 更新包签名(TAURI_SIGNING_PRIVATE_KEY,minisign)是硬需求:缺了客户端一律拒绝安装,
|
||||
* 因此构建前要求凭据存在,构建后用内置公钥复核 `.sig` 才允许继续上传;
|
||||
* - 未通过验签绝不写 OSS:上传顺序为更新包、签名、首装包,全部成功后才覆盖渠道清单指针。
|
||||
*/
|
||||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
const repoRoot = path.resolve(appRoot, '../..');
|
||||
|
||||
/**
|
||||
* 产品名只从渠道安装身份派生(渠道身份由构建期 `--config` 注入 Tauri 配置):
|
||||
* 它同时决定 `*.app` 目录名、updater 归档名与 DMG 卷名。写死会在改名或换渠道后
|
||||
* 让入口静默找错对象(清理、打包、归档三处一起失效)。
|
||||
*/
|
||||
function resolveProductName(channel) {
|
||||
const { productName } = resolveChannelInstallIdentity(channel);
|
||||
assert.ok(
|
||||
typeof productName === 'string' && productName.trim().length > 0,
|
||||
'渠道安装身份缺少 productName',
|
||||
);
|
||||
return productName;
|
||||
}
|
||||
|
||||
assert.equal(process.platform, 'darwin', '只能在 macOS Agent 执行');
|
||||
assert.equal(
|
||||
process.env.JENKINS_URL?.length > 0,
|
||||
true,
|
||||
'此入口仅用于 Jenkins 独立工作区',
|
||||
);
|
||||
assert.equal(
|
||||
fs.realpathSync(process.env.WORKSPACE || '.'),
|
||||
fs.realpathSync(repoRoot),
|
||||
'必须在 Jenkins workspace 根目录执行',
|
||||
);
|
||||
const space = fs.statfsSync(repoRoot);
|
||||
assert.ok(
|
||||
space.bavail * space.bsize >= 8 * 1024 ** 3,
|
||||
'构建前至少需要 8 GiB 可用空间;禁止自动清理开发缓存',
|
||||
);
|
||||
|
||||
// 仅剥离 Apple 签名/公证变量:本节点没有证书,误用只会让构建失败;
|
||||
// 更新包签名与 OSS 凭据必须保留,它们是本入口发布能力的组成部分。
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (/^APPLE_/u.test(key)) delete process.env[key];
|
||||
}
|
||||
assert.ok(
|
||||
process.env.TAURI_SIGNING_PRIVATE_KEY?.length > 0 ||
|
||||
process.env.TAURI_SIGNING_PRIVATE_KEY_PATH?.length > 0,
|
||||
'缺少更新包签名私钥(TAURI_SIGNING_PRIVATE_KEY / _PATH):无签名的更新包会被客户端拒绝,禁止继续',
|
||||
);
|
||||
|
||||
const bucket = process.env.AGC_OSS_BUCKET?.trim() || 'agc-dev';
|
||||
const endpoint =
|
||||
process.env.AGC_OSS_ENDPOINT?.trim() || 'oss-rg-china-mainland.aliyuncs.com';
|
||||
if (!/^[a-z0-9][a-z0-9.-]{1,62}$/u.test(bucket) || /[\r\n\0]/u.test(endpoint)) {
|
||||
throw new Error('OSS bucket 或 endpoint 配置无效');
|
||||
}
|
||||
process.env.AGC_UPDATE_OSS_BASE_URL ||= `https://${bucket}.${endpoint}/agc`;
|
||||
const dryRun = readReleaseDryRun();
|
||||
|
||||
process.env.CARGO_TARGET_DIR = path.join(appRoot, 'src-tauri/target');
|
||||
// 单架构目标:清单侧 `resolveManifestPlatformKeys` 只为它登记 darwin-aarch64。
|
||||
const macTarget = 'aarch64-apple-darwin';
|
||||
const context = resolveReleaseContext([`--target=${macTarget}`]);
|
||||
const partition = resolveReleasePartition(context.channel, context.target);
|
||||
const productName = resolveProductName(context.channel);
|
||||
const appBundleName = `${productName}.app`;
|
||||
const updaterArtifactName = `${productName}.app.tar.gz`;
|
||||
const version = await prepareReleaseVersion(context);
|
||||
// 首装包名必须让清单侧的单架构分支唯一匹配:`<产品名>_<版本>_<架构>.dmg`,
|
||||
// 架构段用 Tauri 的 aarch64 口径(不是 updater 平台键的 arm64 / x86_64)。
|
||||
const firstInstallName = `${productName}_${version}_aarch64.dmg`;
|
||||
|
||||
// 幂等边界:workspace 会保留上一轮产物。先删掉本次将要写出的对象,否则
|
||||
// 1) hdiutil 会因同名 DMG 已存在直接失败(首次实跑即命中);
|
||||
// 2) 上一轮遗留的 `.sig` 会让验签门禁把「本轮其实没签」判成通过。
|
||||
// 只删本次要写出的确切路径,不动其它版本产物与编译缓存。
|
||||
const macosBundle = path.join(context.bundleRoot, 'macos');
|
||||
for (const stale of [
|
||||
path.join(macosBundle, updaterArtifactName),
|
||||
path.join(macosBundle, `${updaterArtifactName}.sig`),
|
||||
path.join(macosBundle, `${firstInstallName}`),
|
||||
path.join(macosBundle, `${firstInstallName}.sha256`),
|
||||
path.join(context.bundleRoot, 'latest.json'),
|
||||
path.join(context.bundleRoot, 'release-notes.txt'),
|
||||
]) {
|
||||
fs.rmSync(stale, { force: true });
|
||||
}
|
||||
|
||||
const args = [
|
||||
`--target=${macTarget}`,
|
||||
'--bundles',
|
||||
'app',
|
||||
'--ci',
|
||||
// 刻意不传 `--no-sign`:它会连带跳过 updater 签名,而客户端强制校验更新包签名。
|
||||
// Apple 侧改为剥离 APPLE_* 凭据,未配置身份时 Tauri 不签名也不失败。
|
||||
// 基础配置已开启;这里显式声明,避免被其它配置来源关掉后静默失去更新能力。
|
||||
'--config',
|
||||
'{"bundle":{"createUpdaterArtifacts":true}}',
|
||||
];
|
||||
const command = (binary, argv, options = {}) =>
|
||||
execFileSync(binary, argv, { cwd: repoRoot, stdio: 'inherit', ...options });
|
||||
runTauriBuild(args, context);
|
||||
|
||||
const app = path.join(context.bundleRoot, 'macos', appBundleName);
|
||||
command(process.execPath, [
|
||||
path.join(appRoot, 'scripts/check-macos-bundle.mjs'),
|
||||
app,
|
||||
'arm64',
|
||||
]);
|
||||
|
||||
// DMG 放在 bundle 根目录下:渠道清单的首装包选择会扫描该目录,命名必须匹配 `_<version>_aarch64.dmg`。
|
||||
const dmgDirectory = path.join(context.bundleRoot, 'macos');
|
||||
fs.mkdirSync(dmgDirectory, { recursive: true });
|
||||
const dmg = path.join(dmgDirectory, firstInstallName);
|
||||
const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-ci-dmg-'));
|
||||
try {
|
||||
command('ditto', [app, path.join(stage, appBundleName)]);
|
||||
fs.symlinkSync('/Applications', path.join(stage, 'Applications'));
|
||||
command('hdiutil', [
|
||||
'create',
|
||||
// 前面已删除同名对象;这里再要求显式覆盖,避免残留文件让构建以「文件已存在」失败。
|
||||
'-ov',
|
||||
'-volname',
|
||||
productName,
|
||||
'-srcfolder',
|
||||
stage,
|
||||
'-format',
|
||||
'UDZO',
|
||||
dmg,
|
||||
]);
|
||||
command('hdiutil', ['verify', dmg]);
|
||||
} finally {
|
||||
fs.rmSync(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const release = await generateUpdateManifest(context);
|
||||
assert.equal(
|
||||
path.resolve(release.downloadArtifact),
|
||||
path.resolve(dmg),
|
||||
'首装包必须锁定本次生成的 arm64 DMG',
|
||||
);
|
||||
|
||||
// 上传前门禁:用产物里烘焙的公钥复核更新包签名。验不过就停在这里,绝不写 OSS。
|
||||
const signature = verifyUpdaterSignature({
|
||||
artifactPath: release.artifact,
|
||||
signaturePath: `${release.artifact}.sig`,
|
||||
pubkey: readUpdaterPubkey(),
|
||||
});
|
||||
console.log(
|
||||
`[agc-macos] 更新包签名校验通过:alg=${signature.algorithm},keyId=${signature.keyId}`,
|
||||
);
|
||||
|
||||
const artifacts = path.join(repoRoot, 'artifacts');
|
||||
// 只清理本 Job 的归档输出,不能把上次 DMG 当成本次成功产物。
|
||||
fs.rmSync(artifacts, { recursive: true, force: true });
|
||||
fs.mkdirSync(artifacts, { recursive: true });
|
||||
const sha256 = (file) => {
|
||||
const hash = createHash('sha256');
|
||||
hash.update(fs.readFileSync(file));
|
||||
return hash.digest('hex');
|
||||
};
|
||||
const dmgHash = sha256(dmg);
|
||||
fs.writeFileSync(`${dmg}.sha256`, `${dmgHash} ${path.basename(dmg)}\n`);
|
||||
|
||||
const uploadPlan = uploadReleaseArtifacts(release, {
|
||||
bucket,
|
||||
endpoint,
|
||||
binary: process.env.OSSUTIL_BIN?.trim() || 'ossutil',
|
||||
accessKeyId: process.env.AGC_OSS_ACCESS_KEY_ID?.trim(),
|
||||
accessKeySecret: process.env.AGC_OSS_ACCESS_KEY_SECRET,
|
||||
dryRun,
|
||||
});
|
||||
|
||||
const archived = [
|
||||
dmg,
|
||||
`${dmg}.sha256`,
|
||||
release.manifestPath,
|
||||
release.notesPath,
|
||||
`${release.artifact}.sig`,
|
||||
];
|
||||
for (const file of archived) {
|
||||
fs.copyFileSync(file, path.join(artifacts, path.basename(file)));
|
||||
}
|
||||
|
||||
const commit = execFileSync('git', ['rev-parse', 'HEAD'], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
// Apple 签名状态必须实测:剥离 APPLE_* 后 Tauri 通常跳过签名,但节点若装了 Developer ID
|
||||
// 证书仍可能签上,硬编码 appleSigned=false 会把「其实签了」写成假事实。
|
||||
const signatureProbe = spawnSync('codesign', ['-dv', '--verbose=2', app], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
const signatureText = `${signatureProbe.stdout ?? ''}${signatureProbe.stderr ?? ''}`;
|
||||
const appleSigned = /Authority=Developer ID Application/u.test(signatureText);
|
||||
const appleSignatureKind = appleSigned
|
||||
? 'developer-id'
|
||||
: /Signature=adhoc/u.test(signatureText)
|
||||
? 'adhoc'
|
||||
: 'unsigned';
|
||||
fs.writeFileSync(
|
||||
path.join(artifacts, 'build-manifest.json'),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version,
|
||||
commit,
|
||||
target: context.target,
|
||||
channel: context.channel,
|
||||
// Apple 签名与公证暂缺:显式记录为未验证项,不静默通过。
|
||||
appleSigned,
|
||||
appleSignatureKind,
|
||||
notarized: false,
|
||||
dryRun,
|
||||
uploaded: !dryRun,
|
||||
updaterSignature: {
|
||||
algorithm: signature.algorithm,
|
||||
keyId: signature.keyId,
|
||||
verified: true,
|
||||
},
|
||||
oss: {
|
||||
bucket,
|
||||
endpoint,
|
||||
partition,
|
||||
latest: `oss://${bucket}/agc/${partition}/latest.json`,
|
||||
objects: uploadPlan.map(({ destination }) => destination),
|
||||
},
|
||||
artifacts: {
|
||||
updater: path.basename(release.artifact),
|
||||
updaterSha256: sha256(release.artifact),
|
||||
updaterBytes: fs.statSync(release.artifact).size,
|
||||
updaterSignature: path.basename(`${release.artifact}.sig`),
|
||||
firstInstall: path.basename(dmg),
|
||||
firstInstallSha256: dmgHash,
|
||||
manifest: 'latest.json',
|
||||
},
|
||||
// 单架构发布:只跑 arm64 隔离 smoke;Intel 未支持(清单里没有 darwin-x86_64 键)。
|
||||
smokes: ['arm64'],
|
||||
manifestPlatformKeys: resolveManifestPlatformKeys(context.target),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
console.log(
|
||||
dryRun
|
||||
? `[agc-macos] dry-run 完成:${partition} 分区产物与清单已生成,未写入 OSS`
|
||||
: `[agc-macos] ${partition} 分区更新包、签名、首装包与清单已上传 OSS`,
|
||||
);
|
||||
@@ -13,6 +13,12 @@ import {
|
||||
defaultEditorFeatures,
|
||||
withDefaultCargoFeatures,
|
||||
} from './cargo-features.mjs';
|
||||
import {
|
||||
resolveChannelInstallIdentity,
|
||||
resolveReleaseChannel,
|
||||
} from './channel-identity.mjs';
|
||||
import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs';
|
||||
import { stageNodeRuntime } from './stage-node-runtime.mjs';
|
||||
|
||||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
// 提交摘要里的 pathspec 与 `git log` 都以仓库根为基准,不能在应用目录里执行。
|
||||
@@ -46,16 +52,12 @@ function explicitBuildTarget(args) {
|
||||
}
|
||||
|
||||
function validateReleaseTarget(target) {
|
||||
if (target === 'universal-apple-darwin') {
|
||||
throw new Error(
|
||||
'内置 Codex 资源仅支持 macOS 单架构构建,请使用 aarch64-apple-darwin 或 x86_64-apple-darwin',
|
||||
);
|
||||
}
|
||||
if (
|
||||
![
|
||||
'x86_64-pc-windows-msvc',
|
||||
'aarch64-apple-darwin',
|
||||
'x86_64-apple-darwin',
|
||||
'universal-apple-darwin',
|
||||
].includes(target)
|
||||
) {
|
||||
throw new Error(`不支持的发布目标:${target}`);
|
||||
@@ -91,14 +93,7 @@ const cargoLockPath = path.join(appRoot, 'src-tauri', 'Cargo.lock');
|
||||
const defaultOssBaseUrl =
|
||||
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc';
|
||||
|
||||
const reservedChannelNames = new Set([
|
||||
'win',
|
||||
'mac',
|
||||
'windows',
|
||||
'macos',
|
||||
'darwin',
|
||||
'linux',
|
||||
]);
|
||||
export { resolveReleaseChannel } from './channel-identity.mjs';
|
||||
|
||||
/**
|
||||
* 影响 Windows 客户端产物的路径。调度管线的发布范围判定与这里的提交摘要必须
|
||||
@@ -167,21 +162,6 @@ export function resolveReleasePlatform(target = defaultTarget()) {
|
||||
throw new Error(`不支持的发布目标:${target}`);
|
||||
}
|
||||
|
||||
export function resolveReleaseChannel(env = process.env) {
|
||||
const channel = env.AGC_UPDATE_CHANNEL?.trim() ?? 'dev';
|
||||
if (
|
||||
!/^[a-z][a-z0-9-]{0,31}$/u.test(channel) ||
|
||||
channel.endsWith('-') ||
|
||||
reservedChannelNames.has(channel) ||
|
||||
/-(win|mac)$/u.test(channel)
|
||||
) {
|
||||
throw new Error(
|
||||
'发布渠道无效:请使用 dev、release 或最多 32 位的小写字母、数字和连字符名称,系统名称不属于渠道',
|
||||
);
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
/** 系统分区延续已发布客户端端点,渠道本身不包含系统。 */
|
||||
export function resolveReleasePartition(
|
||||
channel = resolveReleaseChannel(),
|
||||
@@ -200,10 +180,12 @@ export function updateManifestUrl(
|
||||
}
|
||||
|
||||
/**
|
||||
* 单架构产物只登记实际目标,不能把同一原生资源映射为另一架构。
|
||||
* universal 主程序与双目录原生资源共用一个更新包;单架构只登记实际目标。
|
||||
*/
|
||||
export function resolveManifestPlatformKeys(target = defaultTarget()) {
|
||||
validateReleaseTarget(target);
|
||||
if (target === 'universal-apple-darwin')
|
||||
return ['darwin-aarch64', 'darwin-x86_64'];
|
||||
if (target === 'aarch64-apple-darwin') return ['darwin-aarch64'];
|
||||
if (target === 'x86_64-apple-darwin') return ['darwin-x86_64'];
|
||||
if (target.includes('windows')) {
|
||||
@@ -428,12 +410,19 @@ export function buildTauriBuildArguments(
|
||||
];
|
||||
}
|
||||
|
||||
/** 渠道端点必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道。 */
|
||||
/**
|
||||
* 渠道端点与安装身份必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道,
|
||||
* 而 `productName` / `identifier` 决定安装目录、卸载项与客户端数据目录,
|
||||
* 不同渠道必须在同一台设备上并存而不是互相顶掉。
|
||||
*/
|
||||
export function createChannelConfig(
|
||||
channel = resolveReleaseChannel(),
|
||||
target = defaultTarget(),
|
||||
) {
|
||||
const { productName, identifier } = resolveChannelInstallIdentity(channel);
|
||||
return {
|
||||
productName,
|
||||
identifier,
|
||||
plugins: {
|
||||
updater: {
|
||||
endpoints: [updateManifestUrl(channel, target)],
|
||||
@@ -442,22 +431,25 @@ export function createChannelConfig(
|
||||
};
|
||||
}
|
||||
|
||||
function writeChannelConfigFile(channel, target) {
|
||||
function writeChannelConfigFile(channel, target, includeNodeRuntime = false) {
|
||||
const configPath = path.join(
|
||||
os.tmpdir(),
|
||||
`agc-tauri-channel-${channel}-${target}.json`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
`${JSON.stringify(createChannelConfig(channel, target), null, 2)}\n`,
|
||||
);
|
||||
const config = createChannelConfig(channel, target);
|
||||
// 普通 cargo test/dev 不要求发行资源;只有完成 staging 的发行构建加入映射。
|
||||
if (includeNodeRuntime)
|
||||
config.bundle = {
|
||||
resources: { 'resources/node-runtime': 'game-runtime/node' },
|
||||
};
|
||||
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
||||
return configPath;
|
||||
}
|
||||
|
||||
export function runTauriBuild(
|
||||
args = [],
|
||||
context = resolveReleaseContext(args),
|
||||
{ spawn = spawnSync } = {},
|
||||
{ spawn = spawnSync, stageRuntime = stageNodeRuntime } = {},
|
||||
) {
|
||||
if (
|
||||
explicitBuildTarget(args) &&
|
||||
@@ -467,7 +459,12 @@ export function runTauriBuild(
|
||||
}
|
||||
const tauriArguments = buildTauriBuildArguments(args, context.target);
|
||||
const { channel, target } = context;
|
||||
const configPath = writeChannelConfigFile(channel, target);
|
||||
if (!args.includes('--no-bundle')) stageRuntime(target);
|
||||
const configPath = writeChannelConfigFile(
|
||||
channel,
|
||||
target,
|
||||
!args.includes('--no-bundle'),
|
||||
);
|
||||
console.log(
|
||||
`[ai-game-creator-shell] 渠道 ${channel} 端点配置:${configPath}`,
|
||||
);
|
||||
@@ -483,7 +480,17 @@ export function runTauriBuild(
|
||||
const result = spawn(
|
||||
npmCommand,
|
||||
['--prefix', '../..', 'exec', 'tauri', '--', ...tauriArguments],
|
||||
{ cwd: appRoot, stdio: 'inherit', shell: process.platform === 'win32' },
|
||||
{
|
||||
cwd: appRoot,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
env: {
|
||||
...process.env,
|
||||
// Vite embeds the platform API origin in the packaged renderer. The
|
||||
// release channel and updater channel therefore cannot drift apart.
|
||||
VITE_AGC_PLATFORM_CHANNEL: channel,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||
@@ -543,6 +550,18 @@ export function selectFirstInstallArtifact(
|
||||
if (!selected?.endsWith('.exe')) {
|
||||
throw new Error('Windows 首装包必须复用本次 NSIS .exe 更新包');
|
||||
}
|
||||
} else if (target === 'universal-apple-darwin') {
|
||||
// universal 主程序只产出一个 DMG,aarch64 与 x86_64 首装共用它(命名见 build-macos-ci.mjs)。
|
||||
const suffix = `_${version}_universal.dmg`;
|
||||
const candidates = files.filter((file) =>
|
||||
path.basename(file).endsWith(suffix),
|
||||
);
|
||||
if (candidates.length !== 1) {
|
||||
throw new Error(
|
||||
`首装 DMG 必须唯一匹配本次版本 ${version} 的 universal 产物,找到 ${candidates.length} 个`,
|
||||
);
|
||||
}
|
||||
selected = candidates[0];
|
||||
} else {
|
||||
// Tauri DMG 文件名使用 aarch64 / x64,而 updater 的 Intel 平台键是 x86_64。
|
||||
const architecture = target.startsWith('aarch64') ? 'aarch64' : 'x64';
|
||||
@@ -848,14 +867,19 @@ export async function buildRelease(
|
||||
args = [],
|
||||
{
|
||||
prepareVersion = prepareReleaseVersion,
|
||||
prepareToolset = prepareNsisToolsetForRelease,
|
||||
build = runTauriBuild,
|
||||
generateManifest = generateUpdateManifest,
|
||||
} = {},
|
||||
) {
|
||||
const context = resolveReleaseContext(args);
|
||||
if (!args.includes('--no-bundle')) await prepareVersion(context);
|
||||
const bundling = !args.includes('--no-bundle');
|
||||
if (bundling) await prepareVersion(context);
|
||||
// Tauri bundler 下载 NSIS 工具链时不重试,网络截断会直接毁掉整次打包;
|
||||
// 因此打包前先在 Windows 目标上预置(详见 nsis-toolset.mjs)。
|
||||
if (bundling) await prepareToolset(context, { bundling });
|
||||
build(args, context);
|
||||
if (!args.includes('--no-bundle')) return generateManifest(context);
|
||||
if (bundling) return generateManifest(context);
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -37,6 +37,11 @@ import {
|
||||
selectReleaseArtifact,
|
||||
updateManifestUrl,
|
||||
} from './build-release.mjs';
|
||||
import {
|
||||
AGC_APP_IDENTIFIER,
|
||||
AGC_PRODUCT_NAME,
|
||||
resolveChannelInstallIdentity,
|
||||
} from './channel-identity.mjs';
|
||||
|
||||
const windowsTarget = 'x86_64-pc-windows-msvc';
|
||||
const universalTarget = 'universal-apple-darwin';
|
||||
@@ -45,19 +50,22 @@ const packageVersion = JSON.parse(
|
||||
).version;
|
||||
|
||||
function createDmgFixture(root, target, version = packageVersion) {
|
||||
const architecture = target.startsWith('aarch64') ? 'aarch64' : 'x64';
|
||||
const architecture = target.startsWith('aarch64')
|
||||
? 'aarch64'
|
||||
: target === universalTarget
|
||||
? 'universal'
|
||||
: 'x64';
|
||||
const dmg = path.join(root, `陶泥儿_${version}_${architecture}.dmg`);
|
||||
writeFileSync(dmg, 'first installation disk image');
|
||||
return dmg;
|
||||
}
|
||||
|
||||
test('native sidecar builds reject universal targets and accept each macOS architecture', () => {
|
||||
assert.throws(() => buildTauriBuildArguments([], universalTarget), /单架构/);
|
||||
assert.throws(
|
||||
() => buildTauriBuildArguments(['--target=universal-apple-darwin']),
|
||||
/单架构/,
|
||||
);
|
||||
for (const target of ['aarch64-apple-darwin', 'x86_64-apple-darwin']) {
|
||||
test('native sidecar builds accept universal and each macOS architecture', () => {
|
||||
for (const target of [
|
||||
universalTarget,
|
||||
'aarch64-apple-darwin',
|
||||
'x86_64-apple-darwin',
|
||||
]) {
|
||||
assert.deepEqual(buildTauriBuildArguments([], target), [
|
||||
'build',
|
||||
'--target',
|
||||
@@ -181,6 +189,8 @@ test('channel manifest URL and build-time endpoint follow the channel', () => {
|
||||
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-win/latest.json',
|
||||
);
|
||||
assert.deepEqual(createChannelConfig('dev', 'aarch64-apple-darwin'), {
|
||||
productName: AGC_PRODUCT_NAME,
|
||||
identifier: AGC_APP_IDENTIFIER,
|
||||
plugins: {
|
||||
updater: {
|
||||
endpoints: [
|
||||
@@ -201,8 +211,91 @@ test('channel manifest URL and build-time endpoint follow the channel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('macOS manifests only advertise the architecture actually built', () => {
|
||||
assert.throws(() => resolveManifestPlatformKeys(universalTarget), /单架构/);
|
||||
test('channel install identity isolates co-installed builds and keeps the default channel stable', () => {
|
||||
// 默认渠道必须保持已发布客户端身份:改身份等于换一个 App,升级链会断。
|
||||
assert.deepEqual(resolveChannelInstallIdentity('dev'), {
|
||||
productName: AGC_PRODUCT_NAME,
|
||||
identifier: AGC_APP_IDENTIFIER,
|
||||
});
|
||||
assert.deepEqual(resolveChannelInstallIdentity('release'), {
|
||||
productName: '陶泥儿 Release',
|
||||
identifier: `${AGC_APP_IDENTIFIER}.release`,
|
||||
});
|
||||
assert.deepEqual(resolveChannelInstallIdentity('beta-2'), {
|
||||
productName: '陶泥儿 Beta-2',
|
||||
identifier: `${AGC_APP_IDENTIFIER}.beta-2`,
|
||||
});
|
||||
|
||||
// 同一台设备上不同渠道的安装目录、卸载项与数据目录必须互不相同。
|
||||
for (const channel of ['release', 'beta-2', 'a'.repeat(32)]) {
|
||||
const identity = resolveChannelInstallIdentity(channel);
|
||||
assert.notEqual(identity.productName, AGC_PRODUCT_NAME);
|
||||
assert.notEqual(identity.identifier, AGC_APP_IDENTIFIER);
|
||||
assert.ok(identity.identifier.startsWith(`${AGC_APP_IDENTIFIER}.`));
|
||||
}
|
||||
|
||||
for (const channel of ['dev-win', 'Release', 'win', 'beta-']) {
|
||||
assert.throws(
|
||||
() => resolveChannelInstallIdentity(channel),
|
||||
/发布渠道无效/u,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('channel install identity is baked into the same build-time config as the endpoint', () => {
|
||||
withEnv({ AGC_UPDATE_OSS_BASE_URL: undefined }, () => {
|
||||
const config = createChannelConfig('release', windowsTarget);
|
||||
assert.equal(config.productName, '陶泥儿 Release');
|
||||
assert.equal(config.identifier, `${AGC_APP_IDENTIFIER}.release`);
|
||||
assert.match(
|
||||
config.plugins.updater.endpoints[0],
|
||||
/\/release-win\/latest\.json$/u,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('channel products keep first-install selection working under the channel product name', () => {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-channel-dmg-'));
|
||||
try {
|
||||
const { productName } = resolveChannelInstallIdentity('release');
|
||||
const dmg = path.join(root, `${productName}_${packageVersion}_aarch64.dmg`);
|
||||
writeFileSync(dmg, 'channel first installation disk image');
|
||||
writeFileSync(path.join(root, 'windows.exe'), 'wrong platform');
|
||||
assert.equal(
|
||||
selectFirstInstallArtifact([dmg, path.join(root, 'windows.exe')], {
|
||||
target: 'aarch64-apple-darwin',
|
||||
version: packageVersion,
|
||||
}),
|
||||
dmg,
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('packaged renderer receives the same channel as the updater manifest', () => {
|
||||
const context = resolveReleaseContext([], {
|
||||
AGC_BUILD_TARGET: windowsTarget,
|
||||
AGC_UPDATE_CHANNEL: 'release',
|
||||
});
|
||||
let spawnOptions;
|
||||
runTauriBuild([], context, {
|
||||
// 必须 stub:真实 staging 会用宿主平台(如 macOS 的 darwin/arm64)去对默认的
|
||||
// Windows 目标做一致性校验,在非 Windows 主机上直接失败——本用例只关心渠道注入。
|
||||
stageRuntime: () => {},
|
||||
spawn: (_binary, _args, options) => {
|
||||
spawnOptions = options;
|
||||
return { status: 0 };
|
||||
},
|
||||
});
|
||||
assert.equal(spawnOptions?.env?.VITE_AGC_PLATFORM_CHANNEL, 'release');
|
||||
});
|
||||
|
||||
test('macOS manifests advertise exactly the architectures actually built', () => {
|
||||
assert.deepEqual(resolveManifestPlatformKeys(universalTarget), [
|
||||
'darwin-aarch64',
|
||||
'darwin-x86_64',
|
||||
]);
|
||||
assert.deepEqual(resolveManifestPlatformKeys('aarch64-apple-darwin'), [
|
||||
'darwin-aarch64',
|
||||
]);
|
||||
@@ -246,7 +339,6 @@ test('release context resolves explicit targets before environment/default and f
|
||||
['--target='],
|
||||
['--target', '--no-bundle'],
|
||||
['--target', windowsTarget, '--target=aarch64-apple-darwin'],
|
||||
['--target', universalTarget],
|
||||
['--target', 'unknown'],
|
||||
])
|
||||
assert.throws(() => resolveReleaseContext(args, {}));
|
||||
@@ -279,6 +371,7 @@ test('explicit macOS target drives version lookup, Tauri endpoint, artifact and
|
||||
build: (args, context) => {
|
||||
seenContexts.push(context);
|
||||
runTauriBuild(args, context, {
|
||||
stageRuntime: () => {},
|
||||
spawn: (_binary, command) => {
|
||||
const configIndex = command.lastIndexOf('--config');
|
||||
const config = JSON.parse(
|
||||
@@ -461,8 +554,8 @@ test('invalid target or platform used as channel fails before any release side e
|
||||
},
|
||||
};
|
||||
await assert.rejects(
|
||||
() => buildRelease(['--target', universalTarget], sideEffects),
|
||||
/单架构/,
|
||||
() => buildRelease(['--target', 'unknown'], sideEffects),
|
||||
/不支持的发布目标/,
|
||||
);
|
||||
await withEnv({ AGC_UPDATE_CHANNEL: 'dev-win' }, () =>
|
||||
assert.rejects(
|
||||
@@ -473,6 +566,43 @@ test('invalid target or platform used as channel fails before any release side e
|
||||
assert.equal(touched, false);
|
||||
});
|
||||
|
||||
test('universal uses the Mac channel and the same signed artifact for both architectures', () => {
|
||||
const context = resolveReleaseContext(['--target', universalTarget], {
|
||||
AGC_BUILD_TARGET: windowsTarget,
|
||||
});
|
||||
// 渠道本身不含系统:分区由渠道 + 目标推导,二者不能混为一谈。
|
||||
assert.equal(context.channel, 'dev');
|
||||
assert.equal(
|
||||
resolveReleasePartition(context.channel, context.target),
|
||||
'dev-mac',
|
||||
);
|
||||
assert.ok(context.bundleRoot.includes(universalTarget));
|
||||
withSignedArtifact('陶泥儿.app.tar.gz', (artifact) => {
|
||||
const manifest = createUpdateManifest(artifact, {
|
||||
...context,
|
||||
downloadArtifact: createDmgFixture(
|
||||
path.dirname(artifact),
|
||||
universalTarget,
|
||||
),
|
||||
});
|
||||
assert.deepEqual(Object.keys(manifest.platforms), [
|
||||
'darwin-aarch64',
|
||||
'darwin-x86_64',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
manifest.platforms['darwin-aarch64'],
|
||||
manifest.platforms['darwin-x86_64'],
|
||||
);
|
||||
assert.match(manifest.platforms['darwin-aarch64'].url, /\/dev-mac\//);
|
||||
// 两个平台键共用同一个 universal 首装包,不能要求出两份架构 DMG。
|
||||
assert.deepEqual(
|
||||
manifest.downloads['darwin-aarch64'].url,
|
||||
manifest.downloads['darwin-x86_64'].url,
|
||||
);
|
||||
assert.match(manifest.downloads['darwin-aarch64'].url, /_universal\.dmg$/u);
|
||||
});
|
||||
});
|
||||
|
||||
test('Windows remains the default and explicit Windows overrides macOS environment', () => {
|
||||
const files = ['/tmp/mac.app.tar.gz', '/tmp/windows.exe', '/tmp/mac.dmg'];
|
||||
for (const context of [
|
||||
@@ -490,6 +620,7 @@ test('Windows remains the default and explicit Windows overrides macOS environme
|
||||
['--target', windowsTarget, '--config', 'user-config.json'],
|
||||
context,
|
||||
{
|
||||
stageRuntime: () => {},
|
||||
spawn: (_binary, command) => {
|
||||
assert.ok(
|
||||
command.includes(
|
||||
@@ -528,6 +659,117 @@ test('no-bundle smoke skips version writes and manifest generation', async () =>
|
||||
assert.deepEqual(steps, ['dev']);
|
||||
});
|
||||
|
||||
test('Windows 打包在 Tauri 构建前预置 NSIS 工具链', async () => {
|
||||
const events = [];
|
||||
await buildRelease(['--target', windowsTarget], {
|
||||
prepareVersion: () => {
|
||||
events.push('version');
|
||||
},
|
||||
prepareToolset: (context, options) => {
|
||||
events.push(`toolset:${context.target}:${options.bundling}`);
|
||||
},
|
||||
build: () => {
|
||||
events.push('build');
|
||||
},
|
||||
generateManifest: () => {
|
||||
events.push('manifest');
|
||||
},
|
||||
});
|
||||
assert.deepEqual(events, [
|
||||
'version',
|
||||
`toolset:${windowsTarget}:true`,
|
||||
'build',
|
||||
'manifest',
|
||||
]);
|
||||
});
|
||||
|
||||
test('NSIS 工具链预置失败即失败关闭,不进入 Tauri 构建', async () => {
|
||||
const events = [];
|
||||
await assert.rejects(
|
||||
buildRelease(['--target', windowsTarget], {
|
||||
prepareVersion: () => {
|
||||
events.push('version');
|
||||
},
|
||||
prepareToolset: () => {
|
||||
throw new Error('NSIS 工具链预置失败:下载 nsis-3.11.zip 失败');
|
||||
},
|
||||
build: () => {
|
||||
events.push('build');
|
||||
},
|
||||
generateManifest: () => {
|
||||
events.push('manifest');
|
||||
},
|
||||
}),
|
||||
/NSIS 工具链预置失败/u,
|
||||
);
|
||||
assert.deepEqual(events, ['version']);
|
||||
});
|
||||
|
||||
test('--no-bundle 不预置 NSIS 工具链', async () => {
|
||||
const steps = [];
|
||||
await buildRelease(['--no-bundle', '--target', windowsTarget], {
|
||||
prepareVersion: () => {
|
||||
steps.push('version');
|
||||
},
|
||||
prepareToolset: () => {
|
||||
steps.push('toolset');
|
||||
},
|
||||
build: () => {
|
||||
steps.push('build');
|
||||
},
|
||||
generateManifest: () => {
|
||||
steps.push('manifest');
|
||||
},
|
||||
});
|
||||
assert.deepEqual(steps, ['build']);
|
||||
});
|
||||
|
||||
test('release stages Node before Tauri and injects its resource mapping only for bundles', () => {
|
||||
const context = resolveReleaseContext(['--target', windowsTarget]);
|
||||
const events = [];
|
||||
runTauriBuild(['--target', windowsTarget], context, {
|
||||
stageRuntime(target) {
|
||||
assert.equal(target, windowsTarget);
|
||||
events.push('stage');
|
||||
},
|
||||
spawn(_binary, args) {
|
||||
events.push('build');
|
||||
const config = JSON.parse(
|
||||
readFileSync(args[args.lastIndexOf('--config') + 1], 'utf8'),
|
||||
);
|
||||
assert.deepEqual(config.bundle.resources, {
|
||||
'resources/node-runtime': 'game-runtime/node',
|
||||
});
|
||||
return { status: 0 };
|
||||
},
|
||||
});
|
||||
assert.deepEqual(events, ['stage', 'build']);
|
||||
runTauriBuild(['--no-bundle', '--target', windowsTarget], context, {
|
||||
stageRuntime() {
|
||||
assert.fail('no-bundle must not stage resources');
|
||||
},
|
||||
spawn(_binary, args) {
|
||||
const config = JSON.parse(
|
||||
readFileSync(args[args.lastIndexOf('--config') + 1], 'utf8'),
|
||||
);
|
||||
assert.equal(config.bundle, undefined);
|
||||
return { status: 0 };
|
||||
},
|
||||
});
|
||||
assert.throws(
|
||||
() =>
|
||||
runTauriBuild(['--target', windowsTarget], context, {
|
||||
stageRuntime() {
|
||||
throw new Error('missing runtime');
|
||||
},
|
||||
spawn() {
|
||||
assert.fail('invalid runtime must prevent build');
|
||||
},
|
||||
}),
|
||||
/missing runtime/,
|
||||
);
|
||||
});
|
||||
|
||||
test('channel manifest carries version, platform keys and signature', () => {
|
||||
withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => {
|
||||
withEnv({ AGC_UPDATE_RELEASE_NOTES: '修复与改进' }, () => {
|
||||
@@ -631,6 +873,7 @@ for (const channel of ['release', 'beta-2']) {
|
||||
'2.3.4',
|
||||
);
|
||||
runTauriBuild([`--target=${target}`], context, {
|
||||
stageRuntime: () => {},
|
||||
spawn: (_binary, command) => {
|
||||
const config = JSON.parse(
|
||||
readFileSync(
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* AGC 渠道 → 安装身份。
|
||||
*
|
||||
* 渠道同时决定两件事:
|
||||
* - 更新端点:OSS 分区 `<channel>-win` / `<channel>-mac` 的清单地址;
|
||||
* - 安装身份:`productName` 与 `identifier`。
|
||||
*
|
||||
* 安装身份决定 Windows 安装目录与卸载项、macOS `.app` 名字与 bundle id、
|
||||
* Windows WebView2 数据目录以及 `%APPDATA%\<identifier>` 客户端数据目录。
|
||||
* 因此不同渠道的包体在同一台设备上并存时互不顶掉,也不会共享登录态、
|
||||
* 本地项目与运行锁。
|
||||
*
|
||||
* 默认渠道 `dev` 保持已发布客户端身份不变:升级链路与既有安装不能断。
|
||||
*/
|
||||
|
||||
export const AGC_DEFAULT_CHANNEL = 'dev';
|
||||
export const AGC_PRODUCT_NAME = '陶泥儿';
|
||||
export const AGC_APP_IDENTIFIER = 'world.genarrative.ai-game-creator';
|
||||
|
||||
const reservedChannelNames = new Set([
|
||||
'win',
|
||||
'mac',
|
||||
'windows',
|
||||
'macos',
|
||||
'darwin',
|
||||
'linux',
|
||||
]);
|
||||
|
||||
/** 校验渠道名:小写字母开头,允许数字与连字符,系统名不属于渠道。 */
|
||||
export function validateReleaseChannel(channel) {
|
||||
if (
|
||||
typeof channel !== 'string' ||
|
||||
!/^[a-z][a-z0-9-]{0,31}$/u.test(channel) ||
|
||||
channel.endsWith('-') ||
|
||||
reservedChannelNames.has(channel) ||
|
||||
/-(win|mac)$/u.test(channel)
|
||||
) {
|
||||
throw new Error(
|
||||
'发布渠道无效:请使用 dev、release 或最多 32 位的小写字母、数字和连字符名称,系统名称不属于渠道',
|
||||
);
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
export function resolveReleaseChannel(env = process.env) {
|
||||
return validateReleaseChannel(env.AGC_UPDATE_CHANNEL?.trim() ?? 'dev');
|
||||
}
|
||||
|
||||
/** 安装身份里的展示后缀:`release` → `Release`,`beta-2` → `Beta-2`。 */
|
||||
export function channelDisplaySuffix(channel) {
|
||||
return validateReleaseChannel(channel)
|
||||
.split('-')
|
||||
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
|
||||
.join('-');
|
||||
}
|
||||
|
||||
/**
|
||||
* 渠道对应的安装身份。默认渠道返回基线身份,其它渠道派生渠道后缀,
|
||||
* 保证同一台设备上不同渠道互不覆盖。
|
||||
*/
|
||||
export function resolveChannelInstallIdentity(channel = AGC_DEFAULT_CHANNEL) {
|
||||
validateReleaseChannel(channel);
|
||||
if (channel === AGC_DEFAULT_CHANNEL) {
|
||||
return Object.freeze({
|
||||
productName: AGC_PRODUCT_NAME,
|
||||
identifier: AGC_APP_IDENTIFIER,
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
productName: `${AGC_PRODUCT_NAME} ${channelDisplaySuffix(channel)}`,
|
||||
identifier: `${AGC_APP_IDENTIFIER}.${channel}`,
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user