feat(coding): add generated Data Service SDK skill
This commit is contained in:
300
tests/unit/data-service-sdk-assets.test.ts
Normal file
300
tests/unit/data-service-sdk-assets.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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 或云端发布。',
|
||||
|
||||
Reference in New Issue
Block a user