feat(coding): add generated Data Service SDK skill

This commit is contained in:
2026-08-26 23:26:18 +08:00
parent 552c6162a5
commit 549069dffe
8 changed files with 1454 additions and 0 deletions

View File

@@ -0,0 +1,300 @@
// @vitest-environment node
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import ts from 'typescript';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { listProductCodingSkills } from '@electron/coding-projects/skill-registry';
const ASSET_ROOT = path.resolve('resources/coding-skills/data-service/assets');
const ASSETS = ['makelore-data.ts', 'makelore-data.js'] as const;
const temporaryRoots: string[] = [];
let moduleCounter = 0;
afterEach(async () => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
delete (globalThis as { __MAKELORE_DATA__?: unknown }).__MAKELORE_DATA__;
await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
async function loadSdk(assetName: (typeof ASSETS)[number]): Promise<{
data: {
get(collection: string, documentId: string): Promise<unknown>;
list(collection: string, options?: { limit?: number; cursor?: string }): Promise<unknown>;
put(collection: string, documentId: string, data: Record<string, unknown>, options?: { ifRevision?: number }): Promise<unknown>;
delete(collection: string, documentId: string, options?: { ifRevision?: number }): Promise<void>;
add(collection: string, data: Record<string, unknown>): Promise<unknown>;
};
DataServiceError: new (...args: unknown[]) => Error;
}> {
const source = await readFile(path.join(ASSET_ROOT, assetName), 'utf8');
const root = await mkdtemp(path.join(tmpdir(), 'makelore-data-sdk-'));
temporaryRoots.push(root);
const moduleName = `${path.basename(assetName, path.extname(assetName))}-${moduleCounter += 1}.mjs`;
const javascript = assetName.endsWith('.ts')
? ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
fileName: assetName,
}).outputText
: source;
const modulePath = path.join(root, moduleName);
await writeFile(modulePath, javascript, 'utf8');
return await import(`${pathToFileURL(modulePath).href}?v=${moduleCounter}`) as {
data: {
get(collection: string, documentId: string): Promise<unknown>;
list(collection: string, options?: { limit?: number; cursor?: string }): Promise<unknown>;
put(collection: string, documentId: string, data: Record<string, unknown>, options?: { ifRevision?: number }): Promise<unknown>;
delete(collection: string, documentId: string, options?: { ifRevision?: number }): Promise<void>;
add(collection: string, data: Record<string, unknown>): Promise<unknown>;
};
DataServiceError: new (...args: unknown[]) => Error;
};
}
function injectRuntime(overrides: Record<string, unknown> = {}): void {
Object.defineProperty(globalThis, '__MAKELORE_DATA__', {
configurable: true,
value: {
endpoint: 'http://127.0.0.1:4173/api/runtime/data/v1',
token: 'preview-token',
contractVersion: 1,
...overrides,
},
});
}
function response(payload: unknown, status = 200, headers: Record<string, string> = {}): Response {
return new Response(payload === undefined ? null : JSON.stringify(payload), {
status,
headers: { 'content-type': 'application/json', ...headers },
});
}
const documentDto = {
id: 'todo.1',
data: { title: 'Ship P0', done: false },
revision: 7,
created_at: '2026-08-26T00:00:00.000Z',
updated_at: '2026-08-26T00:00:00.000Z',
};
// Skill installation is agent-driven; this fixture keeps its direct-text policy
// executable without adding a second runtime installer or template abstraction.
async function installFixtureAsset(
projectRoot: string,
target: string,
source: string,
): Promise<'installed' | 'noop' | 'conflict'> {
const targetPath = path.join(projectRoot, target);
let existing: string | undefined;
try {
existing = await readFile(targetPath, 'utf8');
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
if (existing === source) return 'noop';
if (existing !== undefined) return 'conflict';
await mkdir(path.dirname(targetPath), { recursive: true });
await writeFile(targetPath, source, 'utf8');
return 'installed';
}
describe.each(ASSETS)('generated Data Service SDK (%s)', (assetName) => {
it('returns runtime_unavailable without a network call for absent or invalid injection', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const sdk = await loadSdk(assetName);
await expect(sdk.data.get('todos', 'todo.1')).rejects.toMatchObject({
code: 'runtime_unavailable', status: 503, retryable: true,
});
expect(fetchMock).not.toHaveBeenCalled();
injectRuntime({ contractVersion: 2 });
await expect(sdk.data.get('todos', 'todo.1')).rejects.toMatchObject({
code: 'runtime_unavailable', status: 503,
});
expect(fetchMock).not.toHaveBeenCalled();
injectRuntime({ endpoint: 'https://cloud.example/api/runtime/data/v1' });
await expect(sdk.data.get('todos', 'todo.1')).rejects.toMatchObject({
code: 'runtime_unavailable', status: 503,
});
expect(fetchMock).not.toHaveBeenCalled();
});
it('uses the injected local capability, encoded paths, direct DTOs, and one strong If-Match', async () => {
injectRuntime();
const fetchMock = vi.fn()
.mockResolvedValueOnce(response(documentDto))
.mockResolvedValueOnce(response({ items: [documentDto], next_cursor: null, limit: 50 }))
.mockResolvedValueOnce(response(documentDto))
.mockResolvedValueOnce(response(undefined, 204));
vi.stubGlobal('fetch', fetchMock);
const sdk = await loadSdk(assetName);
await expect(sdk.data.get('todos', 'todo.1')).resolves.toEqual(documentDto);
await expect(sdk.data.list('todos', { limit: 50, cursor: 'next cursor' })).resolves.toEqual({
items: [documentDto], next_cursor: null, limit: 50,
});
await expect(sdk.data.put('todos', 'todo.1', { title: 'Ship P0' }, { ifRevision: 7 }))
.resolves.toEqual(documentDto);
await expect(sdk.data.delete('todos', 'todo.1', { ifRevision: 7 })).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalledTimes(4);
expect(fetchMock.mock.calls[0][0]).toBe(
'http://127.0.0.1:4173/api/runtime/data/v1/collections/todos/documents/todo.1',
);
expect(fetchMock.mock.calls[1][0]).toContain('/collections/todos/documents?');
expect(fetchMock.mock.calls[1][0]).toContain('cursor=next+cursor');
expect(fetchMock.mock.calls[2][1]).toMatchObject({
method: 'PUT',
headers: expect.objectContaining({
Authorization: 'Bearer preview-token',
'If-Match': '"7"',
}),
body: JSON.stringify({ data: { title: 'Ship P0' } }),
});
expect(fetchMock.mock.calls[3][1]).toMatchObject({
method: 'DELETE',
headers: expect.objectContaining({ Authorization: 'Bearer preview-token', 'If-Match': '"7"' }),
});
expect(fetchMock.mock.calls[3][1]).not.toHaveProperty('body');
expect(JSON.stringify(fetchMock.mock.calls[3][1])).not.toContain('confirmed');
});
it('strictly projects safe errors and never retries a failed request', async () => {
injectRuntime();
const fetchMock = vi.fn()
.mockResolvedValueOnce(response({
detail: {
code: 'revision_conflict',
message: 'secret upstream detail',
retryable: false,
context: { current_revision: 8 },
},
}, 409, { 'retry-after': '10' }))
.mockRejectedValueOnce(new Error('socket detail should not escape'));
vi.stubGlobal('fetch', fetchMock);
const sdk = await loadSdk(assetName);
const conflict = await sdk.data.put('todos', 'todo.1', { title: 'Ship P0' }, { ifRevision: 7 })
.catch((error: unknown) => error as {
code: string;
status: number;
retryable: boolean;
retryAfterSeconds?: number;
context?: Record<string, unknown>;
});
expect(conflict).toMatchObject({
code: 'revision_conflict', status: 409, retryable: false, retryAfterSeconds: 10,
context: { current_revision: 8 },
});
expect(Object.isFrozen(conflict)).toBe(true);
expect(Object.isFrozen(conflict.context!)).toBe(true);
await expect(sdk.data.get('todos', 'todo.1')).rejects.toMatchObject({
code: 'runtime_unavailable', status: 503, retryable: true,
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(JSON.stringify(await Promise.resolve(fetchMock.mock.calls))).not.toContain('secret upstream detail');
});
it('rejects malformed DTOs and unknown error codes without exposing upstream detail', async () => {
injectRuntime();
const fetchMock = vi.fn()
.mockResolvedValueOnce(response({ ...documentDto, extra: 'not allowed' }))
.mockResolvedValueOnce(response({ detail: {
code: 'toString', message: 'should not be accepted', retryable: false,
} }, 502));
vi.stubGlobal('fetch', fetchMock);
const sdk = await loadSdk(assetName);
await expect(sdk.data.get('todos', 'todo.1')).rejects.toMatchObject({
code: 'upstream_invalid_response', status: 502,
});
await expect(sdk.data.get('todos', 'todo.1')).rejects.toMatchObject({
code: 'upstream_invalid_response', status: 502,
});
expect(JSON.stringify(fetchMock.mock.calls)).not.toContain('should not be accepted');
});
it('keeps DELETE as a document operation and add creates a local UUID before put', async () => {
injectRuntime();
const fetchMock = vi.fn().mockResolvedValue(response(documentDto));
vi.stubGlobal('fetch', fetchMock);
const uuid = '11111111-1111-4111-8111-111111111111';
vi.stubGlobal('crypto', { randomUUID: vi.fn(() => uuid) });
const sdk = await loadSdk(assetName);
await expect(sdk.data.add('todos', { title: 'Ship P0' })).resolves.toEqual(documentDto);
expect(fetchMock.mock.calls[0][0]).toContain(`/documents/${uuid}`);
});
});
describe('bundled Data Service Skill packaging', () => {
it('ships both canonical assets and an ordered workflow with explicit completion criteria', async () => {
const skill = await readFile(path.resolve('resources/coding-skills/data-service/SKILL.md'), 'utf8');
const tsAsset = await readFile(path.join(ASSET_ROOT, 'makelore-data.ts'), 'utf8');
const jsAsset = await readFile(path.join(ASSET_ROOT, 'makelore-data.js'), 'utf8');
expect(skill).toMatch(/^---\nname: data-service\n/m);
expect(skill.indexOf('inspect')).toBeGreaterThanOrEqual(0);
expect(skill.indexOf('explicit')).toBeGreaterThanOrEqual(0);
expect(skill.indexOf('data_service_configure')).toBeGreaterThanOrEqual(0);
expect(skill.indexOf('makelore-data.ts')).toBeGreaterThanOrEqual(0);
expect(skill.indexOf('agent_browser')).toBeGreaterThanOrEqual(0);
expect(skill.indexOf('read-back')).toBeGreaterThanOrEqual(0);
expect(skill.indexOf('report')).toBeGreaterThanOrEqual(0);
expect(skill.indexOf('inspect')).toBeLessThan(skill.indexOf('data_service_configure'));
expect(skill.indexOf('data_service_configure')).toBeLessThan(skill.indexOf('agent_browser'));
expect(skill.indexOf('agent_browser')).toBeLessThan(skill.indexOf('read-back'));
expect(skill.indexOf('read-back')).toBeLessThan(skill.indexOf('report'));
expect(skill).toContain('src/lib/makelore-data.js');
expect(skill).toContain('makelore-data.js');
expect(skill).toContain('逐字复制');
expect(skill).toContain('no-op');
expect(skill).toContain('冲突');
const packaged = await listProductCodingSkills(path.resolve('resources/coding-skills'));
const dataService = packaged.find(({ id }) => id === 'data-service');
expect(dataService).toMatchObject({ id: 'data-service', name: 'data-service' });
expect(dataService?.entries).toEqual(expect.arrayContaining([
{ path: 'SKILL.md', type: 'file' },
{ path: 'assets', type: 'directory' },
{ path: 'assets/makelore-data.js', type: 'file' },
{ path: 'assets/makelore-data.ts', type: 'file' },
]));
for (const source of [tsAsset, jsAsset]) {
expect(source).toContain('globalThis.__MAKELORE_DATA__');
expect(source).toContain('encodeURIComponent');
expect(source).not.toMatch(/firebase|works square|https?:\/\/[^'"`$]*api/i);
expect(source).not.toContain('confirmed');
expect(source).not.toMatch(/retry\s*\(/i);
}
});
it.each([
{
label: 'src TypeScript', target: 'src/lib/makelore-data.ts', asset: 'makelore-data.ts',
},
{
label: 'src JavaScript', target: 'src/lib/makelore-data.js', asset: 'makelore-data.js',
},
{
label: 'root TypeScript', target: 'makelore-data.ts', asset: 'makelore-data.ts',
},
])('keeps first/repeat/conflict install semantics for $label fixtures', async ({ target, asset }) => {
const root = await mkdtemp(path.join(tmpdir(), 'makelore-data-install-'));
temporaryRoots.push(root);
const source = await readFile(path.join(ASSET_ROOT, asset), 'utf8');
await expect(installFixtureAsset(root, target, source)).resolves.toBe('installed');
await expect(readFile(path.join(root, target), 'utf8')).resolves.toBe(source);
await expect(installFixtureAsset(root, target, source)).resolves.toBe('noop');
await writeFile(path.join(root, target), `${source}\n// local edit\n`, 'utf8');
await expect(installFixtureAsset(root, target, source)).resolves.toBe('conflict');
await expect(readFile(path.join(root, target), 'utf8')).resolves.toContain('// local edit');
});
});

View File

@@ -7,6 +7,10 @@ describe('skill display metadata', () => {
name: '开发浏览器',
description: '打开、查看或调试本地及公网网页,读取 Console、Network、DOM 和样式信息。',
});
expect(getSkillDisplayInfo('data-service')).toEqual({
name: '开发数据服务',
description: '为本地项目配置受控的开发数据,并在预览中验证真实读写。',
});
expect(getSkillDisplayInfo('frontend-slides')).toEqual({
name: '项目演示',
description: '把项目内容整理成可播放的 16:9 HTML 幻灯片,不生成 PPTX 或云端发布。',