@playcraft/cli 0.0.48 → 0.0.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -44
- package/REMIX.md +757 -0
- package/dist/commands/login.js +82 -32
- package/dist/commands/remix.js +883 -176
- package/dist/utils/playcraft-http-client.js +132 -0
- package/dist/utils/remix-image-attachments.js +125 -0
- package/package.json +7 -3
- package/dist/agent/cloud-connection.test.js +0 -67
- package/dist/agent/local-backend.test.js +0 -52
- package/dist/commands/fix-ids.test.js +0 -264
- package/dist/commands/tools-research.test.js +0 -216
- package/dist/prad/check.test.js +0 -49
- package/dist/sync/sync-manager.test.js +0 -80
|
@@ -1,216 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* playcraft tools research / fetch-url 命令测试
|
|
3
|
-
*
|
|
4
|
-
* 覆盖:
|
|
5
|
-
* - 正确路径:research 返回 answer + citations
|
|
6
|
-
* - 正确路径:fetch-url 返回 answer + citations
|
|
7
|
-
* - focus 参数透传
|
|
8
|
-
* - query 参数透传
|
|
9
|
-
* - 后端返回 4xx / 5xx 时抛出错误
|
|
10
|
-
* - 鉴权头:sandbox JWT → X-Sandbox-Token;PAT → Authorization: Bearer
|
|
11
|
-
* - citation 为空时不崩溃
|
|
12
|
-
*/
|
|
13
|
-
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi, } from 'vitest';
|
|
14
|
-
import http from 'node:http';
|
|
15
|
-
import { researchCommand, fetchUrlCommand } from './tools-research.js';
|
|
16
|
-
// ─── Mock loadGlobalConfig ─────────────────────────────────────────────────────
|
|
17
|
-
vi.mock('../config.js', () => ({
|
|
18
|
-
loadGlobalConfig: vi.fn(() => ({})),
|
|
19
|
-
saveGlobalConfig: vi.fn(),
|
|
20
|
-
loadConfig: vi.fn(),
|
|
21
|
-
}));
|
|
22
|
-
let server;
|
|
23
|
-
let baseUrl;
|
|
24
|
-
let lastRequest = null;
|
|
25
|
-
let responseStatus = 200;
|
|
26
|
-
let responseBody = {
|
|
27
|
-
answer: 'Test answer from Gemini',
|
|
28
|
-
citations: [],
|
|
29
|
-
};
|
|
30
|
-
beforeAll(async () => {
|
|
31
|
-
server = http.createServer((req, res) => {
|
|
32
|
-
let body = '';
|
|
33
|
-
req.on('data', (chunk) => { body += chunk.toString(); });
|
|
34
|
-
req.on('end', () => {
|
|
35
|
-
lastRequest = {
|
|
36
|
-
url: req.url ?? '',
|
|
37
|
-
method: req.method ?? '',
|
|
38
|
-
headers: req.headers,
|
|
39
|
-
body,
|
|
40
|
-
};
|
|
41
|
-
res.writeHead(responseStatus, { 'Content-Type': 'application/json' });
|
|
42
|
-
res.end(JSON.stringify(responseBody));
|
|
43
|
-
});
|
|
44
|
-
});
|
|
45
|
-
await new Promise((resolve) => {
|
|
46
|
-
server.listen(0, '127.0.0.1', () => resolve());
|
|
47
|
-
});
|
|
48
|
-
const addr = server.address();
|
|
49
|
-
baseUrl = `http://127.0.0.1:${addr.port}`;
|
|
50
|
-
});
|
|
51
|
-
afterAll(async () => {
|
|
52
|
-
await new Promise((resolve) => server.close(() => resolve()));
|
|
53
|
-
});
|
|
54
|
-
beforeEach(() => {
|
|
55
|
-
lastRequest = null;
|
|
56
|
-
responseStatus = 200;
|
|
57
|
-
responseBody = { answer: 'Test answer', citations: [] };
|
|
58
|
-
delete process.env.PLAYCRAFT_API_URL;
|
|
59
|
-
delete process.env.PLAYCRAFT_SANDBOX_TOKEN;
|
|
60
|
-
});
|
|
61
|
-
afterEach(() => {
|
|
62
|
-
delete process.env.PLAYCRAFT_API_URL;
|
|
63
|
-
delete process.env.PLAYCRAFT_SANDBOX_TOKEN;
|
|
64
|
-
});
|
|
65
|
-
// ─── Helper ───────────────────────────────────────────────────────────────────
|
|
66
|
-
/** 解析测试服务器收到的请求 body */
|
|
67
|
-
function parseBody() {
|
|
68
|
-
return JSON.parse(lastRequest?.body ?? '{}');
|
|
69
|
-
}
|
|
70
|
-
// ─── researchCommand ──────────────────────────────────────────────────────────
|
|
71
|
-
describe('researchCommand', () => {
|
|
72
|
-
it('发送 POST 到 /api/agent/tools/research', async () => {
|
|
73
|
-
const result = await researchCommand({
|
|
74
|
-
query: 'match-3 level design',
|
|
75
|
-
apiUrl: baseUrl,
|
|
76
|
-
token: 'test-sandbox-token',
|
|
77
|
-
});
|
|
78
|
-
expect(lastRequest?.url).toBe('/api/agent/tools/research');
|
|
79
|
-
expect(lastRequest?.method).toBe('POST');
|
|
80
|
-
expect(result.answer).toBe('Test answer');
|
|
81
|
-
});
|
|
82
|
-
it('将 query 和 focus 正确写入请求 body', async () => {
|
|
83
|
-
await researchCommand({
|
|
84
|
-
query: 'pixel art color palette',
|
|
85
|
-
focus: 'visual',
|
|
86
|
-
apiUrl: baseUrl,
|
|
87
|
-
token: 'test-sandbox-token',
|
|
88
|
-
});
|
|
89
|
-
const body = parseBody();
|
|
90
|
-
expect(body.query).toBe('pixel art color palette');
|
|
91
|
-
expect(body.focus).toBe('visual');
|
|
92
|
-
});
|
|
93
|
-
it('不传 focus 时 body 中 focus 为 undefined(不发送该字段)', async () => {
|
|
94
|
-
await researchCommand({
|
|
95
|
-
query: 'game mechanics',
|
|
96
|
-
apiUrl: baseUrl,
|
|
97
|
-
token: 'test-sandbox-token',
|
|
98
|
-
});
|
|
99
|
-
const body = parseBody();
|
|
100
|
-
expect(body.query).toBe('game mechanics');
|
|
101
|
-
expect(body.focus).toBeUndefined();
|
|
102
|
-
});
|
|
103
|
-
it('所有 focus 枚举值均可正常透传', async () => {
|
|
104
|
-
const focuses = ['gameplay', 'visual', 'audio', 'code', 'compliance'];
|
|
105
|
-
for (const focus of focuses) {
|
|
106
|
-
await researchCommand({ query: 'test', focus, apiUrl: baseUrl, token: 'tok' });
|
|
107
|
-
expect(parseBody().focus).toBe(focus);
|
|
108
|
-
}
|
|
109
|
-
});
|
|
110
|
-
it('返回包含 citations 的完整结果', async () => {
|
|
111
|
-
responseBody = {
|
|
112
|
-
answer: 'Answer with citations',
|
|
113
|
-
citations: [
|
|
114
|
-
{ url: 'https://example.com/1', title: 'Source 1' },
|
|
115
|
-
{ url: 'https://example.com/2', title: 'Source 2', excerpt: 'excerpt text' },
|
|
116
|
-
],
|
|
117
|
-
};
|
|
118
|
-
const result = await researchCommand({
|
|
119
|
-
query: 'test query',
|
|
120
|
-
apiUrl: baseUrl,
|
|
121
|
-
token: 'test-token',
|
|
122
|
-
});
|
|
123
|
-
expect(result.answer).toBe('Answer with citations');
|
|
124
|
-
expect(result.citations).toHaveLength(2);
|
|
125
|
-
expect(result.citations[0].url).toBe('https://example.com/1');
|
|
126
|
-
expect(result.citations[1].excerpt).toBe('excerpt text');
|
|
127
|
-
});
|
|
128
|
-
it('citations 为空数组时不崩溃', async () => {
|
|
129
|
-
responseBody = { answer: 'No citations answer', citations: [] };
|
|
130
|
-
const result = await researchCommand({
|
|
131
|
-
query: 'test',
|
|
132
|
-
apiUrl: baseUrl,
|
|
133
|
-
token: 'test-token',
|
|
134
|
-
});
|
|
135
|
-
expect(result.citations).toEqual([]);
|
|
136
|
-
});
|
|
137
|
-
it('使用沙箱 JWT 时发送 X-Sandbox-Token 头', async () => {
|
|
138
|
-
await researchCommand({ query: 'test', apiUrl: baseUrl, token: 'sandbox-jwt-eyJ...' });
|
|
139
|
-
expect(lastRequest?.headers['x-sandbox-token']).toBe('sandbox-jwt-eyJ...');
|
|
140
|
-
expect(lastRequest?.headers['authorization']).toBeUndefined();
|
|
141
|
-
});
|
|
142
|
-
it('使用 PAT 时发送 Authorization: Bearer 头', async () => {
|
|
143
|
-
await researchCommand({ query: 'test', apiUrl: baseUrl, token: 'playcraft_pat_abc123' });
|
|
144
|
-
expect(lastRequest?.headers['authorization']).toBe('Bearer playcraft_pat_abc123');
|
|
145
|
-
expect(lastRequest?.headers['x-sandbox-token']).toBeUndefined();
|
|
146
|
-
});
|
|
147
|
-
it('后端返回 400 时抛出错误', async () => {
|
|
148
|
-
responseStatus = 400;
|
|
149
|
-
responseBody = { error: 'query is required' };
|
|
150
|
-
await expect(researchCommand({ query: 'test', apiUrl: baseUrl, token: 'tok' })).rejects.toThrow(/400/);
|
|
151
|
-
});
|
|
152
|
-
it('后端返回 500 时抛出错误', async () => {
|
|
153
|
-
responseStatus = 500;
|
|
154
|
-
responseBody = { error: 'Internal server error' };
|
|
155
|
-
await expect(researchCommand({ query: 'test', apiUrl: baseUrl, token: 'tok' })).rejects.toThrow(/500/);
|
|
156
|
-
});
|
|
157
|
-
});
|
|
158
|
-
// ─── fetchUrlCommand ──────────────────────────────────────────────────────────
|
|
159
|
-
describe('fetchUrlCommand', () => {
|
|
160
|
-
it('发送 POST 到 /api/agent/tools/fetch-url', async () => {
|
|
161
|
-
const result = await fetchUrlCommand({
|
|
162
|
-
url: 'https://phaser.io/docs/3.60.0',
|
|
163
|
-
apiUrl: baseUrl,
|
|
164
|
-
token: 'test-token',
|
|
165
|
-
});
|
|
166
|
-
expect(lastRequest?.url).toBe('/api/agent/tools/fetch-url');
|
|
167
|
-
expect(lastRequest?.method).toBe('POST');
|
|
168
|
-
expect(result.answer).toBe('Test answer');
|
|
169
|
-
});
|
|
170
|
-
it('将 url 和 query 正确写入请求 body', async () => {
|
|
171
|
-
await fetchUrlCommand({
|
|
172
|
-
url: 'https://phaser.io/docs/3.60.0',
|
|
173
|
-
query: 'how does TileMap collision work',
|
|
174
|
-
apiUrl: baseUrl,
|
|
175
|
-
token: 'test-token',
|
|
176
|
-
});
|
|
177
|
-
const body = parseBody();
|
|
178
|
-
expect(body.url).toBe('https://phaser.io/docs/3.60.0');
|
|
179
|
-
expect(body.query).toBe('how does TileMap collision work');
|
|
180
|
-
});
|
|
181
|
-
it('不传 query 时请求 body 中 query 为 undefined', async () => {
|
|
182
|
-
await fetchUrlCommand({
|
|
183
|
-
url: 'https://example.com',
|
|
184
|
-
apiUrl: baseUrl,
|
|
185
|
-
token: 'tok',
|
|
186
|
-
});
|
|
187
|
-
const body = parseBody();
|
|
188
|
-
expect(body.url).toBe('https://example.com');
|
|
189
|
-
expect(body.query).toBeUndefined();
|
|
190
|
-
});
|
|
191
|
-
it('返回包含 citations 的完整结果', async () => {
|
|
192
|
-
responseBody = {
|
|
193
|
-
answer: 'Content from URL',
|
|
194
|
-
citations: [{ url: 'https://phaser.io/docs', title: 'Phaser Docs' }],
|
|
195
|
-
};
|
|
196
|
-
const result = await fetchUrlCommand({
|
|
197
|
-
url: 'https://phaser.io/docs',
|
|
198
|
-
apiUrl: baseUrl,
|
|
199
|
-
token: 'tok',
|
|
200
|
-
});
|
|
201
|
-
expect(result.answer).toBe('Content from URL');
|
|
202
|
-
expect(result.citations[0].title).toBe('Phaser Docs');
|
|
203
|
-
});
|
|
204
|
-
it('使用 PAT 时发送 Authorization: Bearer 头', async () => {
|
|
205
|
-
await fetchUrlCommand({
|
|
206
|
-
url: 'https://example.com',
|
|
207
|
-
apiUrl: baseUrl,
|
|
208
|
-
token: 'playcraft_pat_xyz',
|
|
209
|
-
});
|
|
210
|
-
expect(lastRequest?.headers['authorization']).toBe('Bearer playcraft_pat_xyz');
|
|
211
|
-
});
|
|
212
|
-
it('后端返回 400 时抛出错误', async () => {
|
|
213
|
-
responseStatus = 400;
|
|
214
|
-
await expect(fetchUrlCommand({ url: 'https://example.com', apiUrl: baseUrl, token: 'tok' })).rejects.toThrow(/400/);
|
|
215
|
-
});
|
|
216
|
-
});
|
package/dist/prad/check.test.js
DELETED
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import * as fs from 'node:fs';
|
|
2
|
-
import * as path from 'node:path';
|
|
3
|
-
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import { describe, expect, it } from 'vitest';
|
|
5
|
-
import { checkPradFile } from './check.js';
|
|
6
|
-
import { explainPradPath, formatExplain } from './explain.js';
|
|
7
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
-
const mahjongSample = path.resolve(__dirname, '..', '..', '..', '..', 'docs', 'prad', 'samples', 'mahjong-gate4.atom-tree.json');
|
|
9
|
-
describe('prad check', () => {
|
|
10
|
-
it('mahjong gate4 v0.2.1 passes with no E026 and draft warnings only', () => {
|
|
11
|
-
if (!fs.existsSync(mahjongSample))
|
|
12
|
-
return;
|
|
13
|
-
const result = checkPradFile(mahjongSample, { gate: 4 });
|
|
14
|
-
expect(result.errors).toEqual([]);
|
|
15
|
-
expect(result.warnings.some((w) => w.code === 'PRAD-W020')).toBe(true);
|
|
16
|
-
expect(result.warnings.some((w) => w.code === 'PRAD-E026')).toBe(false);
|
|
17
|
-
expect(result.errors.some((e) => e.code === 'PRAD-E026')).toBe(false);
|
|
18
|
-
expect(result.ok).toBe(true);
|
|
19
|
-
});
|
|
20
|
-
it('explain play_btn path', () => {
|
|
21
|
-
if (!fs.existsSync(mahjongSample))
|
|
22
|
-
return;
|
|
23
|
-
const doc = JSON.parse(fs.readFileSync(mahjongSample, 'utf-8'));
|
|
24
|
-
const result = explainPradPath(doc, 'scenes.entry.ui_layer.play_btn');
|
|
25
|
-
expect(result.found).toBe(true);
|
|
26
|
-
expect(result.slot?.spine).toBe('medium.hook.play');
|
|
27
|
-
expect(formatExplain(result)).toContain('medium.hook.play');
|
|
28
|
-
});
|
|
29
|
-
it('explain rule_engine itemSprites uses pick_and_match manifest', () => {
|
|
30
|
-
if (!fs.existsSync(mahjongSample))
|
|
31
|
-
return;
|
|
32
|
-
const doc = JSON.parse(fs.readFileSync(mahjongSample, 'utf-8'));
|
|
33
|
-
const result = explainPradPath(doc, 'scenes.gameplay.board_layer.rule_engine.props.itemSprites');
|
|
34
|
-
expect(result.found).toBe(true);
|
|
35
|
-
expect(result.bindAs).toBe('itemSprites');
|
|
36
|
-
expect(result.parentEffectiveAtom).toBe('pick_and_match_rules.aigameplay');
|
|
37
|
-
expect(result.manifestImportSlots).toContain('itemSprites');
|
|
38
|
-
});
|
|
39
|
-
it('explain hint_btn icon bindAs fingerTexture on tutorial_overlay', () => {
|
|
40
|
-
if (!fs.existsSync(mahjongSample))
|
|
41
|
-
return;
|
|
42
|
-
const doc = JSON.parse(fs.readFileSync(mahjongSample, 'utf-8'));
|
|
43
|
-
const result = explainPradPath(doc, 'scenes.gameplay.hud_layer.hint_btn.props.icon');
|
|
44
|
-
expect(result.found).toBe(true);
|
|
45
|
-
expect(result.bindAs).toBe('fingerTexture');
|
|
46
|
-
expect(result.parentEffectiveAtom).toBe('tutorial_overlay.aicomponent');
|
|
47
|
-
expect(result.manifestImportSlots).toContain('fingerTexture');
|
|
48
|
-
});
|
|
49
|
-
});
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import fs from 'node:fs/promises';
|
|
4
|
-
import os from 'node:os';
|
|
5
|
-
import { SyncManager } from './sync-manager.js';
|
|
6
|
-
describe('SyncManager', () => {
|
|
7
|
-
let tmpDir;
|
|
8
|
-
let mockConnection;
|
|
9
|
-
beforeEach(async () => {
|
|
10
|
-
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sync-manager-test-'));
|
|
11
|
-
mockConnection = {
|
|
12
|
-
request: vi.fn(),
|
|
13
|
-
isConnected: true,
|
|
14
|
-
connectionState: 'connected',
|
|
15
|
-
};
|
|
16
|
-
});
|
|
17
|
-
afterEach(async () => {
|
|
18
|
-
if (tmpDir) {
|
|
19
|
-
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
20
|
-
}
|
|
21
|
-
});
|
|
22
|
-
it('upload() reads file and calls cloud request', async () => {
|
|
23
|
-
const filePath = 'test.txt';
|
|
24
|
-
const fullPath = path.join(tmpDir, filePath);
|
|
25
|
-
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
26
|
-
await fs.writeFile(fullPath, 'hello', 'utf-8');
|
|
27
|
-
vi.mocked(mockConnection.request).mockResolvedValue({ success: true });
|
|
28
|
-
const manager = new SyncManager({
|
|
29
|
-
cloudConnection: mockConnection,
|
|
30
|
-
projectDir: tmpDir,
|
|
31
|
-
});
|
|
32
|
-
const result = await manager.upload(filePath);
|
|
33
|
-
expect(result.success).toBe(true);
|
|
34
|
-
expect(result.direction).toBe('up');
|
|
35
|
-
expect(mockConnection.request).toHaveBeenCalledWith('POST', '/api/sync/upload', expect.objectContaining({
|
|
36
|
-
path: filePath,
|
|
37
|
-
data: expect.any(String),
|
|
38
|
-
compressed: false,
|
|
39
|
-
}));
|
|
40
|
-
});
|
|
41
|
-
it('upload() returns error result when file missing', async () => {
|
|
42
|
-
const manager = new SyncManager({
|
|
43
|
-
cloudConnection: mockConnection,
|
|
44
|
-
projectDir: tmpDir,
|
|
45
|
-
});
|
|
46
|
-
const result = await manager.upload('nonexistent.txt');
|
|
47
|
-
expect(result.success).toBe(false);
|
|
48
|
-
expect(result.error).toBeDefined();
|
|
49
|
-
expect(mockConnection.request).not.toHaveBeenCalled();
|
|
50
|
-
});
|
|
51
|
-
it('download() calls cloud request and writes file', async () => {
|
|
52
|
-
const filePath = 'subdir/file.txt';
|
|
53
|
-
const content = Buffer.from('world').toString('base64');
|
|
54
|
-
vi.mocked(mockConnection.request).mockResolvedValue({ data: content });
|
|
55
|
-
const manager = new SyncManager({
|
|
56
|
-
cloudConnection: mockConnection,
|
|
57
|
-
projectDir: tmpDir,
|
|
58
|
-
});
|
|
59
|
-
const result = await manager.download(filePath);
|
|
60
|
-
expect(result.success).toBe(true);
|
|
61
|
-
expect(result.direction).toBe('down');
|
|
62
|
-
const fullPath = path.join(tmpDir, filePath);
|
|
63
|
-
const written = await fs.readFile(fullPath, 'utf-8');
|
|
64
|
-
expect(written).toBe('world');
|
|
65
|
-
});
|
|
66
|
-
it('getPendingUploads returns empty array', () => {
|
|
67
|
-
const manager = new SyncManager({
|
|
68
|
-
cloudConnection: mockConnection,
|
|
69
|
-
projectDir: tmpDir,
|
|
70
|
-
});
|
|
71
|
-
expect(manager.getPendingUploads()).toEqual([]);
|
|
72
|
-
});
|
|
73
|
-
it('getPendingDownloads returns empty array', () => {
|
|
74
|
-
const manager = new SyncManager({
|
|
75
|
-
cloudConnection: mockConnection,
|
|
76
|
-
projectDir: tmpDir,
|
|
77
|
-
});
|
|
78
|
-
expect(manager.getPendingDownloads()).toEqual([]);
|
|
79
|
-
});
|
|
80
|
-
});
|