mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
1012 lines
43 KiB
TypeScript
1012 lines
43 KiB
TypeScript
import { execFile } from 'node:child_process';
|
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
import http from 'node:http';
|
|
import { tmpdir } from 'node:os';
|
|
import { dirname, join, resolve as pathResolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { promisify } from 'node:util';
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
|
|
const execFileP = promisify(execFile);
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const DAEMON_ROOT = pathResolve(__dirname, '..');
|
|
const REPO_ROOT = pathResolve(__dirname, '../../..');
|
|
const CLI_SRC = pathResolve(__dirname, '../src/cli.ts');
|
|
const TSX_CLI = pathResolve(REPO_ROOT, 'node_modules/tsx/dist/cli.mjs');
|
|
|
|
interface CapturedRequest {
|
|
method: string;
|
|
url: string;
|
|
headers: http.IncomingHttpHeaders;
|
|
body: string;
|
|
}
|
|
|
|
interface StubServer {
|
|
baseUrl: string;
|
|
requests: CapturedRequest[];
|
|
close: () => Promise<void>;
|
|
}
|
|
|
|
let stub: StubServer | null = null;
|
|
let tempRoot = '';
|
|
|
|
afterEach(async () => {
|
|
if (stub) await stub.close();
|
|
stub = null;
|
|
if (tempRoot) rmSync(tempRoot, { recursive: true, force: true });
|
|
tempRoot = '';
|
|
});
|
|
|
|
async function startProjectStubServer(deleteResponse: unknown = { ok: true }, shareResponse?: unknown, shareStatus = 200, planResponse?: unknown): Promise<StubServer> {
|
|
const requests: CapturedRequest[] = [];
|
|
const server = http.createServer((req, res) => {
|
|
let raw = '';
|
|
req.on('data', (chunk) => {
|
|
raw += chunk;
|
|
});
|
|
req.on('end', () => {
|
|
const captured: CapturedRequest = {
|
|
method: req.method ?? '',
|
|
url: req.url ?? '',
|
|
headers: req.headers,
|
|
body: raw,
|
|
};
|
|
requests.push(captured);
|
|
|
|
res.setHeader('content-type', 'application/json');
|
|
if (captured.method === 'DELETE' && captured.url === '/api/projects/project-1/files/nested%2Findex.html') {
|
|
res.end(JSON.stringify(deleteResponse));
|
|
return;
|
|
}
|
|
if (captured.method === 'DELETE' && captured.url === '/api/projects/project-1') {
|
|
res.end(JSON.stringify(deleteResponse));
|
|
return;
|
|
}
|
|
if (captured.method === 'POST' && captured.url === '/api/projects') {
|
|
res.statusCode = 201;
|
|
res.end(JSON.stringify({ project: { id: 'created-project' } }));
|
|
return;
|
|
}
|
|
if (captured.method === 'POST' && captured.url === '/api/projects/source-project/design-system-copy') {
|
|
res.statusCode = 201;
|
|
res.end(JSON.stringify({
|
|
project: { id: 'design-copy-1', name: 'Design Copy' },
|
|
designSystemId: 'user:design-copy-1',
|
|
conversationId: 'conversation-design-copy',
|
|
}));
|
|
return;
|
|
}
|
|
if (captured.method === 'POST' && captured.url === '/api/projects/source-project/duplicate') {
|
|
res.statusCode = 201;
|
|
res.end(JSON.stringify({
|
|
project: { id: 'duplicate-1', name: 'Duplicate Copy' },
|
|
conversationId: 'conversation-duplicate',
|
|
}));
|
|
return;
|
|
}
|
|
if (captured.method === 'POST' && captured.url === '/api/public-file-stops/retry') {
|
|
res.end(JSON.stringify({ ...JSON.parse(raw), status: 'stopped' }));
|
|
return;
|
|
}
|
|
if (captured.method === 'GET' && captured.url === '/api/projects/project-1/share-state') {
|
|
res.end(JSON.stringify({ projectId: 'project-1', bindingExists: false, hasEverShared: false, publications: [] }));
|
|
return;
|
|
}
|
|
if (captured.method === 'GET' && captured.url === '/api/projects/project-1') {
|
|
res.statusCode = 200;
|
|
res.end(JSON.stringify({
|
|
project: { id: 'project-1', name: 'Project One', workspaceId: 'ws-1' },
|
|
resolvedDir: '/tmp/projects/project-1',
|
|
}));
|
|
return;
|
|
}
|
|
if (captured.method === 'GET' && captured.url === '/api/projects/project-1/files') {
|
|
res.statusCode = 200;
|
|
res.end(JSON.stringify({ files: [] }));
|
|
return;
|
|
}
|
|
if (
|
|
captured.method === 'DELETE'
|
|
&& captured.url === '/api/projects/project-1/files/nested%2Findex.html/publish-public'
|
|
) {
|
|
res.statusCode = 200;
|
|
res.end(JSON.stringify({
|
|
ok: true,
|
|
slug: 'legacy-public-slug',
|
|
fileName: 'nested/index.html',
|
|
}));
|
|
return;
|
|
}
|
|
if (captured.method === 'POST' && captured.url === '/api/projects/project-1/files/nested%2Findex.html/share-plan') {
|
|
res.statusCode = 200;
|
|
res.end(JSON.stringify(planResponse ?? { fileCount: 1, totalBytes: 12, exceedsSizeLimit: false, exclusions: [{ path: 'assets/missing.png', reason: 'missing' }] }));
|
|
return;
|
|
}
|
|
if (['POST', 'GET'].includes(captured.method)
|
|
&& captured.url === '/api/projects/project-1/files/nested%2Findex.html/publish-public') {
|
|
if (shareResponse !== undefined) {
|
|
res.statusCode = shareStatus;
|
|
res.end(typeof shareResponse === 'string' ? shareResponse : JSON.stringify(shareResponse));
|
|
return;
|
|
}
|
|
const publication = { url: 'https://example.invalid/returned-link', slug: 'returned-slug', fileName: 'nested/index.html' };
|
|
res.end(JSON.stringify(captured.method === 'GET' ? { publication } : publication));
|
|
return;
|
|
}
|
|
if (captured.method === 'GET' && captured.url === '/api/workspaces/ws-1/projects?view=team') {
|
|
res.statusCode = 200;
|
|
res.end(JSON.stringify({
|
|
projects: [
|
|
{ id: 'project-1', name: 'Project One', visibility: 'team', resourceState: 'active' },
|
|
],
|
|
}));
|
|
return;
|
|
}
|
|
if (captured.method === 'GET' && captured.url === '/api/workspaces/ws-1/projects') {
|
|
res.statusCode = 200;
|
|
res.end(JSON.stringify({
|
|
projects: [
|
|
{ id: 'project-1', name: 'Project One', skillId: null },
|
|
],
|
|
}));
|
|
return;
|
|
}
|
|
if (captured.method === 'POST' && captured.url === '/api/workspace/invite') {
|
|
res.statusCode = 200;
|
|
res.end(JSON.stringify({
|
|
results: [{ email: 'teammate@example.com', ok: true, inviteId: 'invite-1' }],
|
|
}));
|
|
return;
|
|
}
|
|
if (captured.method === 'GET' && captured.url === '/api/workspace/projects/team') {
|
|
res.statusCode = 200;
|
|
res.end(JSON.stringify({
|
|
projects: [{ projectId: 'team-project-1', displayName: 'Team Project' }],
|
|
}));
|
|
return;
|
|
}
|
|
if (captured.method === 'GET' && captured.url === '/api/workspace/members') {
|
|
res.statusCode = 200;
|
|
res.end(JSON.stringify({
|
|
members: [{ memberId: 'member-1', displayName: 'Member One', role: 'admin' }],
|
|
}));
|
|
return;
|
|
}
|
|
if (captured.method === 'GET' && captured.url === '/api/workspace/skills/team') {
|
|
res.statusCode = 200;
|
|
res.end(JSON.stringify({ ids: ['team-skill'], resources: [{ id: 'team-skill' }] }));
|
|
return;
|
|
}
|
|
if (captured.method === 'POST' && captured.url === '/api/workspaces/ws-1/projects/batch-delete') {
|
|
res.statusCode = 200;
|
|
res.end(JSON.stringify({ ok: true, deletedProjectIds: ['project-1', 'project-2'] }));
|
|
return;
|
|
}
|
|
// Workspace directory used by `od project list` to auto-resolve the
|
|
// signed-in workspace when no explicit --workspace/--workspace-member
|
|
// is supplied (#6679). Mirrors the personal workspace shape returned
|
|
// by the real daemon GET /api/workspace/directory.
|
|
if (captured.method === 'GET' && captured.url === '/api/workspace/directory') {
|
|
res.statusCode = 200;
|
|
res.end(JSON.stringify({
|
|
items: [
|
|
{
|
|
workspaceId: 'ws-personal',
|
|
workspaceName: 'Personal',
|
|
workspaceType: 'personal',
|
|
workspaceMemberId: 'mem-personal',
|
|
role: 'owner',
|
|
memberStatus: 'active',
|
|
lifecycleState: 'active',
|
|
},
|
|
],
|
|
activeWorkspaceId: null,
|
|
}));
|
|
return;
|
|
}
|
|
if (captured.method === 'GET' && captured.url === '/api/workspaces/ws-personal/projects') {
|
|
res.statusCode = 200;
|
|
res.end(JSON.stringify({
|
|
projects: [
|
|
{ id: 'bound-project-1', name: 'Bound Project One', skillId: 'skill-1' },
|
|
{ id: 'bound-project-2', name: 'Bound Project Two', skillId: 'skill-2' },
|
|
],
|
|
}));
|
|
return;
|
|
}
|
|
// An unbound project catalog (no signed-in workspace / non-vela).
|
|
if (captured.method === 'GET' && captured.url === '/api/projects') {
|
|
res.statusCode = 200;
|
|
res.end(JSON.stringify({ projects: [] }));
|
|
return;
|
|
}
|
|
|
|
res.statusCode = 404;
|
|
res.end(JSON.stringify({ error: { code: 'unexpected-request', message: captured.url } }));
|
|
});
|
|
});
|
|
|
|
await new Promise<void>((resolveListen) => server.listen(0, '127.0.0.1', resolveListen));
|
|
const addr = server.address();
|
|
if (!addr || typeof addr === 'string') throw new Error('stub server has no address');
|
|
return {
|
|
baseUrl: `http://127.0.0.1:${addr.port}`,
|
|
requests,
|
|
close: () =>
|
|
new Promise<void>((resolveClose, rejectClose) => {
|
|
server.close((err) => (err ? rejectClose(err) : resolveClose()));
|
|
}),
|
|
};
|
|
}
|
|
|
|
async function runCli(args: string[]): Promise<{ stdout: string; stderr: string; code: number | null }> {
|
|
const env: NodeJS.ProcessEnv = { ...process.env };
|
|
delete env.NODE_OPTIONS;
|
|
try {
|
|
const { stdout, stderr } = await execFileP(process.execPath, [TSX_CLI, CLI_SRC, ...args], {
|
|
cwd: DAEMON_ROOT,
|
|
env,
|
|
timeout: 15_000,
|
|
maxBuffer: 4 * 1024 * 1024,
|
|
});
|
|
return { stdout, stderr, code: 0 };
|
|
} catch (err) {
|
|
const failed = err as { stdout?: string; stderr?: string; code?: number | null };
|
|
return {
|
|
stdout: failed.stdout ?? '',
|
|
stderr: failed.stderr ?? '',
|
|
code: failed.code ?? 1,
|
|
};
|
|
}
|
|
}
|
|
|
|
describe('od project CLI', () => {
|
|
describe.each([false, true])('share error classification, json=%s', json => {
|
|
const args = ['project', 'share', 'publish', 'project-1', '--path', 'nested/index.html', ...(json ? ['--json'] : [])];
|
|
|
|
it.each(['flat', 'nested'])('preserves numeric size metadata from a real HTTP 413 (%s)', async shape => {
|
|
const data = { bytes: 51_000_000, limit: 50_000_000 };
|
|
const body = shape === 'flat'
|
|
? { error: 'too_large', ...data, plan: { internal: 'do-not-print' } }
|
|
: { error: { code: 'too_large', message: 'Share exceeds size limit.', data }, plan: { internal: 'do-not-print' } };
|
|
stub = await startProjectStubServer(undefined, body, 413);
|
|
const result = await runCli([...args, '--daemon-url', stub.baseUrl]);
|
|
expect(result.code).toBe(1);
|
|
expect(result.stdout).toBe('');
|
|
expect(JSON.parse(result.stderr).error).toMatchObject({ code: 'too_large', data });
|
|
expect(result.stderr).not.toContain('do-not-print');
|
|
expect(stub.requests).toHaveLength(1);
|
|
});
|
|
|
|
it.each([
|
|
{ error: 'Internal human explanation', plan: { internal: 'do-not-print' } },
|
|
{ error: 'Internal human explanation', message: 'Not a machine code', plan: { internal: 'do-not-print' } },
|
|
'<html>do-not-print</html>',
|
|
])('does not classify an unknown HTTP 500 as a connection failure (%j)', async body => {
|
|
stub = await startProjectStubServer(undefined, body, 500);
|
|
const result = await runCli([...args, '--daemon-url', stub.baseUrl]);
|
|
expect(result.code).toBe(1);
|
|
expect(result.stdout).toBe('');
|
|
expect(JSON.parse(result.stderr)).toEqual({ error: { code: 'http-error', message: 'HTTP 500', data: {} } });
|
|
});
|
|
|
|
it.each(['flat', 'nested'])('exposes only safe publication failure detail (%s)', async shape => {
|
|
const failure = { stage: 'snapshot', reason: 'session_cleanup' };
|
|
const error = { code: 'PUBLIC_FILE_PUBLISH_UNAVAILABLE', message: 'Share operation failed.' };
|
|
const body = { error: shape === 'flat' ? error.code : error,
|
|
failure: { ...failure, upstreamStatus: 999, upstreamCode: 'private body', stderr: 'private-output' },
|
|
secret: 'private-key' };
|
|
stub = await startProjectStubServer(undefined, body, 502);
|
|
const result = await runCli([...args, '--daemon-url', stub.baseUrl]);
|
|
expect(result.code).toBe(1);
|
|
expect(JSON.parse(result.stderr).error).toEqual({ ...error, data: { failure } });
|
|
expect(result.stderr).not.toContain('private');
|
|
expect(stub.requests).toHaveLength(1);
|
|
});
|
|
|
|
it('keeps actual connection failures classified as daemon-not-running', async () => {
|
|
const closed = await startProjectStubServer();
|
|
await closed.close();
|
|
const result = await runCli([...args, '--daemon-url', closed.baseUrl]);
|
|
expect(result.code).toBe(64);
|
|
expect(result.stdout).toBe('');
|
|
expect(JSON.parse(result.stderr).error.code).toBe('daemon-not-running');
|
|
});
|
|
|
|
it('preserves the existing oversized preflight success exit without blockers', async () => {
|
|
const plan = { fileCount: 1, totalBytes: 51_000_000, exceedsSizeLimit: true, exclusions: [] };
|
|
stub = await startProjectStubServer(undefined, undefined, 200, plan);
|
|
const result = await runCli(['project', 'share', 'preflight', 'project-1', '--path', 'nested/index.html', '--daemon-url', stub.baseUrl, ...(json ? ['--json'] : [])]);
|
|
expect(result.code).toBe(0);
|
|
expect(result.stderr).toBe('');
|
|
if (json) expect(JSON.parse(result.stdout)).toEqual(plan);
|
|
else expect(result.stdout).toContain('Share plan: 1 files, 51000000 bytes.');
|
|
expect(stub.requests).toHaveLength(1);
|
|
});
|
|
});
|
|
it.each([false, true])('resume reports missing workspace rather than an unavailable daemon; nested=%s', async nested => {
|
|
const code = 'WORKSPACE_CONTEXT_REQUIRED';
|
|
const message = 'an explicit workspace context is required';
|
|
stub = await startProjectStubServer(undefined, nested ? { error: { code, message } } : { error: code, message }, 400);
|
|
const result = await runCli(['project', 'share', 'resume', 'project-1', '--path', 'nested/index.html', '--daemon-url', stub.baseUrl, '--json']);
|
|
expect(result.code).toBe(1);
|
|
expect(result.stdout).toBe('');
|
|
expect(JSON.parse(result.stderr)).toEqual({ error: { code, message, data: {} } });
|
|
expect(stub.requests).toHaveLength(1);
|
|
expect(stub.requests[0]!.headers['x-od-workspace-id']).toBeUndefined();
|
|
});
|
|
it.each([false, true])('files delete preserves the HTTP response or human output, json=%s', async json => {
|
|
const body = { ok: true };
|
|
stub = await startProjectStubServer(body);
|
|
const result = await runCli(['files', 'delete', 'project-1', 'nested/index.html', '--workspace', 'ws-1', '--workspace-member', 'member-1', '--daemon-url', stub.baseUrl, ...(json ? ['--json'] : [])]);
|
|
expect(result.code).toBe(0);
|
|
expect(result.stderr).toBe('');
|
|
if (json) expect(JSON.parse(result.stdout)).toEqual(body);
|
|
else expect(result.stdout).toBe('[files] deleted nested/index.html\n');
|
|
expect(stub.requests).toHaveLength(1);
|
|
expect(stub.requests[0]).toMatchObject({ method: 'DELETE', url: '/api/projects/project-1/files/nested%2Findex.html', headers: { 'x-od-workspace-id': 'ws-1', 'x-od-workspace-member-id': 'member-1' } });
|
|
});
|
|
it.each([false, true])('delete reports per-file residuals without extra requests, json=%s', async json => {
|
|
const body = { ok: true, shareResiduals: [
|
|
{ filePath: 'a\n.html', slug: 'a', retrying: true },
|
|
{ filePath: 'b.html', slug: 'b', retrying: false, code: 'STOP_EXHAUSTED' },
|
|
] };
|
|
stub = await startProjectStubServer(body);
|
|
const result = await runCli(['project', 'delete', 'project-1', '--workspace', 'ws-1', '--workspace-member', 'member-1', '--daemon-url', stub.baseUrl, ...(json ? ['--json'] : [])]);
|
|
expect(result.code).toBe(0);
|
|
if (json) { expect(JSON.parse(result.stdout)).toEqual(body); expect(result.stderr).toBe(''); }
|
|
else {
|
|
expect(result.stdout).toBe('[project] deleted project-1\n');
|
|
expect(result.stderr).toBe('[project] warning: public links may still be live:\n "a\\n.html" ("a"): stop queued for retry\n "b.html" ("b"): action required; no automatic retry\n');
|
|
}
|
|
expect(stub.requests).toHaveLength(1);
|
|
expect(stub.requests[0]).toMatchObject({ method: 'DELETE', url: '/api/projects/project-1', headers: { 'x-od-workspace-id': 'ws-1', 'x-od-workspace-member-id': 'member-1' } });
|
|
});
|
|
it.each([false, true])('delete reports unchanged no-residual success, json=%s', async json => {
|
|
stub = await startProjectStubServer();
|
|
const result = await runCli(['project', 'delete', 'project-1', '--workspace', 'ws-1', '--workspace-member', 'member-1', '--daemon-url', stub.baseUrl, ...(json ? ['--json'] : [])]);
|
|
expect(result.code).toBe(0); expect(result.stderr).toBe('');
|
|
if (json) expect(JSON.parse(result.stdout)).toEqual({ ok: true });
|
|
else expect(result.stdout).toBe('[project] deleted project-1\n');
|
|
expect(stub.requests).toHaveLength(1);
|
|
});
|
|
|
|
it.each(['publish', 'resume', 'get'])('%s reports no-link success without printing undefined or declaring failure', async action => {
|
|
const body = { status: action !== 'get' ? 'published' : 'active', link: { status: 'unavailable', code: 'PUBLIC_SHARE_WEB_URL_UNAVAILABLE' }, ...(action === 'get' ? { publication: null, slug: 'stable', freshness: 'unknown' } : { receipt: { slug: 'stable', filePath: 'nested/index.html', versionId: 'v1', version: 1, publishedAt: 1, entryPath: 'index.html' } }) };
|
|
stub = await startProjectStubServer(undefined, body);
|
|
const args = ['project', 'share', action, 'project-1', '--path', 'nested/index.html', '--daemon-url', stub.baseUrl];
|
|
const result = await runCli(args);
|
|
expect(result.code).toBe(0); expect(result.stdout.trim()).toBe('Published; link temporarily unavailable (slug stable).');
|
|
expect(result.stdout).not.toContain('undefined');
|
|
const json = await runCli([...args, '--json']);
|
|
expect(json.code).toBe(0); expect(JSON.parse(json.stdout)).toEqual(body);
|
|
});
|
|
it('publish does not announce a project visibility change; JSON remains raw daemon output', async () => {
|
|
const body = { status: 'published', url: 'https://viewer.example.test/s', madeTeamVisible: true, receipt: { slug: 'stable', filePath: 'nested/index.html', versionId: 'v1', version: 1, publishedAt: 1, entryPath: 'index.html' } };
|
|
stub = await startProjectStubServer(undefined, body);
|
|
const args = ['project', 'share', 'publish', 'project-1', '--path', 'nested/index.html', '--daemon-url', stub.baseUrl];
|
|
const human = await runCli(args);
|
|
expect(human.code).toBe(0);
|
|
expect(human.stdout.trim()).toBe('https://viewer.example.test/s');
|
|
expect(human.stderr).toBe('');
|
|
const json = await runCli([...args, '--json']);
|
|
expect(json.code).toBe(0); expect(json.stderr).toBe('');
|
|
expect(JSON.parse(json.stdout)).toMatchObject({ madeTeamVisible: true });
|
|
const help = await runCli(['project', 'share', '--help']);
|
|
expect(help.stdout).toContain('Publishing does not change project team visibility.');
|
|
expect(help.stdout).not.toContain('madeTeamVisible');
|
|
});
|
|
it('retry-stop targets the persisted file intent, never the deleted project DELETE route', async () => {
|
|
stub = await startProjectStubServer();
|
|
const result = await runCli(['project', 'share', 'retry-stop', 'deleted-project', '--path', 'pages/local.html', '--slug', 'stable', '--workspace', 'ws-1', '--workspace-member', 'member-1', '--daemon-url', stub.baseUrl, '--json']);
|
|
expect(result.code).toBe(0);
|
|
expect(JSON.parse(result.stdout)).toEqual({ projectId: 'deleted-project', filePath: 'pages/local.html', slug: 'stable', status: 'stopped' });
|
|
expect(stub.requests).toHaveLength(1);
|
|
expect(stub.requests[0]).toMatchObject({ method: 'POST', url: '/api/public-file-stops/retry', headers: { 'x-od-workspace-id': 'ws-1', 'x-od-workspace-member-id': 'member-1' }, body: JSON.stringify({ projectId: 'deleted-project', filePath: 'pages/local.html', slug: 'stable' }) });
|
|
});
|
|
it('share status without path reads project binding history through the real CLI process', async () => {
|
|
stub = await startProjectStubServer();
|
|
const result = await runCli(['project', 'share', 'status', 'project-1', '--workspace', 'ws-1', '--workspace-member', 'member-1', '--daemon-url', stub.baseUrl, '--json']);
|
|
expect(result.code).toBe(0);
|
|
expect(JSON.parse(result.stdout)).toEqual({ projectId: 'project-1', bindingExists: false, hasEverShared: false, publications: [] });
|
|
expect(stub.requests).toHaveLength(1);
|
|
expect(stub.requests[0]).toMatchObject({ method: 'GET', url: '/api/projects/project-1/share-state', headers: { 'x-od-workspace-id': 'ws-1', 'x-od-workspace-member-id': 'member-1' } });
|
|
});
|
|
it('share preflight uses the same HTTP plan endpoint and returns its JSON DTO', async () => {
|
|
stub = await startProjectStubServer();
|
|
const result = await runCli(['project', 'share', 'preflight', 'project-1', '--path', 'nested/index.html', '--workspace', 'ws-1', '--workspace-member', 'member-1', '--daemon-url', stub.baseUrl, '--json']);
|
|
expect(result.code).toBe(0);
|
|
expect(result.stderr).toBe('');
|
|
expect(JSON.parse(result.stdout)).toEqual({ fileCount: 1, totalBytes: 12, exceedsSizeLimit: false, exclusions: [{ path: 'assets/missing.png', reason: 'missing' }] });
|
|
expect(stub.requests).toHaveLength(1);
|
|
expect(stub.requests[0]).toMatchObject({ method: 'POST', url: '/api/projects/project-1/files/nested%2Findex.html/share-plan', headers: { 'x-od-workspace-id': 'ws-1', 'x-od-workspace-member-id': 'member-1' }, body: '' });
|
|
});
|
|
const conflictBlocker = {
|
|
code: 'entry-index-conflict', path: 'index.html', entryPath: 'nested/index.html', referencedFrom: 'nested/index.html', suggestedName: 'home.html',
|
|
referrers: [{ file: 'nested/index.html', reference: '../index.html', attribute: '<iframe src>', line: 4, replacement: '../home.html' }],
|
|
agentPrompt: 'Open Design cannot share "nested/index.html" yet.\n1. Rename the project\'s root file "index.html" to "home.html".',
|
|
};
|
|
const blockedPlan = { fileCount: 1, totalBytes: 12, exceedsSizeLimit: false, exclusions: [], blockers: [conflictBlocker] };
|
|
it('share preflight prints blockers with the agent prompt and exits non-zero', async () => {
|
|
stub = await startProjectStubServer(undefined, undefined, 200, blockedPlan);
|
|
const args = ['project', 'share', 'preflight', 'project-1', '--path', 'nested/index.html', '--workspace', 'ws-1', '--workspace-member', 'member-1', '--daemon-url', stub.baseUrl];
|
|
const text = await runCli(args);
|
|
expect(text.code).toBe(1);
|
|
expect(text.stdout).toContain('Blocked (entry-index-conflict): nested/index.html references index.html');
|
|
expect(text.stdout).toContain(conflictBlocker.agentPrompt);
|
|
expect(text.stdout).not.toContain('No missing or invalid references.');
|
|
const json = await runCli([...args, '--json']);
|
|
expect(json.code).toBe(1);
|
|
expect(JSON.parse(json.stdout)).toEqual(blockedPlan);
|
|
});
|
|
it('share publish prints the agent prompt for an index.html conflict', async () => {
|
|
const { code: _code, ...data } = conflictBlocker;
|
|
stub = await startProjectStubServer(undefined, { error: { code: 'SHARE_ENTRY_INDEX_CONFLICT', message: 'conflict message', data }, plan: blockedPlan }, 409);
|
|
const args = ['project', 'share', 'publish', 'project-1', '--path', 'nested/index.html', '--workspace', 'ws-1', '--workspace-member', 'member-1', '--daemon-url', stub.baseUrl];
|
|
const text = await runCli(args);
|
|
expect(text.code).not.toBe(0);
|
|
expect(text.stderr).toContain('conflict message');
|
|
expect(text.stderr).toContain(conflictBlocker.agentPrompt);
|
|
const json = await runCli([...args, '--json']);
|
|
expect(json.code).not.toBe(0);
|
|
const envelope = JSON.parse(json.stderr.trim().split('\n').at(-1)!);
|
|
expect(envelope.error).toMatchObject({ code: 'SHARE_ENTRY_INDEX_CONFLICT', message: 'conflict message', data: { agentPrompt: conflictBlocker.agentPrompt, suggestedName: 'home.html' } });
|
|
});
|
|
it.each(['publish', 'resume', 'get', 'status', 'stop'])('share %s uses the existing file endpoint and emits raw JSON', async action => {
|
|
stub = await startProjectStubServer();
|
|
const result = await runCli([
|
|
'project', 'share', action, 'project-1', '--path', 'nested/index.html',
|
|
'--workspace', 'ws-1', '--workspace-member', 'member-1',
|
|
'--daemon-url', stub.baseUrl, '--json',
|
|
...(action === 'stop' ? ['--slug', 'legacy-public-slug'] : []),
|
|
]);
|
|
expect(result.code).toBe(0);
|
|
expect(result.stderr).toBe('');
|
|
const publication = { url: 'https://example.invalid/returned-link', slug: 'returned-slug', fileName: 'nested/index.html' };
|
|
expect(JSON.parse(result.stdout)).toEqual(action === 'stop'
|
|
? { ok: true, slug: 'legacy-public-slug', fileName: 'nested/index.html' }
|
|
: ['publish', 'resume'].includes(action) ? publication : { publication });
|
|
expect(stub.requests).toHaveLength(1);
|
|
expect(stub.requests[0]).toMatchObject({
|
|
method: action === 'stop' ? 'DELETE' : ['publish', 'resume'].includes(action) ? 'POST' : 'GET',
|
|
url: '/api/projects/project-1/files/nested%2Findex.html/publish-public',
|
|
headers: { 'x-od-workspace-id': 'ws-1', 'x-od-workspace-member-id': 'member-1' },
|
|
body: action === 'stop' ? JSON.stringify({ slug: 'legacy-public-slug' })
|
|
: action === 'resume' ? JSON.stringify({ mode: 'resume' }) : '',
|
|
});
|
|
});
|
|
it('resume is documented and requires a file before making requests', async () => {
|
|
stub = await startProjectStubServer();
|
|
const help = await runCli(['project', 'share', '--help']);
|
|
expect(help.stdout).toContain('od project share resume <id> --path <file> [--json]');
|
|
expect(help.stdout).toContain('od project share preflight <id> --path <file> [--json]');
|
|
const invalid = await runCli(['project', 'share', 'resume', 'project-1', '--daemon-url', stub.baseUrl, '--json']);
|
|
expect(invalid.code).toBe(2);
|
|
expect(stub.requests).toHaveLength(0);
|
|
});
|
|
it('resume preserves binding_pending rather than announcing publication success', async () => {
|
|
const body = { status: 'binding_pending', receipt: { slug: 'stable', filePath: 'nested/index.html', versionId: 'v1', version: 1, publishedAt: 1, entryPath: 'index.html' }, binding: { retrying: true } };
|
|
stub = await startProjectStubServer(undefined, body);
|
|
const args = ['project', 'share', 'resume', 'project-1', '--path', 'nested/index.html', '--daemon-url', stub.baseUrl];
|
|
const json = await runCli([...args, '--json']);
|
|
expect(json.code).toBe(0); expect(JSON.parse(json.stdout)).toEqual(body);
|
|
const human = await runCli(args);
|
|
expect(human.code).toBe(0); expect(human.stdout).toContain('binding pending');
|
|
});
|
|
it('documents exact workspace identity for bound project and file commands', async () => {
|
|
const projectHelp = await runCli(['project', 'help']);
|
|
const filesHelp = await runCli(['files', 'help']);
|
|
|
|
expect(projectHelp.code).toBe(0);
|
|
expect(filesHelp.code).toBe(0);
|
|
expect(projectHelp.stdout).toContain('--workspace <id>');
|
|
expect(projectHelp.stdout).toContain('--workspace-member <id>');
|
|
expect(filesHelp.stdout).toContain('--workspace <id>');
|
|
expect(filesHelp.stdout).toContain('--workspace-member <id>');
|
|
});
|
|
|
|
it.each([
|
|
['project detail', ['project', 'info', 'project-1', '--json']],
|
|
['project files', ['files', 'list', 'project-1', '--json']],
|
|
])('sends exact workspace identity for bound %s', async (_label, command) => {
|
|
stub = await startProjectStubServer();
|
|
|
|
const result = await runCli([
|
|
...command,
|
|
'--workspace',
|
|
'ws-1',
|
|
'--workspace-member',
|
|
'member-1',
|
|
'--daemon-url',
|
|
stub.baseUrl,
|
|
]);
|
|
|
|
expect(result.code).toBe(0);
|
|
expect(result.stderr).toBe('');
|
|
expect(stub.requests).toHaveLength(1);
|
|
expect(stub.requests[0]!.headers).toMatchObject({
|
|
'x-od-workspace-id': 'ws-1',
|
|
'x-od-workspace-member-id': 'member-1',
|
|
});
|
|
});
|
|
|
|
it('revokes a legacy public link by extracting its snapshot slug', async () => {
|
|
stub = await startProjectStubServer();
|
|
|
|
const result = await runCli([
|
|
'project',
|
|
'revoke-public-link',
|
|
'project-1',
|
|
'--path',
|
|
'nested/index.html',
|
|
'--url',
|
|
'https://hub.example.test/api/v1/public/snapshots/legacy-public-slug/files/nested/index.html',
|
|
'--workspace',
|
|
'ws-1',
|
|
'--workspace-member',
|
|
'member-1',
|
|
'--daemon-url',
|
|
stub.baseUrl,
|
|
'--json',
|
|
]);
|
|
|
|
expect(result.code).toBe(0);
|
|
expect(result.stderr).toBe('');
|
|
expect(JSON.parse(result.stdout)).toMatchObject({
|
|
ok: true,
|
|
slug: 'legacy-public-slug',
|
|
});
|
|
expect(stub.requests).toHaveLength(1);
|
|
expect(stub.requests[0]).toMatchObject({
|
|
method: 'DELETE',
|
|
url: '/api/projects/project-1/files/nested%2Findex.html/publish-public',
|
|
headers: {
|
|
'x-od-workspace-id': 'ws-1',
|
|
'x-od-workspace-member-id': 'member-1',
|
|
},
|
|
body: JSON.stringify({ slug: 'legacy-public-slug' }),
|
|
});
|
|
});
|
|
|
|
it('creates a motion design task with the same automatic OD Next route as Home', async () => {
|
|
stub = await startProjectStubServer();
|
|
tempRoot = mkdtempSync(join(tmpdir(), 'od-motion-cli-'));
|
|
const promptPath = join(tempRoot, 'prompt.md');
|
|
writeFileSync(promptPath, 'Create a 12-second motion film.\n', 'utf8');
|
|
const result = await runCli([
|
|
'project', 'create', '--task-type', 'motion-design',
|
|
'--prompt-file', promptPath, '--json', '--daemon-url', stub.baseUrl,
|
|
]);
|
|
expect(result.code).toBe(0);
|
|
const request = stub.requests.find((item) => item.method === 'POST' && item.url === '/api/projects');
|
|
expect(request).toBeDefined();
|
|
expect(JSON.parse(request!.body)).toMatchObject({
|
|
conversationMode: 'design',
|
|
automaticStrategyTaskProfile: 'motion-design',
|
|
metadata: { kind: 'video', intent: 'motion-design', videoModel: 'hyperframes-html' },
|
|
pendingPrompt: 'Create a 12-second motion film.\n',
|
|
});
|
|
expect(JSON.parse(request!.body).pluginId).toBeUndefined();
|
|
});
|
|
|
|
it.each([['--plugin', 'example-hyperframes'], ['--mode', 'chat']])(
|
|
'rejects an incompatible motion route option %s', async (flag, value) => {
|
|
stub = await startProjectStubServer();
|
|
const result = await runCli(['project', 'create', '--task-type', 'motion-design', flag, value, '--daemon-url', stub.baseUrl]);
|
|
expect(result.code).toBe(2);
|
|
expect(stub.requests.some((item) => item.method === 'POST')).toBe(false);
|
|
},
|
|
);
|
|
|
|
it('creates a design-system project with prompt-file content and JSON output', async () => {
|
|
stub = await startProjectStubServer();
|
|
tempRoot = mkdtempSync(join(tmpdir(), 'od-project-cli-'));
|
|
const promptPath = join(tempRoot, 'prompt.md');
|
|
writeFileSync(promptPath, 'Use this workspace as the brand source.\n', 'utf8');
|
|
|
|
const result = await runCli([
|
|
'project',
|
|
'create-design-system',
|
|
'source-project',
|
|
'--name',
|
|
'Design Copy',
|
|
'--prompt-file',
|
|
promptPath,
|
|
'--json',
|
|
'--daemon-url',
|
|
stub.baseUrl,
|
|
]);
|
|
|
|
expect(result.code).toBe(0);
|
|
expect(result.stderr).toBe('');
|
|
expect(JSON.parse(result.stdout)).toEqual({
|
|
project: { id: 'design-copy-1', name: 'Design Copy' },
|
|
designSystemId: 'user:design-copy-1',
|
|
conversationId: 'conversation-design-copy',
|
|
});
|
|
expect(stub.requests).toHaveLength(1);
|
|
expect(stub.requests[0]).toMatchObject({
|
|
method: 'POST',
|
|
url: '/api/projects/source-project/design-system-copy',
|
|
});
|
|
expect(JSON.parse(stub.requests[0]!.body)).toEqual({
|
|
name: 'Design Copy',
|
|
pendingPrompt: 'Use this workspace as the brand source.\n',
|
|
});
|
|
});
|
|
|
|
it('duplicates a project and prints the human-readable result', async () => {
|
|
stub = await startProjectStubServer();
|
|
|
|
const result = await runCli([
|
|
'project',
|
|
'duplicate',
|
|
'source-project',
|
|
'--name',
|
|
'Duplicate Copy',
|
|
'--daemon-url',
|
|
stub.baseUrl,
|
|
]);
|
|
|
|
expect(result.code).toBe(0);
|
|
expect(result.stderr).toBe('');
|
|
expect(result.stdout).toBe(
|
|
'[project] duplicated source-project as duplicate-1 (conversation conversation-duplicate)\n',
|
|
);
|
|
expect(stub.requests).toHaveLength(1);
|
|
expect(stub.requests[0]).toMatchObject({
|
|
method: 'POST',
|
|
url: '/api/projects/source-project/duplicate',
|
|
});
|
|
expect(JSON.parse(stub.requests[0]!.body)).toEqual({ name: 'Duplicate Copy' });
|
|
});
|
|
|
|
it('lists workspace projects through the workspace-scoped API', async () => {
|
|
stub = await startProjectStubServer();
|
|
|
|
const result = await runCli([
|
|
'workspace',
|
|
'projects',
|
|
'list',
|
|
'--workspace',
|
|
'ws-1',
|
|
'--member',
|
|
'member-1',
|
|
'--role',
|
|
'admin',
|
|
'--view',
|
|
'team',
|
|
'--json',
|
|
'--daemon-url',
|
|
stub.baseUrl,
|
|
]);
|
|
|
|
expect(result.code).toBe(0);
|
|
expect(result.stderr).toBe('');
|
|
expect(JSON.parse(result.stdout)).toMatchObject({
|
|
projects: [{ id: 'project-1', visibility: 'team' }],
|
|
});
|
|
expect(stub.requests).toHaveLength(1);
|
|
expect(stub.requests[0]).toMatchObject({
|
|
method: 'GET',
|
|
url: '/api/workspaces/ws-1/projects?view=team',
|
|
});
|
|
expect(stub.requests[0]!.headers).toMatchObject({
|
|
'x-od-workspace-id': 'ws-1',
|
|
'x-od-workspace-member-id': 'member-1',
|
|
'x-od-workspace-role': 'admin',
|
|
});
|
|
});
|
|
|
|
it('od project list without --workspace resolves the signed-in workspace automatically (#6679)', async () => {
|
|
stub = await startProjectStubServer();
|
|
|
|
const result = await runCli(['project', 'list', '--json', '--daemon-url', stub.baseUrl]);
|
|
|
|
expect(result.code).toBe(0);
|
|
expect(result.stderr).toBe('');
|
|
const data = JSON.parse(result.stdout);
|
|
expect(data.projects).toEqual([
|
|
{ id: 'bound-project-1', name: 'Bound Project One', skillId: 'skill-1' },
|
|
{ id: 'bound-project-2', name: 'Bound Project Two', skillId: 'skill-2' },
|
|
]);
|
|
// Should hit both the directory resolver and the workspace-scoped catalog.
|
|
expect(stub.requests).toHaveLength(2);
|
|
expect(stub.requests[0]).toMatchObject({
|
|
method: 'GET',
|
|
url: '/api/workspace/directory',
|
|
});
|
|
expect(stub.requests[1]).toMatchObject({
|
|
method: 'GET',
|
|
url: '/api/workspaces/ws-personal/projects',
|
|
});
|
|
expect(stub.requests[1]!.headers).toMatchObject({
|
|
'x-od-workspace-id': 'ws-personal',
|
|
'x-od-workspace-member-id': 'mem-personal',
|
|
});
|
|
});
|
|
|
|
it('od project list with explicit --workspace routes to the workspace-scoped catalog (#6679)', async () => {
|
|
stub = await startProjectStubServer();
|
|
|
|
const result = await runCli([
|
|
'project',
|
|
'list',
|
|
'--workspace',
|
|
'ws-1',
|
|
'--workspace-member',
|
|
'member-1',
|
|
'--json',
|
|
'--daemon-url',
|
|
stub.baseUrl,
|
|
]);
|
|
|
|
expect(result.code).toBe(0);
|
|
expect(result.stderr).toBe('');
|
|
// No workspace/directory call — explicit flags win, but they route to
|
|
// the workspace-scoped catalog (/api/workspaces/:id/projects), not to
|
|
// the unbound /api/projects catalog. Passing --workspace to /api/projects
|
|
// does NOT scope it (#6679 repro), so the explicit path mirrors the
|
|
// implicit signed-in path.
|
|
expect(stub.requests).toHaveLength(1);
|
|
expect(stub.requests[0]).toMatchObject({
|
|
method: 'GET',
|
|
url: '/api/workspaces/ws-1/projects',
|
|
});
|
|
expect(stub.requests[0]!.headers).toMatchObject({
|
|
'x-od-workspace-id': 'ws-1',
|
|
'x-od-workspace-member-id': 'member-1',
|
|
});
|
|
});
|
|
|
|
it('od project list falls back to headerless catalog when no signed-in workspace (#6679)', async () => {
|
|
// Custom stub whose directory endpoint returns empty (signed-out / no vela)
|
|
// so resolveMcpWorkspaceContext returns null and the CLI falls back to the
|
|
// headerless unbound catalog exactly as before the fix.
|
|
const requests: CapturedRequest[] = [];
|
|
const server = http.createServer((req, res) => {
|
|
let raw = '';
|
|
req.on('data', (chunk) => {
|
|
raw += chunk;
|
|
});
|
|
req.on('end', () => {
|
|
const captured: CapturedRequest = {
|
|
method: req.method ?? '',
|
|
url: req.url ?? '',
|
|
headers: req.headers,
|
|
body: raw,
|
|
};
|
|
requests.push(captured);
|
|
res.setHeader('content-type', 'application/json');
|
|
if (captured.method === 'GET' && captured.url === '/api/workspace/directory') {
|
|
res.statusCode = 200;
|
|
res.end(JSON.stringify({ items: [], activeWorkspaceId: null }));
|
|
return;
|
|
}
|
|
if (captured.method === 'GET' && captured.url === '/api/projects') {
|
|
res.statusCode = 200;
|
|
res.end(JSON.stringify({ projects: [{ id: 'unbound-1', name: 'Unbound One' }] }));
|
|
return;
|
|
}
|
|
res.statusCode = 404;
|
|
res.end(JSON.stringify({ error: { code: 'unexpected', message: captured.url } }));
|
|
});
|
|
});
|
|
await new Promise<void>((resolveListen) => server.listen(0, '127.0.0.1', resolveListen));
|
|
const addr = server.address();
|
|
if (!addr || typeof addr === 'string') throw new Error('stub server has no address');
|
|
const fallBaseUrl = `http://127.0.0.1:${addr.port}`;
|
|
const fallStub = { baseUrl: fallBaseUrl, requests };
|
|
// Replace `stub` so afterEach closes the original stub (we just closed its
|
|
// server implicitly via the same afterEach hook on the closure name).
|
|
stub = {
|
|
baseUrl: fallStub.baseUrl,
|
|
requests,
|
|
close: () =>
|
|
new Promise<void>((resolveClose, rejectClose) => {
|
|
server.close((err) => (err ? rejectClose(err) : resolveClose()));
|
|
}),
|
|
};
|
|
|
|
const result = await runCli(['project', 'list', '--json', '--daemon-url', fallStub.baseUrl]);
|
|
|
|
expect(result.code).toBe(0);
|
|
expect(result.stderr).toBe('');
|
|
const data = JSON.parse(result.stdout);
|
|
expect(data.projects).toEqual([{ id: 'unbound-1', name: 'Unbound One' }]);
|
|
// Fallback path fires: directory probed then unbound catalog.
|
|
const dirReq = requests.find((r) => r.method === 'GET' && r.url === '/api/workspace/directory');
|
|
const catalogReq = requests.find((r) => r.method === 'GET' && r.url === '/api/projects');
|
|
expect(dirReq).toBeDefined();
|
|
expect(catalogReq).toBeDefined();
|
|
});
|
|
|
|
it('creates workspace invites through the workspace invite API', async () => {
|
|
stub = await startProjectStubServer();
|
|
|
|
const result = await runCli([
|
|
'workspace',
|
|
'invite',
|
|
'--email',
|
|
'teammate@example.com',
|
|
'--role',
|
|
'member',
|
|
'--workspace',
|
|
'ws-1',
|
|
'--member',
|
|
'member-1',
|
|
'--json',
|
|
'--daemon-url',
|
|
stub.baseUrl,
|
|
]);
|
|
|
|
expect(result.code).toBe(0);
|
|
expect(result.stderr).toBe('');
|
|
expect(JSON.parse(result.stdout)).toEqual({
|
|
results: [{ email: 'teammate@example.com', ok: true, inviteId: 'invite-1' }],
|
|
});
|
|
expect(stub.requests).toHaveLength(1);
|
|
expect(stub.requests[0]).toMatchObject({
|
|
method: 'POST',
|
|
url: '/api/workspace/invite',
|
|
body: JSON.stringify({ email: 'teammate@example.com', role: 'member' }),
|
|
});
|
|
expect(stub.requests[0]!.headers).toMatchObject({
|
|
'x-od-workspace-id': 'ws-1',
|
|
'x-od-workspace-member-id': 'member-1',
|
|
});
|
|
});
|
|
|
|
it('lists team projects through the workspace discovery API', async () => {
|
|
stub = await startProjectStubServer();
|
|
|
|
const result = await runCli([
|
|
'workspace',
|
|
'projects',
|
|
'team',
|
|
'--workspace',
|
|
'ws-1',
|
|
'--member',
|
|
'member-1',
|
|
'--json',
|
|
'--daemon-url',
|
|
stub.baseUrl,
|
|
]);
|
|
|
|
expect(result.code).toBe(0);
|
|
expect(result.stderr).toBe('');
|
|
expect(JSON.parse(result.stdout)).toEqual({
|
|
projects: [{ projectId: 'team-project-1', displayName: 'Team Project' }],
|
|
});
|
|
expect(stub.requests).toHaveLength(1);
|
|
expect(stub.requests[0]).toMatchObject({
|
|
method: 'GET',
|
|
url: '/api/workspace/projects/team',
|
|
});
|
|
expect(stub.requests[0]!.headers).toMatchObject({
|
|
'x-od-workspace-id': 'ws-1',
|
|
'x-od-workspace-member-id': 'member-1',
|
|
});
|
|
});
|
|
|
|
it('lists workspace members through the workspace member directory API', async () => {
|
|
stub = await startProjectStubServer();
|
|
|
|
const result = await runCli([
|
|
'workspace',
|
|
'members',
|
|
'list',
|
|
'--workspace',
|
|
'ws-1',
|
|
'--member',
|
|
'member-1',
|
|
'--json',
|
|
'--daemon-url',
|
|
stub.baseUrl,
|
|
]);
|
|
|
|
expect(result.code).toBe(0);
|
|
expect(result.stderr).toBe('');
|
|
expect(JSON.parse(result.stdout)).toEqual({
|
|
members: [{ memberId: 'member-1', displayName: 'Member One', role: 'admin' }],
|
|
});
|
|
expect(stub.requests).toHaveLength(1);
|
|
expect(stub.requests[0]).toMatchObject({
|
|
method: 'GET',
|
|
url: '/api/workspace/members',
|
|
});
|
|
expect(stub.requests[0]!.headers).toMatchObject({
|
|
'x-od-workspace-id': 'ws-1',
|
|
'x-od-workspace-member-id': 'member-1',
|
|
});
|
|
});
|
|
|
|
it('rejects workspace directory commands without explicit workspace identity', async () => {
|
|
stub = await startProjectStubServer();
|
|
|
|
const result = await runCli([
|
|
'workspace',
|
|
'members',
|
|
'list',
|
|
'--json',
|
|
'--daemon-url',
|
|
stub.baseUrl,
|
|
]);
|
|
|
|
expect(result.code).toBe(1);
|
|
expect(result.stderr).toContain('--workspace <id> and --workspace-member <id>');
|
|
expect(stub.requests).toHaveLength(0);
|
|
});
|
|
|
|
it('sends explicit CLI workspace identity to team resource routes', async () => {
|
|
stub = await startProjectStubServer();
|
|
|
|
const result = await runCli([
|
|
'collab',
|
|
'team-resources',
|
|
'skills',
|
|
'--workspace',
|
|
'ws-1',
|
|
'--workspace-member',
|
|
'member-1',
|
|
'--json',
|
|
'--daemon-url',
|
|
stub.baseUrl,
|
|
]);
|
|
|
|
expect(result.code).toBe(0);
|
|
expect(result.stderr).toBe('');
|
|
expect(JSON.parse(result.stdout)).toEqual({
|
|
ids: ['team-skill'],
|
|
resources: [{ id: 'team-skill' }],
|
|
});
|
|
expect(stub.requests).toHaveLength(1);
|
|
expect(stub.requests[0]).toMatchObject({
|
|
method: 'GET',
|
|
url: '/api/workspace/skills/team',
|
|
});
|
|
expect(stub.requests[0]!.headers).toMatchObject({
|
|
'x-od-workspace-id': 'ws-1',
|
|
'x-od-workspace-member-id': 'member-1',
|
|
});
|
|
});
|
|
|
|
it('sends repeatable project ids for workspace batch delete', async () => {
|
|
stub = await startProjectStubServer();
|
|
|
|
const result = await runCli([
|
|
'workspace',
|
|
'projects',
|
|
'batch-delete',
|
|
'--workspace',
|
|
'ws-1',
|
|
'--member',
|
|
'member-1',
|
|
'--role',
|
|
'admin',
|
|
'--project',
|
|
'project-1',
|
|
'--project',
|
|
'project-2',
|
|
'--json',
|
|
'--daemon-url',
|
|
stub.baseUrl,
|
|
]);
|
|
|
|
expect(result.code).toBe(0);
|
|
expect(result.stderr).toBe('');
|
|
expect(JSON.parse(result.stdout)).toEqual({ ok: true, deletedProjectIds: ['project-1', 'project-2'] });
|
|
expect(stub.requests).toHaveLength(1);
|
|
expect(stub.requests[0]).toMatchObject({
|
|
method: 'POST',
|
|
url: '/api/workspaces/ws-1/projects/batch-delete',
|
|
body: JSON.stringify({ projectIds: ['project-1', 'project-2'] }),
|
|
});
|
|
expect(stub.requests[0]!.headers).toMatchObject({
|
|
'x-od-workspace-id': 'ws-1',
|
|
'x-od-workspace-member-id': 'member-1',
|
|
'x-od-workspace-role': 'admin',
|
|
});
|
|
});
|
|
});
|