Merge remote-tracking branch 'origin/master' into feat/game-works-management
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m52s
Project CI / Backend tests (pull_request) Failing after 14s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m19s
Project CI / Frontend tests (pull_request) Successful in 2m10s
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m40s
Project CI / Native shell tests (pull_request) Successful in 5m16s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m42s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m16s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m52s
Project CI / Backend tests (pull_request) Failing after 14s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m19s
Project CI / Frontend tests (pull_request) Successful in 2m10s
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m40s
Project CI / Native shell tests (pull_request) Successful in 5m16s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m42s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m16s
This commit is contained in:
@@ -47,8 +47,8 @@ env:
|
||||
# 各自顺序运行两片,让一次 job 只做一次依赖预热,同时避免同一容器内多进程并行争抢
|
||||
# 共享 HOME、target 与固定临时路径。每个分片调用仍会自校验「片并集等于全集且互斥」。
|
||||
jobs:
|
||||
# AGC 壳自身的 Rust bin 单测由两条 lane 承载,每条 lane 顺序运行两片;这里不装 npm
|
||||
# 依赖:壳 Rust 门禁只用 cargo 与 node 内建模块,也只需要 AGC 壳自己那份锁定依赖。
|
||||
# AGC 壳自身的 Rust bin 单测由两条 lane 承载,每条 lane 顺序运行两片;
|
||||
# build.rs 会准备 Claude Agent SDK sidecar,因此编译前必须安装根 workspace npm 依赖。
|
||||
ai-game-creator-shell-rust-lane-1:
|
||||
name: AI game creator shell Rust lane 1/2
|
||||
runs-on: genarrative-ci
|
||||
@@ -62,6 +62,9 @@ jobs:
|
||||
- name: Validate preinstalled CI job image and sandbox
|
||||
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: bash scripts/ci-npm-ci-with-retry.sh
|
||||
|
||||
- name: Prepare AI game creator shell Rust dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -118,6 +121,9 @@ jobs:
|
||||
- name: Validate preinstalled CI job image and sandbox
|
||||
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: bash scripts/ci-npm-ci-with-retry.sh
|
||||
|
||||
- name: Prepare AI game creator shell Rust dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -154,7 +160,7 @@ jobs:
|
||||
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
|
||||
run: python3 scripts/export-gitea-rust-cache.py
|
||||
|
||||
# agent-run smoke 会 spawn `cargo run`(走壳自己的 manifest),同样不装 npm 依赖,
|
||||
# agent-run smoke 会 spawn `cargo run`(走壳自己的 manifest),同样需要 npm 随包依赖,
|
||||
# 单独一个 job,免得把已经压到 4 分钟级的片 job 拖长。
|
||||
ai-game-creator-shell-rust-smoke:
|
||||
name: AI game creator shell Rust smoke
|
||||
@@ -169,6 +175,9 @@ jobs:
|
||||
- name: Validate preinstalled CI job image and sandbox
|
||||
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: bash scripts/ci-npm-ci-with-retry.sh
|
||||
|
||||
- name: Prepare AI game creator shell Rust dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -58,6 +58,12 @@ temp*build*/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/node-runtime/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/claude-agent/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/node-runtime-staging-*/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/plugins-staging-*/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/*-staging-*/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/plugins-backup-*/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/*-backup-*/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/claude-agent-staging-*/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/claude-agent-backup-*/
|
||||
/apps/ai-game-creator-shell/.llm-drafts/
|
||||
/apps/ai-game-creator-shell/game-creator.config.local.json
|
||||
/apps/mobile-shell/.expo/
|
||||
|
||||
+21
-9
@@ -192,7 +192,7 @@ _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
|
||||
## 项目开发对话(DirectProject)
|
||||
|
||||
**DirectProject 专属聊天模块**:
|
||||
AGC 普通项目聊天的独立容器,拥有 DirectProject 的聊天状态、运行态订阅、历史读取、发送队列、附件和中止交互,并把聊天投影交给专属表现层渲染;它不承接 Supervisor、Design Agent 或 Planning V2 的运行态。
|
||||
AGC 普通项目聊天的独立容器,拥有 DirectProject 的聊天状态、运行态订阅、历史读取、待发消息队列的投影、附件和中止交互,并把聊天投影交给专属表现层渲染;它不承接 Supervisor、Design Agent 或 Planning V2 的运行态。
|
||||
_Avoid_: 把 DirectProject 作为项目总控聊天的一个布尔分支、把四种 Agent 会话抽象成同一事实源
|
||||
|
||||
**项目工作台布局**:
|
||||
@@ -208,20 +208,32 @@ Thread Manager 向订阅者推送的当前回合原始事件流,只服务运
|
||||
_Avoid_: 进度通知、快照轮询、第二套历史
|
||||
|
||||
**逻辑回合**:
|
||||
Thread Manager 拥有的一对回合边界(开始与结束),由接单动作开启、由这一轮的占用对象写出,不镜像 Codex 原生回合;界面忙碌态与回合结果只认它。
|
||||
Thread Manager 拥有的一对回合边界(开始与结束),由放行动作开启、由这一轮的占用对象写出,不镜像 Codex 原生回合;界面忙碌态与回合结果只认它。
|
||||
_Avoid_: Codex 原生回合、原生日志、进程生命周期
|
||||
|
||||
**接单**:
|
||||
把一条用户消息交给宿主开始执行的动作,成立即表示这一轮已经存在;此后结果只由运行态事件回答。
|
||||
_Avoid_: 发送成功、命令调用、接口返回
|
||||
**待发消息队列**:
|
||||
Thread Manager 按项目持有的待发用户消息序列,只支持按入队顺序追加与按身份移除,状态由运行态事件派生,不落盘、不构成第二份事实源。
|
||||
_Avoid_: 前端本地队列、队列副本、待发消息的持久化记录
|
||||
|
||||
**拒单**:
|
||||
接单成立之前拒绝这次请求(并发、权限、目录、参数、工程准备未就绪),只回一条可展示原因,不产生回合事件,也不写用户条目。
|
||||
**待发消息**:
|
||||
已经通过入队检查、等待被放行的用户消息;它在放行之前不是回合,不写用户条目、不产生回合事件。
|
||||
_Avoid_: 回合、在途回合、草稿
|
||||
|
||||
**入队**:
|
||||
把一条用户消息交给宿主的动作:宿主跑完入队检查后把它放进待发消息队列;入队成立只表示这条消息会按顺序被放行。
|
||||
_Avoid_: 发送成功、已经开跑、回合成立
|
||||
|
||||
**入队失败**:
|
||||
入队检查未通过(身份、形状、容量、权限、目录、参数、工程准备未就绪)时拒绝这次请求,只回一条可展示原因,不入队、不产生回合事件,也不写用户条目。
|
||||
_Avoid_: 回合失败、执行失败、失败事件
|
||||
|
||||
**放行**:
|
||||
Thread Manager 在一个回合收口之后把队首的待发消息送进回合:同一临界区里登记占用、落盘用户条目、发出逻辑回合开始事件并起整轮;放行之后的结果只由运行态事件回答。
|
||||
_Avoid_: 前端放行、定时轮询、放行失败
|
||||
|
||||
**在途回合**:
|
||||
界面本地已经把这条用户消息发出去、宿主还没有对应回合开始事件的那一小段状态。
|
||||
_Avoid_: 运行中回合、乐观锁、发送队列
|
||||
界面本地已经入队、宿主还没有对应回合开始事件的那一小段状态。
|
||||
_Avoid_: 运行中回合、乐观锁、前端发送队列
|
||||
|
||||
**聊天投影**:
|
||||
把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。
|
||||
|
||||
@@ -16,7 +16,10 @@ import {
|
||||
getAdminProjectSnapshotChannels,
|
||||
listAdminProjectSnapshots,
|
||||
} from '../api/adminApiClient';
|
||||
import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes';
|
||||
import type {
|
||||
AdminProjectSnapshotEntry,
|
||||
AdminProjectSnapshotListResponse,
|
||||
} from '../api/adminApiTypes';
|
||||
import { AdminProjectSnapshotsPage } from './AdminProjectSnapshotsPage';
|
||||
|
||||
vi.mock('../api/adminApiClient', async () => ({
|
||||
@@ -58,6 +61,241 @@ afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('全量读取后按同步时间和身份排序,本地分页并支持刷新和恢复默认', async () => {
|
||||
const olderProjects = Array.from({ length: 22 }, (_, index) => ({
|
||||
...entry,
|
||||
projectId: `older-${index}`,
|
||||
projectName: `旧项目 ${index}`,
|
||||
syncedAtMs: entry.syncedAtMs + index,
|
||||
}));
|
||||
const tiedProjects = [
|
||||
{ ...entry, userId: 'user-2', projectId: 'p-1', projectName: '同秒乙' },
|
||||
{ ...entry, userId: 'user-1', projectId: 'p-2', projectName: '同秒甲二' },
|
||||
{ ...entry, userId: 'user-1', projectId: 'p-1', projectName: '同秒甲一' },
|
||||
].map((project) => ({ ...project, syncedAtMs: entry.syncedAtMs + 100 }));
|
||||
vi.mocked(listAdminProjectSnapshots)
|
||||
.mockResolvedValueOnce({ items: [entry], nextCursor: 'original-next' })
|
||||
.mockResolvedValueOnce({ items: olderProjects, nextCursor: 'scan-2' })
|
||||
// 后端有界扫描可能返回带下一游标的空页,不能提前结束。
|
||||
.mockResolvedValueOnce({ items: [], nextCursor: 'scan-3' })
|
||||
.mockResolvedValueOnce({
|
||||
items: [
|
||||
...tiedProjects,
|
||||
{
|
||||
...olderProjects[0]!,
|
||||
projectName: '更新项目',
|
||||
syncedAtMs: entry.syncedAtMs + 200,
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
||||
await screen.findByText('三消工程');
|
||||
fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' }));
|
||||
await screen.findByRole('button', { name: '恢复默认顺序' });
|
||||
const rowNames = () =>
|
||||
screen
|
||||
.getAllByRole('row')
|
||||
.map((row) => row.querySelector('td strong')?.textContent)
|
||||
.filter(Boolean);
|
||||
expect(rowNames().slice(0, 4)).toEqual([
|
||||
'更新项目',
|
||||
'同秒甲一',
|
||||
'同秒甲二',
|
||||
'同秒乙',
|
||||
]);
|
||||
expect(rowNames()).toHaveLength(20);
|
||||
expect(screen.getByRole('navigation').textContent).toContain('共 25 个');
|
||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
'token',
|
||||
{ cursor: 'scan-3', limit: 100, channel: 'dev' },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
||||
expect(rowNames()).toHaveLength(5);
|
||||
expect(screen.getByRole('navigation').textContent).toContain('第 2 页');
|
||||
fireEvent.click(screen.getByRole('button', { name: '上一页' }));
|
||||
expect(rowNames()[0]).toBe('更新项目');
|
||||
fireEvent.change(screen.getByLabelText('每页条数'), {
|
||||
target: { value: '50' },
|
||||
});
|
||||
expect(rowNames()).toHaveLength(25);
|
||||
expect(listAdminProjectSnapshots).toHaveBeenCalledTimes(4);
|
||||
|
||||
vi.mocked(listAdminProjectSnapshots).mockResolvedValueOnce({
|
||||
items: [{ ...entry, projectName: '刷新后的项目' }],
|
||||
nextCursor: null,
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
||||
await screen.findByText('刷新后的项目');
|
||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||
5,
|
||||
'token',
|
||||
{ cursor: null, limit: 100, channel: 'dev' },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '恢复默认顺序' }));
|
||||
await screen.findByText('三消工程');
|
||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||
6,
|
||||
'token',
|
||||
{ cursor: null, limit: 50, channel: 'dev' },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
});
|
||||
|
||||
test('恢复默认顺序失败保留排序分页,重试成功后切换远端第一页及游标', async () => {
|
||||
const projects = Array.from({ length: 45 }, (_, index) => ({
|
||||
...entry,
|
||||
projectId: `sorted-${index}`,
|
||||
projectName: `排序项目 ${index}`,
|
||||
syncedAtMs: entry.syncedAtMs - index,
|
||||
}));
|
||||
vi.mocked(listAdminProjectSnapshots)
|
||||
.mockResolvedValueOnce({ items: [entry], nextCursor: null })
|
||||
.mockResolvedValueOnce({ items: projects, nextCursor: null })
|
||||
.mockRejectedValueOnce(new Error('恢复失败'))
|
||||
.mockResolvedValueOnce({
|
||||
items: [{ ...entry, projectName: '默认第一页' }],
|
||||
nextCursor: 'default-next',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: [{ ...entry, projectName: '默认第二页' }],
|
||||
nextCursor: null,
|
||||
});
|
||||
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
||||
await screen.findByText('三消工程');
|
||||
fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' }));
|
||||
await screen.findByText('排序项目 0');
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '恢复默认顺序' }));
|
||||
await screen.findByText('恢复失败');
|
||||
expect(screen.getByText('排序项目 20')).toBeTruthy();
|
||||
expect(screen.getByRole('navigation').textContent).toContain('第 2 页');
|
||||
expect(screen.getByRole('navigation').textContent).toContain('共 45 个');
|
||||
expect(screen.getByRole('button', { name: '恢复默认顺序' })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
||||
expect(screen.getByText('排序项目 40')).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: '上一页' }));
|
||||
expect(screen.getByText('排序项目 20')).toBeTruthy();
|
||||
expect(listAdminProjectSnapshots).toHaveBeenCalledTimes(3);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '恢复默认顺序' }));
|
||||
await screen.findByText('默认第一页');
|
||||
expect(screen.getByRole('navigation').textContent).toContain('第 1 页');
|
||||
expect(screen.getByRole('button', { name: '按同步时间排序' })).toBeTruthy();
|
||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
'token',
|
||||
{ cursor: null, limit: 20, channel: 'dev' },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
||||
await screen.findByText('默认第二页');
|
||||
expect(screen.getByRole('navigation').textContent).toContain('第 2 页');
|
||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||
5,
|
||||
'token',
|
||||
{ cursor: 'default-next', limit: 20, channel: 'dev' },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
});
|
||||
|
||||
test('全量排序中途失败不发布部分结果,重试刷新失败保留完整排序', async () => {
|
||||
vi.mocked(listAdminProjectSnapshots)
|
||||
.mockResolvedValueOnce({ items: [entry], nextCursor: 'original-next' })
|
||||
.mockResolvedValueOnce({
|
||||
items: [{ ...entry, projectName: '部分读取项目' }],
|
||||
nextCursor: 'scan-next',
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('全量读取失败'));
|
||||
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
||||
await screen.findByText('三消工程');
|
||||
fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' }));
|
||||
await screen.findByText('全量读取失败');
|
||||
expect(screen.getByText('三消工程')).toBeTruthy();
|
||||
expect(screen.queryByText('部分读取项目')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '恢复默认顺序' })).toBeNull();
|
||||
expect(
|
||||
screen.getByRole('button', { name: '下一页' }).hasAttribute('disabled'),
|
||||
).toBe(false);
|
||||
|
||||
vi.mocked(listAdminProjectSnapshots).mockResolvedValueOnce({
|
||||
items: [{ ...entry, projectName: '完整排序项目' }],
|
||||
nextCursor: null,
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' }));
|
||||
await screen.findByText('完整排序项目');
|
||||
vi.mocked(listAdminProjectSnapshots).mockRejectedValueOnce(
|
||||
new Error('刷新读取失败'),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
||||
await screen.findByText('刷新读取失败');
|
||||
expect(screen.getByText('完整排序项目')).toBeTruthy();
|
||||
expect(screen.getByRole('navigation').textContent).toContain('共 1 个');
|
||||
});
|
||||
|
||||
test('取消或切换渠道时中断全量读取,忽略随后到达的旧响应', async () => {
|
||||
vi.mocked(getAdminProjectSnapshotChannels).mockResolvedValue({
|
||||
defaultChannel: 'dev',
|
||||
channels: ['dev', 'release'],
|
||||
});
|
||||
let finishCancelled!: (value: AdminProjectSnapshotListResponse) => void;
|
||||
let finishOldChannel!: (value: AdminProjectSnapshotListResponse) => void;
|
||||
vi.mocked(listAdminProjectSnapshots)
|
||||
.mockResolvedValueOnce({ items: [entry], nextCursor: null })
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<AdminProjectSnapshotListResponse>((resolve) => {
|
||||
finishCancelled = resolve;
|
||||
}),
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<AdminProjectSnapshotListResponse>((resolve) => {
|
||||
finishOldChannel = resolve;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
items: [{ ...entry, channel: 'release', projectName: '正式渠道项目' }],
|
||||
nextCursor: null,
|
||||
});
|
||||
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
||||
await screen.findByText('三消工程');
|
||||
fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' }));
|
||||
const cancelledSignal = vi.mocked(listAdminProjectSnapshots).mock
|
||||
.calls[1]![2]!;
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消排序' }));
|
||||
expect(cancelledSignal.aborted).toBe(true);
|
||||
await act(async () => {
|
||||
finishCancelled({
|
||||
items: [{ ...entry, projectName: '已取消项目' }],
|
||||
nextCursor: null,
|
||||
});
|
||||
});
|
||||
expect(screen.getByText('三消工程')).toBeTruthy();
|
||||
expect(screen.queryByText('已取消项目')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' }));
|
||||
const oldChannelSignal = vi.mocked(listAdminProjectSnapshots).mock
|
||||
.calls[2]![2]!;
|
||||
fireEvent.change(screen.getByLabelText('项目工程渠道'), {
|
||||
target: { value: 'release' },
|
||||
});
|
||||
await screen.findByText('正式渠道项目');
|
||||
expect(oldChannelSignal.aborted).toBe(true);
|
||||
await act(async () => {
|
||||
finishOldChannel({
|
||||
items: [{ ...entry, projectName: '旧渠道项目' }],
|
||||
nextCursor: null,
|
||||
});
|
||||
});
|
||||
expect(screen.getByText('正式渠道项目')).toBeTruthy();
|
||||
expect(screen.queryByText('旧渠道项目')).toBeNull();
|
||||
expect(screen.getByRole('button', { name: '按同步时间排序' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('按项目展示完整性并限制未完成工程下载', async () => {
|
||||
vi.mocked(listAdminProjectSnapshots).mockResolvedValue({
|
||||
items: [
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
AdminPagination,
|
||||
AdminStatusPill,
|
||||
} from '@genarrative/shared/components';
|
||||
import { Download, RefreshCcw, X } from 'lucide-react';
|
||||
import { ArrowDownWideNarrow, Download, RefreshCcw, X } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
@@ -50,6 +50,9 @@ const snapshotStatuses: Record<
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
const PAGE_SIZE_OPTIONS = [20, 50, 100];
|
||||
const MAX_SORT_PAGES = 200;
|
||||
const MAX_SORT_PROJECTS = 10_000;
|
||||
const SORT_TIMEOUT_MS = 60_000;
|
||||
|
||||
export function AdminProjectSnapshotsPage({
|
||||
token,
|
||||
@@ -64,6 +67,9 @@ export function AdminProjectSnapshotsPage({
|
||||
const [channelOptions, setChannelOptions] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const [isSorted, setIsSorted] = useState(false);
|
||||
const [isSorting, setIsSorting] = useState(false);
|
||||
const [sortProgress, setSortProgress] = useState(0);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [downloadingKey, setDownloadingKey] = useState<string | null>(null);
|
||||
const listController = useRef<AbortController | null>(null);
|
||||
@@ -71,9 +77,23 @@ export function AdminProjectSnapshotsPage({
|
||||
// 远端按游标分页且不给总数:第 N 页的起始游标只能由前 N-1 页依次返回,
|
||||
// 因此按页记录已取得的游标,翻页只在这些游标之间移动。
|
||||
const pageCursors = useRef<(string | null)[]>([null]);
|
||||
// 全量读取完成后才发布;分页只切片本次读到的完整集合。
|
||||
const sortedItems = useRef<AdminProjectSnapshotEntry[] | null>(null);
|
||||
|
||||
const loadPage = useCallback(
|
||||
async (cursor: string | null, limit: number, page: number) => {
|
||||
async (
|
||||
cursor: string | null,
|
||||
limit: number,
|
||||
page: number,
|
||||
restoreDefault = false,
|
||||
) => {
|
||||
if (!restoreDefault && sortedItems.current !== null) {
|
||||
setItems(sortedItems.current.slice((page - 1) * limit, page * limit));
|
||||
setPageIndex(page);
|
||||
setHasLoaded(true);
|
||||
setErrorMessage('');
|
||||
return;
|
||||
}
|
||||
listController.current?.abort();
|
||||
const controller = new AbortController();
|
||||
listController.current = controller;
|
||||
@@ -86,6 +106,12 @@ export function AdminProjectSnapshotsPage({
|
||||
controller.signal,
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
// 远端第一页读取成功后再切换模式,失败时保留完整排序和分页。
|
||||
if (restoreDefault) {
|
||||
sortedItems.current = null;
|
||||
setIsSorted(false);
|
||||
pageCursors.current = [null];
|
||||
}
|
||||
setItems(response.items);
|
||||
setNextCursor(response.nextCursor);
|
||||
setPageIndex(page);
|
||||
@@ -126,8 +152,16 @@ export function AdminProjectSnapshotsPage({
|
||||
return () => controller.abort();
|
||||
}, [token, onUnauthorized]);
|
||||
|
||||
useEffect(() => {
|
||||
sortedItems.current = null;
|
||||
setIsSorted(false);
|
||||
}, [token, channel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (channel === null) return undefined;
|
||||
setIsSorting(false);
|
||||
setIsLoading(false);
|
||||
setSortProgress(0);
|
||||
pageCursors.current = [null];
|
||||
setItems([]);
|
||||
setNextCursor(null);
|
||||
@@ -144,6 +178,10 @@ export function AdminProjectSnapshotsPage({
|
||||
}, [loadPage, pageSize, channel]);
|
||||
|
||||
function goToNextPage() {
|
||||
if (sortedItems.current !== null) {
|
||||
void loadPage(null, pageSize, pageIndex + 1);
|
||||
return;
|
||||
}
|
||||
if (!nextCursor) return;
|
||||
pageCursors.current[pageIndex] = nextCursor;
|
||||
void loadPage(nextCursor, pageSize, pageIndex + 1);
|
||||
@@ -159,6 +197,10 @@ export function AdminProjectSnapshotsPage({
|
||||
}
|
||||
|
||||
function refreshCurrentPage() {
|
||||
if (sortedItems.current !== null) {
|
||||
void sortAllProjects();
|
||||
return;
|
||||
}
|
||||
void loadPage(
|
||||
pageCursors.current[pageIndex - 1] ?? null,
|
||||
pageSize,
|
||||
@@ -166,6 +208,90 @@ export function AdminProjectSnapshotsPage({
|
||||
);
|
||||
}
|
||||
|
||||
async function sortAllProjects() {
|
||||
if (channel === null) return;
|
||||
listController.current?.abort();
|
||||
const controller = new AbortController();
|
||||
listController.current = controller;
|
||||
setIsLoading(true);
|
||||
setIsSorting(true);
|
||||
setSortProgress(0);
|
||||
setErrorMessage('');
|
||||
let timedOut = false;
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
}, SORT_TIMEOUT_MS);
|
||||
try {
|
||||
const projects = new Map<string, AdminProjectSnapshotEntry>();
|
||||
const seenCursors = new Set<string>();
|
||||
let cursor: string | null = null;
|
||||
for (let page = 0; page < MAX_SORT_PAGES; page += 1) {
|
||||
const response = await listAdminProjectSnapshots(
|
||||
token,
|
||||
{ cursor, limit: 100, channel },
|
||||
controller.signal,
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
for (const project of response.items) {
|
||||
const key = snapshotKey(project);
|
||||
const previous = projects.get(key);
|
||||
if (!previous || project.syncedAtMs >= previous.syncedAtMs) {
|
||||
projects.set(key, project);
|
||||
}
|
||||
}
|
||||
if (projects.size > MAX_SORT_PROJECTS) {
|
||||
throw new Error('项目超过 10,000 个,无法完成全量排序');
|
||||
}
|
||||
setSortProgress(projects.size);
|
||||
if (!response.nextCursor) {
|
||||
const result = [...projects.values()].sort(
|
||||
(left, right) =>
|
||||
right.syncedAtMs - left.syncedAtMs ||
|
||||
compareIds(left.userId, right.userId) ||
|
||||
compareIds(left.projectId, right.projectId),
|
||||
);
|
||||
sortedItems.current = result;
|
||||
setIsSorted(true);
|
||||
setItems(result.slice(0, pageSize));
|
||||
setPageIndex(1);
|
||||
setNextCursor(null);
|
||||
setHasLoaded(true);
|
||||
return;
|
||||
}
|
||||
if (seenCursors.has(response.nextCursor)) {
|
||||
throw new Error('项目列表游标重复,无法完成全量排序,请重试');
|
||||
}
|
||||
seenCursors.add(response.nextCursor);
|
||||
cursor = response.nextCursor;
|
||||
}
|
||||
throw new Error('项目读取超过 200 页,无法完成全量排序');
|
||||
} catch (error: unknown) {
|
||||
if (!controller.signal.aborted) {
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
if (listController.current === controller) {
|
||||
listController.current = null;
|
||||
setIsLoading(false);
|
||||
setIsSorting(false);
|
||||
if (timedOut) setErrorMessage('全量项目读取超时,请重试');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cancelSorting() {
|
||||
listController.current?.abort();
|
||||
listController.current = null;
|
||||
setIsLoading(false);
|
||||
setIsSorting(false);
|
||||
}
|
||||
|
||||
function restoreDefaultOrder() {
|
||||
void loadPage(null, pageSize, 1, true);
|
||||
}
|
||||
|
||||
async function downloadProject(entry: AdminProjectSnapshotEntry) {
|
||||
if (downloadController.current || entry.status === 'partial') return;
|
||||
const controller = new AbortController();
|
||||
@@ -236,6 +362,32 @@ export function AdminProjectSnapshotsPage({
|
||||
))}
|
||||
</select>
|
||||
</AdminField>
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
disabled={isLoading || !hasLoaded}
|
||||
type="button"
|
||||
onClick={
|
||||
isSorted ? restoreDefaultOrder : () => void sortAllProjects()
|
||||
}
|
||||
>
|
||||
<ArrowDownWideNarrow size={17} aria-hidden="true" />
|
||||
<span>{isSorted ? '恢复默认顺序' : '按同步时间排序'}</span>
|
||||
</AdminButton>
|
||||
{isSorting ? (
|
||||
<>
|
||||
<span role="status">
|
||||
正在读取全部项目,已读取 {sortProgress} 个
|
||||
</span>
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={cancelSorting}
|
||||
>
|
||||
<X size={17} aria-hidden="true" />
|
||||
<span>取消排序</span>
|
||||
</AdminButton>
|
||||
</>
|
||||
) : null}
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
disabled={isLoading}
|
||||
@@ -269,6 +421,9 @@ export function AdminProjectSnapshotsPage({
|
||||
<>
|
||||
第 {pageIndex} 页
|
||||
{items.length ? `,本页 ${items.length} 个项目` : ''}
|
||||
{isSorted
|
||||
? `,共 ${sortedItems.current?.length ?? 0} 个,按同步时间从新到旧`
|
||||
: ''}
|
||||
</>
|
||||
}
|
||||
pageSize={{
|
||||
@@ -279,7 +434,12 @@ export function AdminProjectSnapshotsPage({
|
||||
}}
|
||||
busy={isLoading}
|
||||
previous={{ disabled: pageIndex <= 1, onClick: goToPreviousPage }}
|
||||
next={{ disabled: !nextCursor, onClick: goToNextPage }}
|
||||
next={{
|
||||
disabled: isSorted
|
||||
? pageIndex * pageSize >= (sortedItems.current?.length ?? 0)
|
||||
: !nextCursor,
|
||||
onClick: goToNextPage,
|
||||
}}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
@@ -375,6 +535,10 @@ function snapshotKey(entry: AdminProjectSnapshotEntry) {
|
||||
return `${entry.userId}/${entry.projectId}`;
|
||||
}
|
||||
|
||||
function compareIds(left: string, right: string) {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
const units = ['B', 'KiB', 'MiB', 'GiB'];
|
||||
const unit = Math.min(
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
# resources/plugins 由 build.rs 从 plugins/ 复制生成,属于构建产物。
|
||||
# 它在 dev 监听范围内,重新生成会让 Tauri dev 误判为源码改动而触发
|
||||
# “构建 -> 监听 -> 再构建”的自触发循环。
|
||||
resources/plugins/
|
||||
@@ -13,6 +13,10 @@
|
||||
"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",
|
||||
"bundled-resources:check": "node scripts/check-package-layout.mjs",
|
||||
"bundled-resources:sync": "node scripts/check-package-layout.mjs --write",
|
||||
"bundled-resources:prepare": "node scripts/prepare-bundled-resources.mjs",
|
||||
"bundled-resources:test": "node --test scripts/prepare-bundled-resources.test.mjs",
|
||||
"llm-status": "node scripts/run-cli-with-config.mjs --llm-status",
|
||||
"agent-task": "node scripts/run-cli-with-config.mjs --agent-task",
|
||||
"config": "node scripts/game-creator-config-wizard.mjs",
|
||||
@@ -24,7 +28,7 @@
|
||||
"agent-runtime:supervisor-swarm-tool-plan-handoff-runner-kill-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-tool-plan-handoff-runner-kill",
|
||||
"agent-runtime:steer-real-e2e": "node scripts/agent-runtime-steer-real-e2e.mjs",
|
||||
"agent-runtime:steer-runner-kill-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite steer-runner-kill",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit && npm run skill-pack:check && node scripts/check-config.mjs"
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit && npm run skill-pack:check && npm run bundled-resources:check && node scripts/check-config.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.285",
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
} from './agc-global-version.mjs';
|
||||
import {
|
||||
defaultEditorFeatures,
|
||||
readCargoTarget,
|
||||
resolveEditorFeatures,
|
||||
withDefaultCargoFeatures,
|
||||
} from './cargo-features.mjs';
|
||||
import {
|
||||
@@ -19,6 +21,10 @@ import {
|
||||
resolveReleaseChannel,
|
||||
} from './channel-identity.mjs';
|
||||
import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs';
|
||||
import {
|
||||
prepareBundledResources,
|
||||
supportedHostTarget,
|
||||
} from './prepare-bundled-resources.mjs';
|
||||
import { stageNodeRuntime } from './stage-node-runtime.mjs';
|
||||
|
||||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
@@ -40,26 +46,7 @@ function defaultTarget() {
|
||||
}
|
||||
|
||||
function explicitBuildTarget(args) {
|
||||
let target;
|
||||
const separator = args.indexOf('--');
|
||||
const options = separator < 0 ? args : args.slice(0, separator);
|
||||
for (let index = 0; index < options.length; index += 1) {
|
||||
const argument = options[index];
|
||||
let value;
|
||||
if (argument === '--target' || argument === '-t') {
|
||||
value = options[++index];
|
||||
} else if (argument.startsWith('--target=')) {
|
||||
value = argument.slice('--target='.length);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
if (!value?.trim() || value.startsWith('-')) {
|
||||
throw new Error('--target 缺少有效目标');
|
||||
}
|
||||
if (target !== undefined) throw new Error('不能重复指定 --target');
|
||||
target = value.trim();
|
||||
}
|
||||
return target;
|
||||
return readCargoTarget(args);
|
||||
}
|
||||
|
||||
function validateReleaseTarget(target) {
|
||||
@@ -469,10 +456,32 @@ function writeChannelConfigFile(channel, target, includeNodeRuntime = false) {
|
||||
return configPath;
|
||||
}
|
||||
|
||||
/// 随包资源必须在打包工具之前生成:构建脚本只做只读校验,不再生成。
|
||||
export function stageBundledResources(
|
||||
target,
|
||||
{ prepare = prepareBundledResources, features } = {},
|
||||
) {
|
||||
const summaries = prepare({
|
||||
target,
|
||||
features: new Set(features ?? defaultEditorFeatures(target)),
|
||||
profile: 'release',
|
||||
log: (line) => console.log(`[ai-game-creator-shell] ${line}`),
|
||||
});
|
||||
for (const summary of summaries) {
|
||||
console.log(`[ai-game-creator-shell] ${summary}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function runTauriBuild(
|
||||
args = [],
|
||||
context = resolveReleaseContext(args),
|
||||
{ spawn = spawnSync, stageRuntime = stageNodeRuntime } = {},
|
||||
{
|
||||
spawn = spawnSync,
|
||||
stageRuntime = stageNodeRuntime,
|
||||
stageBundled = stageBundledResources,
|
||||
platform = process.platform,
|
||||
arch = process.arch,
|
||||
} = {},
|
||||
) {
|
||||
if (
|
||||
explicitBuildTarget(args) &&
|
||||
@@ -480,14 +489,44 @@ export function runTauriBuild(
|
||||
) {
|
||||
throw new Error('构建参数与发布上下文目标不一致');
|
||||
}
|
||||
const tauriArguments = buildTauriBuildArguments(args, context.target);
|
||||
const { channel, target } = context;
|
||||
if (!args.includes('--no-bundle')) stageRuntime(target);
|
||||
const configPath = writeChannelConfigFile(
|
||||
channel,
|
||||
target,
|
||||
!args.includes('--no-bundle'),
|
||||
const tauriArguments = buildTauriBuildArguments(
|
||||
args,
|
||||
context.target,
|
||||
platform,
|
||||
);
|
||||
const bundling = !args.includes('--no-bundle');
|
||||
// 无显式 target 的 no-bundle 由 Cargo 构建宿主平台,不使用默认发布目标。
|
||||
const resourceTarget =
|
||||
explicitBuildTarget(args) ||
|
||||
(bundling ? context.target : supportedHostTarget(platform, arch));
|
||||
if (
|
||||
!bundling &&
|
||||
!resourceTarget &&
|
||||
defaultEditorFeatures(platform).length > 0
|
||||
) {
|
||||
throw new Error(
|
||||
`当前宿主 ${platform}/${arch} 不在随包资源声明覆盖内;请用 --target 指定受支持的构建目标`,
|
||||
);
|
||||
}
|
||||
const features = resolveEditorFeatures({
|
||||
argv: tauriArguments,
|
||||
target: resourceTarget ?? platform,
|
||||
env: {},
|
||||
});
|
||||
const { channel } = context;
|
||||
if (bundling) {
|
||||
stageRuntime(resourceTarget);
|
||||
}
|
||||
if (resourceTarget) {
|
||||
stageBundled(resourceTarget, { features });
|
||||
} else {
|
||||
console.log(
|
||||
'[ai-game-creator-shell] 当前宿主平台不参与客户端随包资源构建,跳过资源准备',
|
||||
);
|
||||
}
|
||||
// Linux smoke 不发布 updater,沿用渠道上下文;桌面宿主必须与实际构建目标一致。
|
||||
const target = resourceTarget ?? context.target;
|
||||
const configPath = writeChannelConfigFile(channel, target, bundling);
|
||||
console.log(
|
||||
`[ai-game-creator-shell] 渠道 ${channel} 端点配置:${configPath}`,
|
||||
);
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
AGC_PRODUCT_NAME,
|
||||
resolveChannelInstallIdentity,
|
||||
} from './channel-identity.mjs';
|
||||
import { supportedHostTarget } from './prepare-bundled-resources.mjs';
|
||||
|
||||
const windowsTarget = 'x86_64-pc-windows-msvc';
|
||||
const universalTarget = 'universal-apple-darwin';
|
||||
@@ -504,6 +505,8 @@ test('packaged renderer receives the same channel as the updater manifest', () =
|
||||
// 必须 stub:真实 staging 会用宿主平台(如 macOS 的 darwin/arm64)去对默认的
|
||||
// Windows 目标做一致性校验,在非 Windows 主机上直接失败——本用例只关心渠道注入。
|
||||
stageRuntime: () => {},
|
||||
// 同上:随包资源准备会读取真实上游包,本用例只关心渠道环境变量。
|
||||
stageBundled: () => {},
|
||||
spawn: (_binary, _args, options) => {
|
||||
spawnOptions = options;
|
||||
return { status: 0 };
|
||||
@@ -594,6 +597,8 @@ test('explicit macOS target drives version lookup, Tauri endpoint, artifact and
|
||||
seenContexts.push(context);
|
||||
runTauriBuild(args, context, {
|
||||
stageRuntime: () => {},
|
||||
// 必须 stub:随包资源准备会读取真实上游包与仓库插件工作区,本用例只关心参数。
|
||||
stageBundled: () => {},
|
||||
spawn: (_binary, command) => {
|
||||
const configIndex = command.lastIndexOf('--config');
|
||||
const config = JSON.parse(
|
||||
@@ -843,6 +848,7 @@ test('Windows remains the default and explicit Windows overrides macOS environme
|
||||
context,
|
||||
{
|
||||
stageRuntime: () => {},
|
||||
stageBundled: () => {},
|
||||
spawn: (_binary, command) => {
|
||||
assert.ok(
|
||||
command.includes(
|
||||
@@ -960,6 +966,10 @@ test('release stages Node before Tauri and injects its resource mapping only for
|
||||
assert.equal(target, windowsTarget);
|
||||
events.push('stage');
|
||||
},
|
||||
stageBundled(target) {
|
||||
assert.equal(target, windowsTarget);
|
||||
events.push('bundled');
|
||||
},
|
||||
spawn(_binary, args) {
|
||||
events.push('build');
|
||||
const config = JSON.parse(
|
||||
@@ -975,12 +985,24 @@ test('release stages Node before Tauri and injects its resource mapping only for
|
||||
return { status: 0 };
|
||||
},
|
||||
});
|
||||
assert.deepEqual(events, ['stage', 'build']);
|
||||
assert.deepEqual(events, ['stage', 'bundled', 'build']);
|
||||
events.length = 0;
|
||||
runTauriBuild(['--no-bundle', '--target', windowsTarget], context, {
|
||||
stageRuntime() {
|
||||
assert.fail('no-bundle must not stage resources');
|
||||
assert.fail('no-bundle must not stage node runtime');
|
||||
},
|
||||
stageBundled(target, options) {
|
||||
// app 构建本身就需要随包资源,因此 --no-bundle 也要 staging,
|
||||
// 且必须拿到与 cargo 相同的 feature 集(避免「cargo 开、staging 没开」)。
|
||||
assert.equal(target, windowsTarget);
|
||||
assert.ok(
|
||||
options?.features?.length > 0,
|
||||
'bundled staging 必须收到 feature 集',
|
||||
);
|
||||
events.push('bundled');
|
||||
},
|
||||
spawn(_binary, args) {
|
||||
events.push('build');
|
||||
const config = JSON.parse(
|
||||
readFileSync(args[args.lastIndexOf('--config') + 1], 'utf8'),
|
||||
);
|
||||
@@ -993,12 +1015,16 @@ test('release stages Node before Tauri and injects its resource mapping only for
|
||||
return { status: 0 };
|
||||
},
|
||||
});
|
||||
assert.deepEqual(events, ['bundled', 'build']);
|
||||
assert.throws(
|
||||
() =>
|
||||
runTauriBuild(['--target', windowsTarget], context, {
|
||||
stageRuntime() {
|
||||
throw new Error('missing runtime');
|
||||
},
|
||||
stageBundled() {
|
||||
assert.fail('invalid runtime must prevent bundled staging');
|
||||
},
|
||||
spawn() {
|
||||
assert.fail('invalid runtime must prevent build');
|
||||
},
|
||||
@@ -1007,6 +1033,297 @@ test('release stages Node before Tauri and injects its resource mapping only for
|
||||
);
|
||||
});
|
||||
|
||||
const windowsEditorFeatures = [
|
||||
'cocos-editor-execute',
|
||||
'unity-editor-execute',
|
||||
'godot-editor-execute',
|
||||
];
|
||||
|
||||
function uniqueSorted(values) {
|
||||
return [...new Set(values ?? [])].sort();
|
||||
}
|
||||
|
||||
function splitFeatureList(value) {
|
||||
return String(value ?? '')
|
||||
.split(',')
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/// 最终命令行里 Cargo 实际会收到的 feature 集:tauri 自身的 `--features` / `-f`
|
||||
/// 与 `--` 之后的 runner 参数(tauri 会把它们追加到 cargo 命令)都算数,
|
||||
/// 随包资源准备必须拿到同一集合。
|
||||
function cargoFeaturesFromCommand(command) {
|
||||
const argv = command.slice(command.indexOf('--') + 1);
|
||||
const features = [];
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = String(argv[index]);
|
||||
if (argument.startsWith('--features=')) {
|
||||
features.push(...splitFeatureList(argument.slice('--features='.length)));
|
||||
} else if (argument === '--features' || argument === '-f') {
|
||||
features.push(...splitFeatureList(argv[index + 1]));
|
||||
index += 1;
|
||||
} else if (/^-f.+/u.test(argument)) {
|
||||
features.push(...splitFeatureList(argument.slice(2)));
|
||||
}
|
||||
}
|
||||
return uniqueSorted(features);
|
||||
}
|
||||
|
||||
/**
|
||||
* PR520 的 CI 回归:`tauri build --no-bundle` 不带显式 target 时编译的是**宿主**平台,
|
||||
* 而发布上下文默认目标始终是 Windows。随包资源与 feature 集必须跟着宿主走,
|
||||
* 否则 Linux 宿主会拿着 Windows 目标去 staging(CI smoke 直接失败),
|
||||
* 还会出现「cargo 没开 feature、staging 开了」的错配。
|
||||
*/
|
||||
test('no-bundle 在无随包资源声明的宿主(Linux)既不 staging 也不注入 Windows feature', () => {
|
||||
const context = resolveReleaseContext(['--no-bundle'], {});
|
||||
assert.equal(context.target, windowsTarget);
|
||||
let command;
|
||||
withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => {
|
||||
runTauriBuild(['--no-bundle'], context, {
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
stageRuntime() {
|
||||
assert.fail('--no-bundle 不得准备 Node 运行时');
|
||||
},
|
||||
stageBundled(target) {
|
||||
assert.fail(`Linux 宿主没有随包资源声明,不得 staging ${target}`);
|
||||
},
|
||||
spawn(_binary, args) {
|
||||
command = args;
|
||||
return { status: 0 };
|
||||
},
|
||||
});
|
||||
});
|
||||
assert.ok(command, '宿主不受声明覆盖时仍必须完成本次构建');
|
||||
assert.ok(!command.includes('--target'), '宿主构建不得打显式 target');
|
||||
assert.deepEqual(
|
||||
cargoFeaturesFromCommand(command),
|
||||
[],
|
||||
'Linux 宿主不得注入 Windows 编辑器 feature',
|
||||
);
|
||||
});
|
||||
|
||||
test('no-bundle 在 macOS / Windows 宿主按真实宿主三元组准备随包资源', () => {
|
||||
const context = resolveReleaseContext(['--no-bundle'], {});
|
||||
for (const [platform, arch, hostTarget, expectedFeatures] of [
|
||||
['darwin', 'arm64', 'aarch64-apple-darwin', []],
|
||||
['darwin', 'x64', 'x86_64-apple-darwin', []],
|
||||
['win32', 'x64', windowsTarget, windowsEditorFeatures],
|
||||
]) {
|
||||
const label = `${platform}/${arch}`;
|
||||
let staged;
|
||||
let command;
|
||||
withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => {
|
||||
runTauriBuild(['--no-bundle'], context, {
|
||||
platform,
|
||||
arch,
|
||||
stageRuntime() {
|
||||
assert.fail('--no-bundle 不得准备 Node 运行时');
|
||||
},
|
||||
stageBundled(target, options) {
|
||||
staged = { target, features: options?.features };
|
||||
},
|
||||
spawn(_binary, args) {
|
||||
command = args;
|
||||
return { status: 0 };
|
||||
},
|
||||
});
|
||||
});
|
||||
assert.equal(
|
||||
staged?.target,
|
||||
hostTarget,
|
||||
`${label} 必须按宿主三元组 staging`,
|
||||
);
|
||||
assert.deepEqual(
|
||||
uniqueSorted(staged?.features),
|
||||
uniqueSorted(expectedFeatures),
|
||||
`${label} staging feature 与宿主不一致`,
|
||||
);
|
||||
assert.deepEqual(
|
||||
cargoFeaturesFromCommand(command),
|
||||
uniqueSorted(expectedFeatures),
|
||||
`${label} Cargo feature 与 staging 不一致`,
|
||||
);
|
||||
assert.ok(
|
||||
!command.includes('--target'),
|
||||
`${label} 宿主构建不得打显式 target`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('no-bundle 的显式 target 优先于宿主:跨平台 / 跨架构仍按参数准备', () => {
|
||||
for (const [platform, arch, target, expectedFeatures] of [
|
||||
['linux', 'x64', windowsTarget, windowsEditorFeatures],
|
||||
['darwin', 'arm64', 'x86_64-apple-darwin', []],
|
||||
]) {
|
||||
const args = ['--no-bundle', '--target', target];
|
||||
const context = resolveReleaseContext(args, {});
|
||||
const label = `${platform}/${arch} → ${target}`;
|
||||
let staged;
|
||||
let command;
|
||||
withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => {
|
||||
runTauriBuild(args, context, {
|
||||
platform,
|
||||
arch,
|
||||
stageRuntime() {
|
||||
assert.fail('--no-bundle 不得准备 Node 运行时');
|
||||
},
|
||||
stageBundled(stageTarget, options) {
|
||||
staged = { target: stageTarget, features: options?.features };
|
||||
},
|
||||
spawn(_binary, argv) {
|
||||
command = argv;
|
||||
return { status: 0 };
|
||||
},
|
||||
});
|
||||
});
|
||||
assert.equal(staged?.target, target, `${label} 必须按显式 target staging`);
|
||||
assert.ok(
|
||||
command.includes(target),
|
||||
`${label} 必须把显式 target 传给 Tauri`,
|
||||
);
|
||||
assert.deepEqual(
|
||||
uniqueSorted(staged?.features),
|
||||
uniqueSorted(expectedFeatures),
|
||||
`${label} staging feature 与预期不一致`,
|
||||
);
|
||||
assert.deepEqual(
|
||||
cargoFeaturesFromCommand(command),
|
||||
uniqueSorted(expectedFeatures),
|
||||
`${label} Cargo feature 与 staging 不一致`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('打包构建(非 no-bundle)的 staging 目标跟随发布上下文而不是宿主', () => {
|
||||
const crossArch = 'x86_64-apple-darwin';
|
||||
const context = resolveReleaseContext([], { AGC_BUILD_TARGET: crossArch });
|
||||
assert.equal(context.target, crossArch);
|
||||
let runtimeTarget;
|
||||
let staged;
|
||||
let command;
|
||||
withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => {
|
||||
runTauriBuild([], context, {
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
stageRuntime(target) {
|
||||
runtimeTarget = target;
|
||||
},
|
||||
stageBundled(target, options) {
|
||||
staged = { target, features: options?.features };
|
||||
},
|
||||
spawn(_binary, args) {
|
||||
command = args;
|
||||
return { status: 0 };
|
||||
},
|
||||
});
|
||||
});
|
||||
assert.equal(runtimeTarget, crossArch);
|
||||
assert.equal(
|
||||
staged?.target,
|
||||
crossArch,
|
||||
'打包构建必须按发布上下文目标 staging',
|
||||
);
|
||||
assert.deepEqual(uniqueSorted(staged?.features), []);
|
||||
assert.ok(
|
||||
command.includes(crossArch),
|
||||
'打包构建必须把发布上下文目标传给 Tauri',
|
||||
);
|
||||
assert.deepEqual(cargoFeaturesFromCommand(command), []);
|
||||
});
|
||||
|
||||
test('staging feature 与最终 Cargo feature 一致:-f / --features / -- 之后的 runner 参数', () => {
|
||||
const context = resolveReleaseContext(['--no-bundle'], {});
|
||||
const cases = [
|
||||
{
|
||||
args: ['--no-bundle', '--features', 'custom-editor-feature'],
|
||||
expected: ['custom-editor-feature'],
|
||||
},
|
||||
{
|
||||
args: ['--no-bundle', '--features=custom-a,custom-b'],
|
||||
expected: ['custom-a', 'custom-b'],
|
||||
},
|
||||
{
|
||||
args: ['--no-bundle', '-f', 'custom-editor-feature'],
|
||||
expected: ['custom-editor-feature'],
|
||||
},
|
||||
{
|
||||
args: ['--no-bundle', '-fcustom-editor-feature'],
|
||||
expected: ['custom-editor-feature'],
|
||||
},
|
||||
// `--` 之后的参数是 runner 参数:tauri 会把它们追加到 cargo 命令行
|
||||
// (`tauri build --help`:Command line arguments passed to the runner),
|
||||
// 因此 Cargo 最终拿到的是「注入的默认值 ∪ runner 参数」,staging 必须一致。
|
||||
{
|
||||
args: ['--no-bundle', '--', '--features=app-runner'],
|
||||
expected: [...windowsEditorFeatures, 'app-runner'],
|
||||
},
|
||||
// 环境变量泄漏(Jenkins Job 参数)不得让 staging 与 Cargo 分叉。
|
||||
{
|
||||
args: ['--no-bundle'],
|
||||
env: { AGC_DEV_CARGO_FEATURES: 'cocos-editor-execute' },
|
||||
},
|
||||
];
|
||||
for (const entry of cases) {
|
||||
const { args, expected } = entry;
|
||||
const label = args.join(' ');
|
||||
let staged;
|
||||
let command;
|
||||
withEnv({ AGC_DEV_CARGO_FEATURES: undefined, ...entry.env }, () => {
|
||||
runTauriBuild(args, context, {
|
||||
platform: 'win32',
|
||||
arch: 'x64',
|
||||
stageRuntime() {
|
||||
assert.fail(`${label} 不得准备 Node 运行时`);
|
||||
},
|
||||
stageBundled(target, options) {
|
||||
staged = { target, features: options?.features };
|
||||
},
|
||||
spawn(_binary, argv) {
|
||||
command = argv;
|
||||
return { status: 0 };
|
||||
},
|
||||
});
|
||||
});
|
||||
assert.equal(staged?.target, windowsTarget, label);
|
||||
const cargoFeatures = cargoFeaturesFromCommand(command);
|
||||
assert.deepEqual(
|
||||
uniqueSorted(staged?.features),
|
||||
cargoFeatures,
|
||||
`${label}:staging feature 必须等于 Cargo 收到的 feature`,
|
||||
);
|
||||
if (expected) {
|
||||
assert.deepEqual(
|
||||
cargoFeatures,
|
||||
uniqueSorted(expected),
|
||||
`${label}:Cargo feature 与预期不一致`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('未注入 platform / arch 时按真实 process 平台取宿主随包目标', () => {
|
||||
const hostTarget = supportedHostTarget(process.platform, process.arch);
|
||||
const context = resolveReleaseContext(['--no-bundle'], {});
|
||||
const events = [];
|
||||
withEnv({ AGC_DEV_CARGO_FEATURES: undefined }, () => {
|
||||
runTauriBuild(['--no-bundle'], context, {
|
||||
stageRuntime() {
|
||||
assert.fail('--no-bundle 不得准备 Node 运行时');
|
||||
},
|
||||
stageBundled(target) {
|
||||
events.push(target);
|
||||
},
|
||||
spawn() {
|
||||
return { status: 0 };
|
||||
},
|
||||
});
|
||||
});
|
||||
assert.deepEqual(events, hostTarget ? [hostTarget] : []);
|
||||
});
|
||||
|
||||
test('channel manifest carries version, platform keys and signature', () => {
|
||||
withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => {
|
||||
withEnv({ AGC_UPDATE_RELEASE_NOTES: '修复与改进' }, () => {
|
||||
@@ -1111,6 +1428,8 @@ for (const channel of ['release', 'beta-2']) {
|
||||
);
|
||||
runTauriBuild([`--target=${target}`], context, {
|
||||
stageRuntime: () => {},
|
||||
// 必须 stub:随包资源准备会读取真实上游包与仓库插件工作区,本用例只关心参数。
|
||||
stageBundled: () => {},
|
||||
spawn: (_binary, command) => {
|
||||
const config = JSON.parse(
|
||||
readFileSync(
|
||||
|
||||
@@ -1,24 +1,135 @@
|
||||
/** 默认桌面能力;显式 feature 参数优先,不把应用参数当 Cargo 参数。 */
|
||||
export function withDefaultCargoFeatures(argv, features) {
|
||||
const separator = argv.indexOf('--');
|
||||
const cargoArgs = separator < 0 ? argv : argv.slice(0, separator);
|
||||
if (
|
||||
!features.length ||
|
||||
cargoArgs.some(
|
||||
(value) =>
|
||||
value === '--features' ||
|
||||
value === '-f' ||
|
||||
value.startsWith('--features=') ||
|
||||
/^-f.+/u.test(value),
|
||||
)
|
||||
) {
|
||||
// 只看第一个 `--` 之前的 Tauri 选项:runner 参数区里的 `--features` 会与这里注入的
|
||||
// 默认值在 Cargo 侧合并,不能当作「用户已经指定过了」而跳过注入。
|
||||
if (!features.length || tauriOptions(argv).some(hasExplicitFeatures)) {
|
||||
return argv;
|
||||
}
|
||||
return [`--features=${features.join(',')}`, ...argv];
|
||||
}
|
||||
|
||||
/// 第一个 `--` 之前是 Tauri 自己的选项,之后是 runner(Cargo)参数。
|
||||
function tauriOptions(argv) {
|
||||
const separator = argv.indexOf('--');
|
||||
return separator < 0 ? argv : argv.slice(0, separator);
|
||||
}
|
||||
|
||||
/**
|
||||
* 交给 Tauri 与 Cargo 的参数区间:第一个 `--` 之前是 Tauri 选项,之后到第二个 `--` 是
|
||||
* runner(Cargo)参数;第二个 `--` 之后由 Tauri 转交应用,不属于构建参数。
|
||||
* dev 入口(`buildTauriArguments` 会把启动器的单个 `--` 补成 runner + 应用两段)与发布
|
||||
* 入口都用这一份区间解析,两边的目标与 feature 必须来自同一个口径。
|
||||
*/
|
||||
function cargoArguments(argv) {
|
||||
const separator = argv.indexOf('--');
|
||||
if (separator < 0) return argv;
|
||||
const applicationStart = argv.indexOf('--', separator + 1);
|
||||
return applicationStart < 0 ? argv : argv.slice(0, applicationStart);
|
||||
}
|
||||
|
||||
/// 四种拼写(`--features x` / `--features=x` / `-f x` / `-fx`)都算显式指定。
|
||||
function hasExplicitFeatures(value) {
|
||||
return (
|
||||
value === '--features' ||
|
||||
value === '-f' ||
|
||||
value.startsWith('--features=') ||
|
||||
/^-f.+/u.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function parseFeatureNames(raw) {
|
||||
return String(raw)
|
||||
.split(/[\s,]+/u)
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function defaultEditorFeatures(target) {
|
||||
return target === 'win32' || target.includes('windows')
|
||||
? ['cocos-editor-execute', 'unity-editor-execute', 'godot-editor-execute']
|
||||
: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 显式 Cargo 目标解析:`--target <triple>` / `--target=<triple>` / `-t <triple>`。
|
||||
* 应用参数区(第二个 `--` 之后)不参与解析。未显式指定返回 undefined;取值缺失、
|
||||
* 取到另一个选项或重复指定都失败关闭。
|
||||
* dev 入口与发布入口共用这一份解析:cargo 的构建目标与随包资源 staging 目标
|
||||
* 必须来自同一个显式来源,不能一方读参数、另一方回退宿主默认值。
|
||||
*/
|
||||
export function readCargoTarget(argv = []) {
|
||||
const options = cargoArguments(argv);
|
||||
let target;
|
||||
for (let index = 0; index < options.length; index += 1) {
|
||||
const argument = String(options[index]);
|
||||
let value;
|
||||
if (argument === '--target' || argument === '-t') {
|
||||
value = options[index + 1];
|
||||
index += 1;
|
||||
} else if (argument.startsWith('--target=')) {
|
||||
value = argument.slice('--target='.length);
|
||||
} else if (argument.startsWith('-t') && argument.length > 2) {
|
||||
value = argument.slice(2).replace(/^=/u, '');
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
if (!value?.trim() || String(value).startsWith('-')) {
|
||||
throw new Error('--target 缺少有效目标');
|
||||
}
|
||||
if (target !== undefined) throw new Error('不能重复指定 --target');
|
||||
target = String(value).trim();
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单一声明式解析:本次构建实际生效的编辑器 feature 集。
|
||||
* 解析顺序:命令行 `--features`(Cargo 真正收到的参数,不能被环境覆盖)
|
||||
* > 环境变量 `AGC_DEV_CARGO_FEATURES`(存在即以它为准,空串即关闭默认 feature)
|
||||
* > 目标平台默认值。
|
||||
* 传入的必须是「最终交给 Tauri 的参数」:dev 入口与发布入口的 cargo 参数与随包资源
|
||||
* 准备步骤都消费这里的返回值,否则会出现「cargo 开了 feature、staging 没开(或反之)」。
|
||||
*/
|
||||
export function resolveEditorFeatures({
|
||||
argv = [],
|
||||
target,
|
||||
env = process.env,
|
||||
} = {}) {
|
||||
const fromArgv = featuresFromArgv(argv);
|
||||
if (fromArgv) {
|
||||
return fromArgv;
|
||||
}
|
||||
// 变量存在本身即声明「本次 feature 集由环境决定」:空串是有意的关闭,不是未设置。
|
||||
if (env.AGC_DEV_CARGO_FEATURES !== undefined) {
|
||||
return parseFeatureNames(env.AGC_DEV_CARGO_FEATURES);
|
||||
}
|
||||
return defaultEditorFeatures(target);
|
||||
}
|
||||
|
||||
/// 汇总 cargo 参数区里出现在任何位置的 `--features` / `-f`(Cargo 会把多个 flag 合并);
|
||||
/// 没有任何 feature flag 时返回 null。显式但为空(如 `--features=`)返回空数组。
|
||||
export function featuresFromArgv(argv = []) {
|
||||
const options = cargoArguments(argv);
|
||||
const features = [];
|
||||
let found = false;
|
||||
for (let index = 0; index < options.length; index += 1) {
|
||||
const value = String(options[index]);
|
||||
if (value.startsWith('--features=')) {
|
||||
found = true;
|
||||
features.push(...parseFeatureNames(value.slice('--features='.length)));
|
||||
} else if (value === '--features' || value === '-f') {
|
||||
found = true;
|
||||
while (
|
||||
index + 1 < options.length &&
|
||||
!String(options[index + 1]).startsWith('-')
|
||||
) {
|
||||
index += 1;
|
||||
features.push(...parseFeatureNames(options[index]));
|
||||
}
|
||||
} else if (value.startsWith('-f') && value.length > 2) {
|
||||
found = true;
|
||||
features.push(...parseFeatureNames(value.slice(2).replace(/^=/u, '')));
|
||||
}
|
||||
}
|
||||
return found ? features : null;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import { buildTauriBuildArguments } from './build-release.mjs';
|
||||
import { withDefaultCargoFeatures } from './cargo-features.mjs';
|
||||
import {
|
||||
featuresFromArgv,
|
||||
withDefaultCargoFeatures,
|
||||
} from './cargo-features.mjs';
|
||||
|
||||
test('Windows release includes the same editor feature as development', () => {
|
||||
assert.deepEqual(
|
||||
@@ -47,3 +50,16 @@ test('explicit Cargo features override defaults in every supported spelling', ()
|
||||
['--features=cocos-editor-execute', '--', '--features=app'],
|
||||
);
|
||||
});
|
||||
|
||||
test('features 变参会完整消费空格与逗号分隔的值', () => {
|
||||
assert.deepEqual(
|
||||
featuresFromArgv([
|
||||
'-f',
|
||||
'cocos-editor-execute,unity-editor-execute',
|
||||
'godot-editor-execute',
|
||||
'--target',
|
||||
'x86_64-pc-windows-msvc',
|
||||
]),
|
||||
['cocos-editor-execute', 'unity-editor-execute', 'godot-editor-execute'],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1358,7 +1358,6 @@ for (const channel of ['release', 'beta-2']) {
|
||||
|
||||
const expectedBundledDesignAgentResources = {
|
||||
'design-agent': 'design-agent',
|
||||
'resources/claude-agent': 'claude-agent',
|
||||
...Object.fromEntries(
|
||||
[
|
||||
'codex-patch-parser',
|
||||
@@ -1387,6 +1386,7 @@ const expectedBundledWindowsResources = {
|
||||
'resources/codex/win-x64/NOTICE.md': 'coding-agent/win-x64/NOTICE.md',
|
||||
'resources/codex/win-x64/manifest.json': 'coding-agent/win-x64/manifest.json',
|
||||
'resources/plugins': 'plugins',
|
||||
'resources/claude-agent': 'claude-agent',
|
||||
};
|
||||
assert.deepEqual(
|
||||
tauriConfig.bundle?.resources,
|
||||
@@ -1399,6 +1399,13 @@ for (const key of Object.keys(tauriConfig.bundle?.resources ?? {})) {
|
||||
'AI game creator shell base Tauri config must not require Windows-only Codex resources',
|
||||
);
|
||||
}
|
||||
// 基线配置同时服务 Linux(只有 CI 会编译壳 crate):随包资源由准备步骤按目标生成,
|
||||
// 基线声明它们会让没有 node_modules 的平台在构建期就因资源缺失失败。
|
||||
if (String(key).startsWith('resources/')) {
|
||||
throw new Error(
|
||||
'AI game creator shell base Tauri config must not require platform-only bundled resources',
|
||||
);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(
|
||||
windowsTauriConfig.bundle?.resources,
|
||||
@@ -1438,6 +1445,7 @@ assert.deepEqual(
|
||||
]),
|
||||
),
|
||||
['resources/plugins', 'plugins'],
|
||||
['resources/claude-agent', 'claude-agent'],
|
||||
]),
|
||||
'macOS must bundle the complete native Codex layout and plugin workspace',
|
||||
);
|
||||
|
||||
@@ -5,6 +5,8 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { listForbiddenBundledResourceFiles } from './macos-bundle-policy.mjs';
|
||||
|
||||
// 只操作临时复制品;不启动 GUI、不读取开发机凭据、不访问 Provider。
|
||||
assert.equal(process.platform, 'darwin', '此验证必须在 macOS 执行');
|
||||
const source = path.resolve(process.argv[2] || '');
|
||||
@@ -287,22 +289,32 @@ try {
|
||||
]) {
|
||||
assert.ok(fs.existsSync(path.join(plugin, file)), file);
|
||||
}
|
||||
// 随包 Node 的 npm 是包里唯一允许出现的 node_modules:除了 npm 目录自身与它的子项,
|
||||
// 还要放行它的上级目录 `game-runtime/node/node_modules`(recursive readdir 会列出目录项,
|
||||
// 少了这一条会让整个门禁对合法包失败——#439 引入后一直没被跑到,直到 2026-09-21 才暴露)。
|
||||
const allowedNodeModules = (file) =>
|
||||
file === 'game-runtime/node/node_modules' ||
|
||||
file === 'game-runtime/node/node_modules/npm' ||
|
||||
file.startsWith('game-runtime/node/node_modules/npm/');
|
||||
const claudeAgentRoot = path.join(resources, 'claude-agent');
|
||||
const claudeAgentSdk = path.join(
|
||||
claudeAgentRoot,
|
||||
'node_modules/@anthropic-ai/claude-agent-sdk',
|
||||
);
|
||||
const claudeAgentBinaryPackage = `claude-agent-sdk-darwin-${architecture === 'arm64' ? 'arm64' : 'x64'}`;
|
||||
const claudeAgentBinary = path.join(
|
||||
claudeAgentRoot,
|
||||
'node_modules/@anthropic-ai',
|
||||
claudeAgentBinaryPackage,
|
||||
'claude',
|
||||
);
|
||||
for (const required of [
|
||||
path.join(claudeAgentRoot, 'index.mjs'),
|
||||
path.join(claudeAgentSdk, 'sdk.mjs'),
|
||||
claudeAgentBinary,
|
||||
]) {
|
||||
assert.ok(fs.existsSync(required), required);
|
||||
}
|
||||
// 随包只允许 Node runtime 的 npm 与 Claude Agent SDK sidecar 这两棵
|
||||
// node_modules 子树;任何其它 node_modules 都是构建残留或不可控依赖。
|
||||
const packageFiles = fs.readdirSync(resources, { recursive: true });
|
||||
assert.ok(
|
||||
!packageFiles.some(
|
||||
(file) =>
|
||||
/(^|\/)(\.env[^/]*|auth\.json|target|\.git)(\/|$)|\.(exe|dll)$/.test(
|
||||
file,
|
||||
) ||
|
||||
(/(^|\/)node_modules(\/|$)/.test(file) && !allowedNodeModules(file)),
|
||||
),
|
||||
assert.deepEqual(
|
||||
listForbiddenBundledResourceFiles(packageFiles),
|
||||
[],
|
||||
'安装包包含禁止资源',
|
||||
);
|
||||
assert.equal(run(executable, ['--version']).stdout.trim(), manifest.version);
|
||||
assert.equal(
|
||||
|
||||
+917
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* macOS 安装包内容策略。
|
||||
*
|
||||
* `check-macos-bundle.mjs` 会扫描随包 resources。Node runtime 与 Claude Agent SDK
|
||||
* sidecar 都需要 `node_modules` 形式的目录,但其它构建残留或第三方依赖不能被
|
||||
* 静默带入安装包,因此这里只放行两棵明确的生产子树。
|
||||
*/
|
||||
|
||||
const NODE_RUNTIME_NODE_MODULES = 'game-runtime/node/node_modules';
|
||||
const CLAUDE_AGENT_NODE_MODULES = 'claude-agent/node_modules';
|
||||
const CLAUDE_AGENT_SCOPE = `${CLAUDE_AGENT_NODE_MODULES}/@anthropic-ai`;
|
||||
|
||||
const CLAUDE_AGENT_PACKAGE_PATTERN =
|
||||
/^(?:claude-agent-sdk|claude-agent-sdk-darwin-(?:arm64|x64))(?:\/.*)?$/u;
|
||||
|
||||
function isAllowedClaudeAgentNodeModulesPath(file) {
|
||||
if (file === CLAUDE_AGENT_NODE_MODULES || file === CLAUDE_AGENT_SCOPE) {
|
||||
return true;
|
||||
}
|
||||
if (!file.startsWith(`${CLAUDE_AGENT_SCOPE}/`)) {
|
||||
return false;
|
||||
}
|
||||
return CLAUDE_AGENT_PACKAGE_PATTERN.test(
|
||||
file.slice(`${CLAUDE_AGENT_SCOPE}/`.length),
|
||||
);
|
||||
}
|
||||
|
||||
export function isAllowedBundledNodeModulesPath(file) {
|
||||
return (
|
||||
file === NODE_RUNTIME_NODE_MODULES ||
|
||||
file === `${NODE_RUNTIME_NODE_MODULES}/npm` ||
|
||||
file.startsWith(`${NODE_RUNTIME_NODE_MODULES}/npm/`) ||
|
||||
isAllowedClaudeAgentNodeModulesPath(file)
|
||||
);
|
||||
}
|
||||
|
||||
export function listForbiddenBundledResourceFiles(files) {
|
||||
return files.filter(
|
||||
(file) =>
|
||||
/(^|\/)(\.env[^/]*|auth\.json|target|\.git)(\/|$)|\.(exe|dll)$/u.test(
|
||||
file,
|
||||
) ||
|
||||
(/(^|\/)node_modules(\/|$)/u.test(file) &&
|
||||
!isAllowedBundledNodeModulesPath(file)),
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { listForbiddenBundledResourceFiles } from './macos-bundle-policy.mjs';
|
||||
import {
|
||||
assertMacosAppMatchesChannelIdentity,
|
||||
assertManifestArtifactMatchesExpected,
|
||||
@@ -8,6 +9,42 @@ import {
|
||||
readMacosAppInfoIdentity,
|
||||
} from './macos-release-identity.mjs';
|
||||
|
||||
test('allows only the production node_modules subtrees in the macOS bundle', () => {
|
||||
assert.deepEqual(
|
||||
listForbiddenBundledResourceFiles([
|
||||
'game-runtime/node/node_modules',
|
||||
'game-runtime/node/node_modules/npm/bin/npm-cli.js',
|
||||
'claude-agent/node_modules',
|
||||
'claude-agent/node_modules/@anthropic-ai',
|
||||
'claude-agent/node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs',
|
||||
'claude-agent/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64/claude',
|
||||
]),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
listForbiddenBundledResourceFiles([
|
||||
'claude-agent/node_modules/unexpected/index.mjs',
|
||||
'claude-agent/node_modules/@anthropic-ai/unexpected/index.mjs',
|
||||
'plugins/agc-cocos-editor/node_modules/leftover/index.mjs',
|
||||
'game-runtime/node/.env.local',
|
||||
'claude-agent/auth.json',
|
||||
'claude-agent/target/debug/claude',
|
||||
'claude-agent/tool.exe',
|
||||
'claude-agent/tool.dll',
|
||||
]),
|
||||
[
|
||||
'claude-agent/node_modules/unexpected/index.mjs',
|
||||
'claude-agent/node_modules/@anthropic-ai/unexpected/index.mjs',
|
||||
'plugins/agc-cocos-editor/node_modules/leftover/index.mjs',
|
||||
'game-runtime/node/.env.local',
|
||||
'claude-agent/auth.json',
|
||||
'claude-agent/target/debug/claude',
|
||||
'claude-agent/tool.exe',
|
||||
'claude-agent/tool.dll',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
const DEV_IDENTITY = {
|
||||
productName: '陶泥儿开发版',
|
||||
identifier: 'world.genarrative.ai-game-creator',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,10 @@
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { buildLocalRustProcessEnv } from '../../../scripts/dev.mjs';
|
||||
import {
|
||||
defaultEditorFeatures,
|
||||
readCargoTarget,
|
||||
resolveEditorFeatures,
|
||||
withDefaultCargoFeatures,
|
||||
} from './cargo-features.mjs';
|
||||
import {
|
||||
@@ -10,6 +12,10 @@ import {
|
||||
resolveAgcDevEndpoint,
|
||||
withAgcDevEndpointEnv,
|
||||
} from './dev-port.mjs';
|
||||
import {
|
||||
prepareBundledResources,
|
||||
supportedHostTarget,
|
||||
} from './prepare-bundled-resources.mjs';
|
||||
import {
|
||||
isAiGameCreatorServer,
|
||||
preflightExistingVite,
|
||||
@@ -51,22 +57,51 @@ function buildTauriArguments(argv, devUrl = readAgcDevEndpoint().url) {
|
||||
}
|
||||
|
||||
// 开发和发行构建使用同一平台编辑器 feature 集合。
|
||||
// 可用 AGC_DEV_CARGO_FEATURES(逗号分隔)覆盖,传空串即关闭。
|
||||
function readDevCargoFeatures(env = process.env) {
|
||||
const override = env.AGC_DEV_CARGO_FEATURES;
|
||||
if (override !== undefined) {
|
||||
return override
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return defaultEditorFeatures(process.platform);
|
||||
// 可用 AGC_DEV_CARGO_FEATURES(逗号分隔)覆盖,传空串即关闭;
|
||||
// 命令行显式 `--features` / `-f` 优先于环境变量(cargo 实际收到的是命令行参数)。
|
||||
// 入参是「最终交给 Tauri 的参数」:默认平台由其中的显式 `--target` 决定,未指定才是宿主平台。
|
||||
function readDevCargoFeatures(argv = []) {
|
||||
return resolveEditorFeatures({
|
||||
argv,
|
||||
target: readCargoTarget(argv) ?? process.platform,
|
||||
});
|
||||
}
|
||||
|
||||
function withDevCargoFeatures(argv, features = readDevCargoFeatures()) {
|
||||
function withDevCargoFeatures(argv, features = readDevCargoFeatures(argv)) {
|
||||
return withDefaultCargoFeatures(argv, features);
|
||||
}
|
||||
|
||||
/// 随包资源必须在 Tauri 之前生成:构建脚本只做只读校验,不再生成资源。
|
||||
/// 命中缓存的重复调用不写任何文件,因此每次 dev 启动都会先跑一次。
|
||||
/// `argv` 是「最终交给 Tauri 的参数」;staging 目标与本次 cargo 构建目标同源:
|
||||
/// 显式 `--target` 优先,未指定时回落宿主目标;宿主平台不受声明覆盖(如 Linux)时保持跳过,
|
||||
/// 不为其新增随包资源准备。`features` 省略时按同一份参数解析,不允许「cargo 一套、staging 另一套」。
|
||||
function prepareBundledResourcesBeforeTauri(
|
||||
features,
|
||||
argv = [],
|
||||
{
|
||||
prepare = prepareBundledResources,
|
||||
log = console.log,
|
||||
hostTarget = supportedHostTarget(),
|
||||
} = {},
|
||||
) {
|
||||
const target = readCargoTarget(argv) ?? hostTarget;
|
||||
if (!target) {
|
||||
log(
|
||||
'[ai-game-creator-shell] 当前平台不受随包资源声明覆盖,跳过随包资源准备',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const summaries = prepare({
|
||||
target,
|
||||
features: new Set(features ?? readDevCargoFeatures(argv)),
|
||||
log: (line) => log(`[ai-game-creator-shell] ${line}`),
|
||||
});
|
||||
for (const summary of summaries) {
|
||||
log(`[ai-game-creator-shell] ${summary}`);
|
||||
}
|
||||
}
|
||||
|
||||
function spawnTauriCli(argv, { env = process.env } = {}) {
|
||||
return spawnChild(process.execPath, [tauriCliPath, ...argv], {
|
||||
cwd: appRoot,
|
||||
@@ -75,6 +110,17 @@ function spawnTauriCli(argv, { env = process.env } = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Tauri dev 的 Cargo 直接继承启动器环境,用户级 / 仓库级 Cargo 配置里的
|
||||
/// `rustc-wrapper`(本地常见为 sccache)会在这里生效。本地 sccache daemon 状态
|
||||
/// 一旦损坏,`cargo` 的首次 rustc 探测就会失败并阻断整个 AGC 启动;因此这里复用
|
||||
/// `npm run dev` 的本地 Rust 环境规则,由脚本而不是本机 Cargo 配置决定 wrapper。
|
||||
function buildTauriDevProcessEnv(endpoint, env = process.env) {
|
||||
return buildLocalRustProcessEnv({
|
||||
...withAgcDevEndpointEnv(endpoint, env),
|
||||
[AGC_DESIGN_DEBUG_ENV]: designDebugEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
async function runTauriDev(
|
||||
argv = process.argv.slice(2),
|
||||
{
|
||||
@@ -84,6 +130,7 @@ async function runTauriDev(
|
||||
spawnCli = spawnTauriCli,
|
||||
waitForCli = waitForChildTermination,
|
||||
terminateTree = terminateChildTree,
|
||||
prepareResources = prepareBundledResourcesBeforeTauri,
|
||||
} = {},
|
||||
) {
|
||||
const endpoint = await resolveDevEndpoint();
|
||||
@@ -119,6 +166,12 @@ async function runTauriDev(
|
||||
}
|
||||
|
||||
try {
|
||||
// 目标与 feature 都从「最终交给 Tauri 的参数」解析:启动器自己的 `--` 之后是应用参数,
|
||||
// 不能参与解析;注入默认 feature 也不改变这份解析结果。
|
||||
const devTauriArguments = buildTauriArguments(argv, endpoint.url);
|
||||
const devFeatures = readDevCargoFeatures(devTauriArguments);
|
||||
prepareResources(devFeatures, devTauriArguments);
|
||||
// Windows 下 Vite 会监听资源目录;必须在启动前完成目录替换,避免 rename 被占用。
|
||||
const preparation = prepareFrontend(endpoint, {
|
||||
signal: preparationAbort.signal,
|
||||
onChild(frontend) {
|
||||
@@ -131,14 +184,11 @@ async function runTauriDev(
|
||||
]);
|
||||
if (!prepared || shutdownSignal) return 1;
|
||||
const tauriArguments = buildTauriArguments(
|
||||
withDevCargoFeatures(argv),
|
||||
withDevCargoFeatures(argv, devFeatures),
|
||||
endpoint.url,
|
||||
);
|
||||
child = spawnCli(tauriArguments, {
|
||||
env: {
|
||||
...withAgcDevEndpointEnv(endpoint),
|
||||
[AGC_DESIGN_DEBUG_ENV]: designDebugEnabled,
|
||||
},
|
||||
env: buildTauriDevProcessEnv(endpoint),
|
||||
});
|
||||
const childResult = waitForCli(child);
|
||||
const outcome = await Promise.race([
|
||||
@@ -225,7 +275,9 @@ function isDirectModuleExecution() {
|
||||
|
||||
export {
|
||||
buildTauriArguments,
|
||||
buildTauriDevProcessEnv,
|
||||
isDirectModuleExecution,
|
||||
prepareBundledResourcesBeforeTauri,
|
||||
runTauriDev,
|
||||
spawnTauriCli,
|
||||
withDevCargoFeatures,
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
# resources/plugins 由 build.rs 从 plugins/ 复制生成,属于构建产物。
|
||||
# 它在 dev 监听范围内,重新生成会让 Tauri dev 误判为源码改动而触发
|
||||
# “构建 -> 监听 -> 再构建”的自触发循环。
|
||||
resources/plugins/
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,18 @@
|
||||
//! 构建与运行共用的平台布局;只允许分发锁定原生包里的明确组件。
|
||||
//!
|
||||
//! 布局、组件白名单与版本常量来自唯一声明 `build_support/package-layout.json`
|
||||
//! (Rust 侧经 `build_support/package-layout.generated.rs` 取得编译期常量,
|
||||
//! 由 `scripts/check-package-layout.mjs` 生成并在门禁中校验一致)。
|
||||
//! 本模块只读声明,不写任何随包资源。
|
||||
|
||||
pub const VERSION: &str = "0.155.1";
|
||||
pub const CLI_VERSION: &str = "codex-cli 0.155.1";
|
||||
pub const SCHEMA: &str = "genarrative-codex-sidecar.v2";
|
||||
// 共享声明模块:构建脚本、运行期与测试各自只用到其中一部分,未用到的入口不算缺陷。
|
||||
#[allow(dead_code)]
|
||||
#[path = "package_layout.rs"]
|
||||
pub(crate) mod package_layout;
|
||||
|
||||
pub const VERSION: &str = package_layout::CODEX_VERSION;
|
||||
pub const CLI_VERSION: &str = package_layout::CODEX_CLI_VERSION;
|
||||
pub const SCHEMA: &str = package_layout::CODEX_MANIFEST_SCHEMA;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Layout {
|
||||
@@ -12,46 +22,13 @@ pub struct Layout {
|
||||
pub files: &'static [&'static str],
|
||||
}
|
||||
|
||||
const WINDOWS_FILES: &[&str] = &[
|
||||
"bin/codex.exe",
|
||||
"bin/codex-code-mode-host.exe",
|
||||
"codex-path/rg.exe",
|
||||
"codex-resources/codex-command-runner.exe",
|
||||
"codex-resources/codex-windows-sandbox-setup.exe",
|
||||
"codex-package.json",
|
||||
];
|
||||
const MAC_FILES: &[&str] = &[
|
||||
"bin/codex",
|
||||
"bin/codex-code-mode-host",
|
||||
"codex-path/rg",
|
||||
"codex-resources/zsh/bin/zsh",
|
||||
"codex-package.json",
|
||||
];
|
||||
|
||||
pub fn for_target(target: &str) -> Option<Layout> {
|
||||
match target {
|
||||
"x86_64-pc-windows-msvc" => Some(Layout {
|
||||
platform: "win32-x64",
|
||||
directory: "win-x64",
|
||||
executable: "bin/codex.exe",
|
||||
files: WINDOWS_FILES,
|
||||
}),
|
||||
"aarch64-apple-darwin" | "x86_64-apple-darwin" => Some(Layout {
|
||||
platform: if target.starts_with("aarch64") {
|
||||
"darwin-arm64"
|
||||
} else {
|
||||
"darwin-x64"
|
||||
},
|
||||
directory: if target.starts_with("aarch64") {
|
||||
"mac-native/darwin-arm64"
|
||||
} else {
|
||||
"mac-native/darwin-x64"
|
||||
},
|
||||
executable: "bin/codex",
|
||||
files: MAC_FILES,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
package_layout::codex_target(target).map(|declared| Layout {
|
||||
platform: declared.platform,
|
||||
directory: declared.directory,
|
||||
executable: declared.executable,
|
||||
files: declared.files,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -80,4 +57,11 @@ mod tests {
|
||||
assert!(for_target("aarch64-pc-windows-msvc").is_none());
|
||||
assert!(for_target("x86_64-unknown-linux-gnu").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constants_come_from_the_shared_declaration() {
|
||||
assert_eq!(VERSION, "0.155.1");
|
||||
assert_eq!(CLI_VERSION, format!("codex-cli {VERSION}"));
|
||||
assert_eq!(SCHEMA, "genarrative-codex-sidecar.v2");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
//! 随包阶段的原生包元数据校验,不进入运行时生产模块。
|
||||
|
||||
use super::codex_bundle::{Layout, VERSION};
|
||||
|
||||
pub fn validate_package_metadata(
|
||||
metadata: &serde_json::Value,
|
||||
target: &str,
|
||||
layout: Layout,
|
||||
) -> Result<(), String> {
|
||||
if metadata["layoutVersion"] == 1
|
||||
&& metadata["version"] == VERSION
|
||||
&& metadata["target"] == target
|
||||
&& metadata["entrypoint"] == layout.executable
|
||||
&& metadata["resourcesDir"] == "codex-resources"
|
||||
&& metadata["pathDir"] == "codex-path"
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Codex 原生包版本、布局或架构不匹配目标 {target}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::codex_bundle::for_target;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn metadata_rejects_version_architecture_and_layout_drift() {
|
||||
let target = "aarch64-apple-darwin";
|
||||
let layout = for_target(target).unwrap();
|
||||
let valid = serde_json::json!({
|
||||
"layoutVersion": 1,
|
||||
"version": VERSION,
|
||||
"target": target,
|
||||
"entrypoint": "bin/codex",
|
||||
"resourcesDir": "codex-resources",
|
||||
"pathDir": "codex-path",
|
||||
});
|
||||
assert!(validate_package_metadata(&valid, target, layout).is_ok());
|
||||
for (key, value) in [
|
||||
("layoutVersion", serde_json::json!(2)),
|
||||
("version", serde_json::json!("0.0.0")),
|
||||
("target", serde_json::json!("x86_64-apple-darwin")),
|
||||
("entrypoint", serde_json::json!("bin/codex.exe")),
|
||||
("resourcesDir", serde_json::json!("../private")),
|
||||
("pathDir", serde_json::json!(null)),
|
||||
] {
|
||||
let mut invalid = valid.clone();
|
||||
invalid[key] = value;
|
||||
assert!(
|
||||
validate_package_metadata(&invalid, target, layout).is_err(),
|
||||
"{key}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
|
||||
pub const BUNDLE_FILES: [&str; 4] = [
|
||||
"bin/win-x64/agc_godot_editor.dll",
|
||||
"bin/win-x64/metadata.json",
|
||||
"vendor/LICENSE.txt",
|
||||
"vendor/provenance.json",
|
||||
];
|
||||
// 共享随包资源声明:Godot 随包文件清单与构建期校验共用同一份来源。
|
||||
#[allow(dead_code)]
|
||||
#[path = "package_layout.rs"]
|
||||
mod package_layout;
|
||||
|
||||
pub const BUNDLE_FILES: &[&str] = package_layout::GODOT_BUNDLE_FILES;
|
||||
|
||||
fn plain_metadata(path: &Path) -> Result<fs::Metadata, String> {
|
||||
let metadata = fs::symlink_metadata(path)
|
||||
@@ -74,39 +74,6 @@ pub fn validate(root: &Path) -> Result<Vec<(&'static str, Vec<u8>)>, String> {
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
pub fn stage(root: &Path, destination: &Path, target: &str, enabled: bool) -> Result<(), String> {
|
||||
if target != "x86_64-pc-windows-msvc" || !enabled {
|
||||
return Ok(());
|
||||
}
|
||||
for (relative, bytes) in validate(root)? {
|
||||
let path = destination.join(relative);
|
||||
fs::create_dir_all(path.parent().expect("Godot resource parent"))
|
||||
.map_err(|error| format!("创建 Godot 资源目录失败:{error}"))?;
|
||||
fs::write(&path, bytes).map_err(|error| format!("写入 Godot 资源失败:{error}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn source_files(root: &Path) -> Result<Vec<PathBuf>, String> {
|
||||
plain_metadata(root)?;
|
||||
let mut sources = Vec::new();
|
||||
for entry in fs::read_dir(root).map_err(|error| format!("读取 Godot 源码失败:{error}"))?
|
||||
{
|
||||
let entry = entry.map_err(|error| format!("读取 Godot 源码目录项失败:{error}"))?;
|
||||
if matches!(entry.file_name().to_str(), Some("bin" | ".build")) {
|
||||
continue;
|
||||
}
|
||||
let metadata = plain_metadata(&entry.path())?;
|
||||
if metadata.is_dir() {
|
||||
sources.extend(source_files(&entry.path())?);
|
||||
} else if metadata.is_file() {
|
||||
sources.push(entry.path());
|
||||
}
|
||||
}
|
||||
sources.sort();
|
||||
Ok(sources)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -134,82 +101,20 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_only_verified_windows_runtime_and_not_build_inputs() {
|
||||
let source = tempfile::tempdir().unwrap();
|
||||
let destination = tempfile::tempdir().unwrap();
|
||||
fixture(source.path());
|
||||
fs::write(source.path().join("bridge.gd"), "source").unwrap();
|
||||
fs::write(source.path().join("bin/win-x64/extra.dll"), "excluded").unwrap();
|
||||
stage(
|
||||
source.path(),
|
||||
destination.path(),
|
||||
"x86_64-pc-windows-msvc",
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
fn validate_rejects_incomplete_bundle() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let error = validate(temp.path()).expect_err("空目录必须被拒绝");
|
||||
assert!(!error.is_empty(), "失败原因不能为空");
|
||||
|
||||
// 只有文件名、内容不合法的「像样」目录也必须被拒绝。
|
||||
for relative in BUNDLE_FILES {
|
||||
assert_eq!(
|
||||
fs::read(source.path().join(relative)).unwrap(),
|
||||
fs::read(destination.path().join(relative)).unwrap()
|
||||
);
|
||||
let file = temp.path().join(relative);
|
||||
fs::create_dir_all(file.parent().expect("parent")).expect("create dir");
|
||||
fs::write(&file, b"not a real artifact").expect("write file");
|
||||
}
|
||||
assert!(!destination.path().join("bridge.gd").exists());
|
||||
assert!(!destination.path().join("bin/win-x64/extra.dll").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_or_disabled_targets_need_no_native_artifacts() {
|
||||
let destination = tempfile::tempdir().unwrap();
|
||||
for (target, enabled) in [
|
||||
("aarch64-apple-darwin", true),
|
||||
("x86_64-apple-darwin", true),
|
||||
("x86_64-unknown-linux-gnu", true),
|
||||
("aarch64-pc-windows-msvc", true),
|
||||
("x86_64-pc-windows-msvc", false),
|
||||
] {
|
||||
stage(
|
||||
Path::new("missing-godot-native"),
|
||||
destination.path(),
|
||||
target,
|
||||
enabled,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(fs::read_dir(destination.path()).unwrap().count(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_or_tampered_bundle_fails_before_copying() {
|
||||
let source = tempfile::tempdir().unwrap();
|
||||
let destination = tempfile::tempdir().unwrap();
|
||||
fixture(source.path());
|
||||
fs::write(source.path().join(BUNDLE_FILES[0]), b"tampered").unwrap();
|
||||
assert!(stage(
|
||||
source.path(),
|
||||
destination.path(),
|
||||
"x86_64-pc-windows-msvc",
|
||||
true
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("SHA256"));
|
||||
assert_eq!(fs::read_dir(destination.path()).unwrap().count(), 0);
|
||||
fixture(source.path());
|
||||
fs::remove_file(source.path().join("vendor/LICENSE.txt")).unwrap();
|
||||
assert!(validate(source.path()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_watch_list_excludes_build_outputs() {
|
||||
let source = tempfile::tempdir().unwrap();
|
||||
fixture(source.path());
|
||||
fs::create_dir(source.path().join(".build")).unwrap();
|
||||
fs::write(source.path().join(".build/bridge.obj"), "generated").unwrap();
|
||||
fs::write(source.path().join("bridge.gd"), "source").unwrap();
|
||||
let sources = source_files(source.path()).unwrap();
|
||||
assert_eq!(sources.len(), 3);
|
||||
assert!(sources.contains(&source.path().join("bridge.gd")));
|
||||
assert!(!sources.iter().any(|path| path
|
||||
.components()
|
||||
.any(|component| component.as_os_str() == "bin" || component.as_os_str() == ".build")));
|
||||
assert!(
|
||||
validate(temp.path()).is_err(),
|
||||
"内容不合法的随包库必须被拒绝",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user