@imgly/codesign-mcp 0.1.0 → 0.1.2
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/assets/docs/handbook/SKILL.md +6 -0
- package/dist/assets/inline.generated.js +1 -1
- package/dist/assets/inline.generated.js.map +1 -1
- package/dist/cli/http.d.ts.map +1 -1
- package/dist/cli/http.js +4 -0
- package/dist/cli/http.js.map +1 -1
- package/dist/cli/stdio.d.ts.map +1 -1
- package/dist/cli/stdio.js +55 -18
- package/dist/cli/stdio.js.map +1 -1
- package/dist/engine/cesdk-adapter.d.ts +7 -1
- package/dist/engine/cesdk-adapter.d.ts.map +1 -1
- package/dist/engine/cesdk-adapter.js +7 -1
- package/dist/engine/cesdk-adapter.js.map +1 -1
- package/dist/engine/unsplash-source.d.ts +8 -0
- package/dist/engine/unsplash-source.d.ts.map +1 -0
- package/dist/engine/unsplash-source.js +77 -0
- package/dist/engine/unsplash-source.js.map +1 -0
- package/dist/http/server.d.ts.map +1 -1
- package/dist/http/server.js +0 -1
- package/dist/http/server.js.map +1 -1
- package/dist/server.d.ts +0 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js.map +1 -1
- package/dist/test/test/cli/redirect-console.test.d.ts +2 -0
- package/dist/test/test/cli/redirect-console.test.d.ts.map +1 -0
- package/dist/test/test/cli/redirect-console.test.js +47 -0
- package/dist/test/test/cli/redirect-console.test.js.map +1 -0
- package/dist/test/test/cli/resolve-license.test.d.ts +2 -0
- package/dist/test/test/cli/resolve-license.test.d.ts.map +1 -0
- package/dist/test/test/cli/resolve-license.test.js +20 -0
- package/dist/test/test/cli/resolve-license.test.js.map +1 -0
- package/dist/test/test/cli/stdio-license.test.d.ts +2 -0
- package/dist/test/test/cli/stdio-license.test.d.ts.map +1 -0
- package/dist/test/test/cli/stdio-license.test.js +19 -0
- package/dist/test/test/cli/stdio-license.test.js.map +1 -0
- package/dist/test/test/config.test.d.ts +2 -0
- package/dist/test/test/config.test.d.ts.map +1 -0
- package/dist/test/test/config.test.js +91 -0
- package/dist/test/test/config.test.js.map +1 -0
- package/dist/test/test/engine/asset-sources.test.d.ts +2 -0
- package/dist/test/test/engine/asset-sources.test.d.ts.map +1 -0
- package/dist/test/test/engine/asset-sources.test.js +100 -0
- package/dist/test/test/engine/asset-sources.test.js.map +1 -0
- package/dist/test/test/engine/mutex.test.d.ts +2 -0
- package/dist/test/test/engine/mutex.test.d.ts.map +1 -0
- package/dist/test/test/engine/mutex.test.js +63 -0
- package/dist/test/test/engine/mutex.test.js.map +1 -0
- package/dist/test/test/engine/session.test.js +15 -0
- package/dist/test/test/engine/session.test.js.map +1 -1
- package/dist/test/test/engine/unsplash-source.test.d.ts +2 -0
- package/dist/test/test/engine/unsplash-source.test.d.ts.map +1 -0
- package/dist/test/test/engine/unsplash-source.test.js +129 -0
- package/dist/test/test/engine/unsplash-source.test.js.map +1 -0
- package/dist/test/test/handbook-contract.test.js +8 -4
- package/dist/test/test/handbook-contract.test.js.map +1 -1
- package/dist/test/test/http/server.test.js +129 -0
- package/dist/test/test/http/server.test.js.map +1 -1
- package/dist/test/test/http/session-id-header.test.d.ts +2 -0
- package/dist/test/test/http/session-id-header.test.d.ts.map +1 -0
- package/dist/test/test/http/session-id-header.test.js +76 -0
- package/dist/test/test/http/session-id-header.test.js.map +1 -0
- package/dist/test/test/http/session-manager.test.js +14 -0
- package/dist/test/test/http/session-manager.test.js.map +1 -1
- package/dist/test/test/http/workspace-auth.test.d.ts +2 -0
- package/dist/test/test/http/workspace-auth.test.d.ts.map +1 -0
- package/dist/test/test/http/workspace-auth.test.js +41 -0
- package/dist/test/test/http/workspace-auth.test.js.map +1 -0
- package/dist/test/test/metrics/mount.test.d.ts +2 -0
- package/dist/test/test/metrics/mount.test.d.ts.map +1 -0
- package/dist/test/test/metrics/mount.test.js +59 -0
- package/dist/test/test/metrics/mount.test.js.map +1 -0
- package/dist/test/test/metrics/recorder.test.d.ts +2 -0
- package/dist/test/test/metrics/recorder.test.d.ts.map +1 -0
- package/dist/test/test/metrics/recorder.test.js +118 -0
- package/dist/test/test/metrics/recorder.test.js.map +1 -0
- package/dist/test/test/revisions/store.test.js +81 -11
- package/dist/test/test/revisions/store.test.js.map +1 -1
- package/dist/test/test/server.metrics.test.d.ts +2 -0
- package/dist/test/test/server.metrics.test.d.ts.map +1 -0
- package/dist/test/test/server.metrics.test.js +87 -0
- package/dist/test/test/server.metrics.test.js.map +1 -0
- package/dist/test/test/server.test.js +33 -4
- package/dist/test/test/server.test.js.map +1 -1
- package/dist/test/test/tools/asset-search.test.d.ts +2 -0
- package/dist/test/test/tools/asset-search.test.d.ts.map +1 -0
- package/dist/test/test/tools/asset-search.test.js +121 -0
- package/dist/test/test/tools/asset-search.test.js.map +1 -0
- package/dist/test/test/tools/edit.test.js +71 -9
- package/dist/test/test/tools/edit.test.js.map +1 -1
- package/dist/test/test/tools/export.test.js +60 -30
- package/dist/test/test/tools/export.test.js.map +1 -1
- package/dist/test/test/tools/index.test.d.ts +2 -0
- package/dist/test/test/tools/index.test.d.ts.map +1 -0
- package/dist/test/test/tools/index.test.js +55 -0
- package/dist/test/test/tools/index.test.js.map +1 -0
- package/dist/test/test/tools/metrics.test.d.ts +2 -0
- package/dist/test/test/tools/metrics.test.d.ts.map +1 -0
- package/dist/test/test/tools/metrics.test.js +29 -0
- package/dist/test/test/tools/metrics.test.js.map +1 -0
- package/dist/test/test/tools/preview.test.js +6 -4
- package/dist/test/test/tools/preview.test.js.map +1 -1
- package/dist/test/test/tools/rename.test.js +1 -2
- package/dist/test/test/tools/rename.test.js.map +1 -1
- package/dist/test/test/tools/save.test.d.ts +2 -0
- package/dist/test/test/tools/save.test.d.ts.map +1 -0
- package/dist/test/test/tools/save.test.js +137 -0
- package/dist/test/test/tools/save.test.js.map +1 -0
- package/dist/test/test/viewer/router.test.js +56 -0
- package/dist/test/test/viewer/router.test.js.map +1 -1
- package/dist/test/test/viewer/session-mount.test.d.ts +2 -0
- package/dist/test/test/viewer/session-mount.test.d.ts.map +1 -0
- package/dist/test/test/viewer/session-mount.test.js +52 -0
- package/dist/test/test/viewer/session-mount.test.js.map +1 -0
- package/dist/test/test/viewer/url-builder.test.js +76 -9
- package/dist/test/test/viewer/url-builder.test.js.map +1 -1
- package/dist/test/test/workspace/s3.test.d.ts +2 -0
- package/dist/test/test/workspace/s3.test.d.ts.map +1 -0
- package/dist/test/test/workspace/s3.test.js +153 -0
- package/dist/test/test/workspace/s3.test.js.map +1 -0
- package/dist/test/test/workspace/scoped.test.d.ts +2 -0
- package/dist/test/test/workspace/scoped.test.d.ts.map +1 -0
- package/dist/test/test/workspace/scoped.test.js +59 -0
- package/dist/test/test/workspace/scoped.test.js.map +1 -0
- package/dist/test/tsconfig.test.tsbuildinfo +1 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +14 -4
- package/dist/tools/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { S3Client } from '@aws-sdk/client-s3';
|
|
1
2
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
3
|
+
import { LocalViewerUrlBuilder, viewerBaseFor } from '../../src/viewer/url-builder.js';
|
|
4
|
+
import { PresignedR2UrlBuilder } from '../../src/viewer/presigned-r2-url-builder.js';
|
|
3
5
|
describe('LocalViewerUrlBuilder', () => {
|
|
4
6
|
it('builds /d/<root> design URLs against the listener base', () => {
|
|
5
7
|
const b = new LocalViewerUrlBuilder({ base: 'http://127.0.0.1:8745' });
|
|
@@ -17,15 +19,80 @@ describe('LocalViewerUrlBuilder', () => {
|
|
|
17
19
|
const b = new LocalViewerUrlBuilder({ base: 'http://127.0.0.1:8745' });
|
|
18
20
|
expect(b.forResource('assets/9f2a1c.png')).toBe('http://127.0.0.1:8745/r/assets/9f2a1c.png');
|
|
19
21
|
});
|
|
22
|
+
it('forDownload resolves to the same URL as forResource', async () => {
|
|
23
|
+
const b = new LocalViewerUrlBuilder({ base: 'http://127.0.0.1:8745' });
|
|
24
|
+
const relpath = 'exports/ab/cd1234/image.png';
|
|
25
|
+
await expect(b.forDownload(relpath)).resolves.toBe(b.forResource(relpath));
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
describe('viewerBaseFor', () => {
|
|
29
|
+
it('session mode + public base → /v/<sid> on the public origin', () => {
|
|
30
|
+
expect(viewerBaseFor({
|
|
31
|
+
publicBaseUrl: 'https://codesign.example.com',
|
|
32
|
+
port: 8080,
|
|
33
|
+
mode: 'session',
|
|
34
|
+
sessionId: 'abc-123'
|
|
35
|
+
})).toBe('https://codesign.example.com/v/abc-123');
|
|
36
|
+
});
|
|
37
|
+
it('session mode without public base → loopback /v/<sid>', () => {
|
|
38
|
+
expect(viewerBaseFor({
|
|
39
|
+
publicBaseUrl: undefined,
|
|
40
|
+
port: 8745,
|
|
41
|
+
mode: 'session',
|
|
42
|
+
sessionId: 'abc-123'
|
|
43
|
+
})).toBe('http://127.0.0.1:8745/v/abc-123');
|
|
44
|
+
});
|
|
45
|
+
it('single mode → /viewer (the fixed-workspace mount)', () => {
|
|
46
|
+
expect(viewerBaseFor({
|
|
47
|
+
publicBaseUrl: undefined,
|
|
48
|
+
port: 8745,
|
|
49
|
+
mode: 'single',
|
|
50
|
+
sessionId: 'abc-123'
|
|
51
|
+
})).toBe('http://127.0.0.1:8745/viewer');
|
|
52
|
+
});
|
|
20
53
|
});
|
|
21
|
-
describe('
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
54
|
+
describe('PresignedR2UrlBuilder', () => {
|
|
55
|
+
// getSignedUrl from @aws-sdk/s3-request-presigner signs locally — no network
|
|
56
|
+
// call is made. We use dummy credentials and a fake R2 endpoint.
|
|
57
|
+
const client = new S3Client({
|
|
58
|
+
region: 'auto',
|
|
59
|
+
endpoint: 'https://accountid.r2.cloudflarestorage.com',
|
|
60
|
+
forcePathStyle: true,
|
|
61
|
+
credentials: { accessKeyId: 'test', secretAccessKey: 'test' }
|
|
62
|
+
});
|
|
63
|
+
const workspaceId = 'ws-test-001';
|
|
64
|
+
const builder = new PresignedR2UrlBuilder({
|
|
65
|
+
base: 'http://127.0.0.1:8745',
|
|
66
|
+
client,
|
|
67
|
+
bucket: 'codesign-bucket',
|
|
68
|
+
workspaceId,
|
|
69
|
+
expiresIn: 300
|
|
70
|
+
});
|
|
71
|
+
it('forDownload returns a presigned URL containing the workspace-scoped R2 key', async () => {
|
|
72
|
+
const relpath = 'exports/ab/cd1234ef/scene.scene';
|
|
73
|
+
const url = await builder.forDownload(relpath);
|
|
74
|
+
// The full key must appear as one ordered, contiguous path segment —
|
|
75
|
+
// workspaces/<workspaceId>/<relpath> — not just the fragments in any order.
|
|
76
|
+
// R2 keys aren't percent-encoded for these chars, so assert the exact path.
|
|
77
|
+
expect(url).toContain(`workspaces/${workspaceId}/${relpath}`);
|
|
78
|
+
});
|
|
79
|
+
it('forDownload URL contains X-Amz-Signature (proves it is presigned)', async () => {
|
|
80
|
+
const url = await builder.forDownload('exports/ab/cd/image.png');
|
|
81
|
+
expect(url).toContain('X-Amz-Signature');
|
|
82
|
+
});
|
|
83
|
+
it('inherits sync forResource from LocalViewerUrlBuilder unchanged', () => {
|
|
84
|
+
expect(builder.forResource('assets/9f2a1c.png')).toBe('http://127.0.0.1:8745/r/assets/9f2a1c.png');
|
|
85
|
+
});
|
|
86
|
+
it('forDownload with default expiresIn (3600) also signs', async () => {
|
|
87
|
+
const b = new PresignedR2UrlBuilder({
|
|
88
|
+
base: 'http://127.0.0.1:8745',
|
|
89
|
+
client,
|
|
90
|
+
bucket: 'codesign-bucket',
|
|
91
|
+
workspaceId: 'ws-default'
|
|
92
|
+
});
|
|
93
|
+
const url = await b.forDownload('exports/11/22/design.scene');
|
|
94
|
+
expect(url).toContain('X-Amz-Signature');
|
|
95
|
+
expect(url).toContain('ws-default');
|
|
29
96
|
});
|
|
30
97
|
});
|
|
31
98
|
//# sourceMappingURL=url-builder.test.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"url-builder.test.js","sourceRoot":"","sources":["../../../../test/viewer/url-builder.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EACL,
|
|
1
|
+
{"version":3,"file":"url-builder.test.js","sourceRoot":"","sources":["../../../../test/viewer/url-builder.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { S3Client } from '@aws-sdk/client-s3';\nimport { describe, expect, it } from 'vitest';\nimport {\n LocalViewerUrlBuilder,\n viewerBaseFor\n} from '../../src/viewer/url-builder.js';\nimport { PresignedR2UrlBuilder } from '../../src/viewer/presigned-r2-url-builder.js';\n\ndescribe('LocalViewerUrlBuilder', () => {\n it('builds /d/<root> design URLs against the listener base', () => {\n const b = new LocalViewerUrlBuilder({ base: 'http://127.0.0.1:8745' });\n expect(b.forDesign('abc123def456')).toBe(\n 'http://127.0.0.1:8745/d/abc123def456'\n );\n });\n\n it('builds /d/<root>/<rev> pinned-revision URLs', () => {\n const b = new LocalViewerUrlBuilder({ base: 'http://127.0.0.1:8745' });\n expect(b.forPinnedRevision('abc123def456', '0011deadbeef')).toBe(\n 'http://127.0.0.1:8745/d/abc123def456/0011deadbeef'\n );\n });\n\n it('builds the studio landing URL', () => {\n const b = new LocalViewerUrlBuilder({ base: 'http://127.0.0.1:8745' });\n expect(b.forStudio()).toBe('http://127.0.0.1:8745/');\n });\n\n it('builds /r/<relpath> resource URLs against the listener base', () => {\n const b = new LocalViewerUrlBuilder({ base: 'http://127.0.0.1:8745' });\n expect(b.forResource('assets/9f2a1c.png')).toBe(\n 'http://127.0.0.1:8745/r/assets/9f2a1c.png'\n );\n });\n\n it('forDownload resolves to the same URL as forResource', async () => {\n const b = new LocalViewerUrlBuilder({ base: 'http://127.0.0.1:8745' });\n const relpath = 'exports/ab/cd1234/image.png';\n await expect(b.forDownload(relpath)).resolves.toBe(b.forResource(relpath));\n });\n});\n\ndescribe('viewerBaseFor', () => {\n it('session mode + public base → /v/<sid> on the public origin', () => {\n expect(\n viewerBaseFor({\n publicBaseUrl: 'https://codesign.example.com',\n port: 8080,\n mode: 'session',\n sessionId: 'abc-123'\n })\n ).toBe('https://codesign.example.com/v/abc-123');\n });\n\n it('session mode without public base → loopback /v/<sid>', () => {\n expect(\n viewerBaseFor({\n publicBaseUrl: undefined,\n port: 8745,\n mode: 'session',\n sessionId: 'abc-123'\n })\n ).toBe('http://127.0.0.1:8745/v/abc-123');\n });\n\n it('single mode → /viewer (the fixed-workspace mount)', () => {\n expect(\n viewerBaseFor({\n publicBaseUrl: undefined,\n port: 8745,\n mode: 'single',\n sessionId: 'abc-123'\n })\n ).toBe('http://127.0.0.1:8745/viewer');\n });\n});\n\ndescribe('PresignedR2UrlBuilder', () => {\n // getSignedUrl from @aws-sdk/s3-request-presigner signs locally — no network\n // call is made. We use dummy credentials and a fake R2 endpoint.\n const client = new S3Client({\n region: 'auto',\n endpoint: 'https://accountid.r2.cloudflarestorage.com',\n forcePathStyle: true,\n credentials: { accessKeyId: 'test', secretAccessKey: 'test' }\n });\n const workspaceId = 'ws-test-001';\n const builder = new PresignedR2UrlBuilder({\n base: 'http://127.0.0.1:8745',\n client,\n bucket: 'codesign-bucket',\n workspaceId,\n expiresIn: 300\n });\n\n it('forDownload returns a presigned URL containing the workspace-scoped R2 key', async () => {\n const relpath = 'exports/ab/cd1234ef/scene.scene';\n const url = await builder.forDownload(relpath);\n // The full key must appear as one ordered, contiguous path segment —\n // workspaces/<workspaceId>/<relpath> — not just the fragments in any order.\n // R2 keys aren't percent-encoded for these chars, so assert the exact path.\n expect(url).toContain(`workspaces/${workspaceId}/${relpath}`);\n });\n\n it('forDownload URL contains X-Amz-Signature (proves it is presigned)', async () => {\n const url = await builder.forDownload('exports/ab/cd/image.png');\n expect(url).toContain('X-Amz-Signature');\n });\n\n it('inherits sync forResource from LocalViewerUrlBuilder unchanged', () => {\n expect(builder.forResource('assets/9f2a1c.png')).toBe(\n 'http://127.0.0.1:8745/r/assets/9f2a1c.png'\n );\n });\n\n it('forDownload with default expiresIn (3600) also signs', async () => {\n const b = new PresignedR2UrlBuilder({\n base: 'http://127.0.0.1:8745',\n client,\n bucket: 'codesign-bucket',\n workspaceId: 'ws-default'\n });\n const url = await b.forDownload('exports/11/22/design.scene');\n expect(url).toContain('X-Amz-Signature');\n expect(url).toContain('ws-default');\n });\n});\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"s3.test.d.ts","sourceRoot":"","sources":["../../../../test/workspace/s3.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, PutObjectCommand } from '@aws-sdk/client-s3';
|
|
3
|
+
import { S3WorkspaceFS } from '../../src/workspace/s3.js';
|
|
4
|
+
/** Minimal in-memory fake of the S3 `send`-based client the adapter uses. */
|
|
5
|
+
function makeFakeS3(seed = {}) {
|
|
6
|
+
const store = new Map();
|
|
7
|
+
for (const [k, v] of Object.entries(seed))
|
|
8
|
+
store.set(k, new TextEncoder().encode(v));
|
|
9
|
+
const notFound = () => {
|
|
10
|
+
const err = new Error('NoSuchKey');
|
|
11
|
+
err.name = 'NoSuchKey';
|
|
12
|
+
err.$metadata = { httpStatusCode: 404 };
|
|
13
|
+
throw err;
|
|
14
|
+
};
|
|
15
|
+
const client = {
|
|
16
|
+
store,
|
|
17
|
+
send: vi.fn(async (cmd) => {
|
|
18
|
+
if (cmd instanceof PutObjectCommand) {
|
|
19
|
+
const { Key, Body } = cmd.input;
|
|
20
|
+
const bytes = typeof Body === 'string'
|
|
21
|
+
? new TextEncoder().encode(Body)
|
|
22
|
+
: Body;
|
|
23
|
+
store.set(Key, bytes);
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
if (cmd instanceof GetObjectCommand) {
|
|
27
|
+
const bytes = store.get(cmd.input.Key);
|
|
28
|
+
if (!bytes)
|
|
29
|
+
notFound();
|
|
30
|
+
return {
|
|
31
|
+
Body: {
|
|
32
|
+
transformToByteArray: async () => bytes
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (cmd instanceof HeadObjectCommand) {
|
|
37
|
+
const bytes = store.get(cmd.input.Key);
|
|
38
|
+
if (!bytes)
|
|
39
|
+
notFound();
|
|
40
|
+
return { ContentLength: bytes.byteLength, LastModified: new Date(0) };
|
|
41
|
+
}
|
|
42
|
+
if (cmd instanceof ListObjectsV2Command) {
|
|
43
|
+
const prefix = cmd.input.Prefix ?? '';
|
|
44
|
+
const delimiter = cmd.input.Delimiter;
|
|
45
|
+
const keys = [...store.keys()].filter((k) => k.startsWith(prefix));
|
|
46
|
+
const contents = [];
|
|
47
|
+
const commonPrefixes = new Set();
|
|
48
|
+
for (const k of keys) {
|
|
49
|
+
const rest = k.slice(prefix.length);
|
|
50
|
+
if (delimiter && rest.includes(delimiter)) {
|
|
51
|
+
commonPrefixes.add(prefix + rest.slice(0, rest.indexOf(delimiter) + 1));
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
contents.push({ Key: k });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
Contents: contents,
|
|
59
|
+
CommonPrefixes: [...commonPrefixes].map((Prefix) => ({ Prefix })),
|
|
60
|
+
IsTruncated: false
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
if (cmd instanceof DeleteObjectCommand) {
|
|
64
|
+
store.delete(cmd.input.Key);
|
|
65
|
+
return {};
|
|
66
|
+
}
|
|
67
|
+
throw new Error(`fake S3: unhandled command ${cmd.constructor.name}`);
|
|
68
|
+
})
|
|
69
|
+
};
|
|
70
|
+
return client;
|
|
71
|
+
}
|
|
72
|
+
function makeFs(seed) {
|
|
73
|
+
const client = makeFakeS3(seed);
|
|
74
|
+
const fs = new S3WorkspaceFS({ client: client, bucket: 'b' });
|
|
75
|
+
return { fs, client };
|
|
76
|
+
}
|
|
77
|
+
describe('S3WorkspaceFS', () => {
|
|
78
|
+
it('writeFile then readFile round-trips utf8', async () => {
|
|
79
|
+
const { fs } = makeFs();
|
|
80
|
+
await fs.writeFile('objects/ab/cd.scene', 'hello');
|
|
81
|
+
expect(await fs.readFile('objects/ab/cd.scene', 'utf8')).toBe('hello');
|
|
82
|
+
});
|
|
83
|
+
it('readFile throws ENOENT for a missing key', async () => {
|
|
84
|
+
const { fs } = makeFs();
|
|
85
|
+
await expect(fs.readFile('missing', 'utf8')).rejects.toMatchObject({
|
|
86
|
+
code: 'ENOENT'
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
it('exists is true for a written key and false otherwise', async () => {
|
|
90
|
+
const { fs } = makeFs({ 'a/b': 'x' });
|
|
91
|
+
expect(await fs.exists('a/b')).toBe(true);
|
|
92
|
+
expect(await fs.exists('a/z')).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
it('exists is true for a prefix that has children (a "directory")', async () => {
|
|
95
|
+
const { fs } = makeFs({ 'journal/root1/0001-leaf': '' });
|
|
96
|
+
expect(await fs.exists('journal')).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
it('readdir returns immediate child names (dirs + files)', async () => {
|
|
99
|
+
const { fs } = makeFs({
|
|
100
|
+
'journal/root1/0001-a': '',
|
|
101
|
+
'journal/root2/0002-b': ''
|
|
102
|
+
});
|
|
103
|
+
expect((await fs.readdir('journal')).sort()).toEqual(['root1', 'root2']);
|
|
104
|
+
expect(await fs.readdir('journal/root1')).toEqual(['0001-a']);
|
|
105
|
+
});
|
|
106
|
+
it('rename copies bytes to the new key and removes the old', async () => {
|
|
107
|
+
const { fs } = makeFs({ 'a/old': 'payload' });
|
|
108
|
+
await fs.rename('a/old', 'a/new');
|
|
109
|
+
expect(await fs.exists('a/old')).toBe(false);
|
|
110
|
+
expect(await fs.readFile('a/new', 'utf8')).toBe('payload');
|
|
111
|
+
});
|
|
112
|
+
it('stat reports a file for an object and a directory for a prefix', async () => {
|
|
113
|
+
const { fs } = makeFs({ 'd/child': 'x' });
|
|
114
|
+
const fileStat = await fs.stat('d/child');
|
|
115
|
+
expect(fileStat.isFile()).toBe(true);
|
|
116
|
+
expect(fileStat.isDirectory()).toBe(false);
|
|
117
|
+
const dirStat = await fs.stat('d');
|
|
118
|
+
expect(dirStat.isDirectory()).toBe(true);
|
|
119
|
+
expect(dirStat.isFile()).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
it('rm recursive deletes the prefix subtree AND the key itself', async () => {
|
|
122
|
+
const { fs } = makeFs({
|
|
123
|
+
p: 'self',
|
|
124
|
+
'p/a': '1',
|
|
125
|
+
'p/b/c': '2'
|
|
126
|
+
});
|
|
127
|
+
await fs.rm('p', { recursive: true });
|
|
128
|
+
expect(await fs.exists('p')).toBe(false);
|
|
129
|
+
expect(await fs.exists('p/a')).toBe(false);
|
|
130
|
+
expect(await fs.exists('p/b/c')).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
it('retries a read-after-write 404 then succeeds (bounded)', async () => {
|
|
133
|
+
const { fs, client } = makeFs();
|
|
134
|
+
let gets = 0;
|
|
135
|
+
const realSend = client.send;
|
|
136
|
+
client.send = vi.fn(async (cmd) => {
|
|
137
|
+
if (cmd instanceof GetObjectCommand) {
|
|
138
|
+
gets += 1;
|
|
139
|
+
if (gets === 1) {
|
|
140
|
+
const err = new Error('NoSuchKey');
|
|
141
|
+
err.name = 'NoSuchKey';
|
|
142
|
+
err.$metadata = { httpStatusCode: 404 };
|
|
143
|
+
throw err;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return realSend(cmd);
|
|
147
|
+
});
|
|
148
|
+
client.store.set('k', new TextEncoder().encode('v'));
|
|
149
|
+
expect(await fs.readFile('k', 'utf8')).toBe('v');
|
|
150
|
+
expect(gets).toBe(2);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
//# sourceMappingURL=s3.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"s3.test.js","sourceRoot":"","sources":["../../../../test/workspace/s3.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { describe, expect, it, vi } from 'vitest';\nimport {\n DeleteObjectCommand,\n GetObjectCommand,\n HeadObjectCommand,\n ListObjectsV2Command,\n PutObjectCommand\n} from '@aws-sdk/client-s3';\nimport { S3WorkspaceFS } from '../../src/workspace/s3.js';\n\n/** Minimal in-memory fake of the S3 `send`-based client the adapter uses. */\nfunction makeFakeS3(seed: Record<string, string> = {}) {\n const store = new Map<string, Uint8Array>();\n for (const [k, v] of Object.entries(seed))\n store.set(k, new TextEncoder().encode(v));\n\n const notFound = (): never => {\n const err = new Error('NoSuchKey') as Error & {\n name: string;\n $metadata: { httpStatusCode: number };\n };\n err.name = 'NoSuchKey';\n err.$metadata = { httpStatusCode: 404 };\n throw err;\n };\n\n const client = {\n store,\n send: vi.fn(async (cmd: unknown) => {\n if (cmd instanceof PutObjectCommand) {\n const { Key, Body } = cmd.input;\n const bytes =\n typeof Body === 'string'\n ? new TextEncoder().encode(Body)\n : (Body as Uint8Array);\n store.set(Key as string, bytes);\n return {};\n }\n if (cmd instanceof GetObjectCommand) {\n const bytes = store.get(cmd.input.Key as string);\n if (!bytes) notFound();\n return {\n Body: {\n transformToByteArray: async () => bytes\n }\n };\n }\n if (cmd instanceof HeadObjectCommand) {\n const bytes = store.get(cmd.input.Key as string);\n if (!bytes) notFound();\n return { ContentLength: bytes!.byteLength, LastModified: new Date(0) };\n }\n if (cmd instanceof ListObjectsV2Command) {\n const prefix = (cmd.input.Prefix as string) ?? '';\n const delimiter = cmd.input.Delimiter as string | undefined;\n const keys = [...store.keys()].filter((k) => k.startsWith(prefix));\n const contents: Array<{ Key: string }> = [];\n const commonPrefixes = new Set<string>();\n for (const k of keys) {\n const rest = k.slice(prefix.length);\n if (delimiter && rest.includes(delimiter)) {\n commonPrefixes.add(\n prefix + rest.slice(0, rest.indexOf(delimiter) + 1)\n );\n } else {\n contents.push({ Key: k });\n }\n }\n return {\n Contents: contents,\n CommonPrefixes: [...commonPrefixes].map((Prefix) => ({ Prefix })),\n IsTruncated: false\n };\n }\n if (cmd instanceof DeleteObjectCommand) {\n store.delete(cmd.input.Key as string);\n return {};\n }\n throw new Error(\n `fake S3: unhandled command ${(cmd as object).constructor.name}`\n );\n })\n };\n return client;\n}\n\nfunction makeFs(seed?: Record<string, string>) {\n const client = makeFakeS3(seed);\n const fs = new S3WorkspaceFS({ client: client as never, bucket: 'b' });\n return { fs, client };\n}\n\ndescribe('S3WorkspaceFS', () => {\n it('writeFile then readFile round-trips utf8', async () => {\n const { fs } = makeFs();\n await fs.writeFile('objects/ab/cd.scene', 'hello');\n expect(await fs.readFile('objects/ab/cd.scene', 'utf8')).toBe('hello');\n });\n\n it('readFile throws ENOENT for a missing key', async () => {\n const { fs } = makeFs();\n await expect(fs.readFile('missing', 'utf8')).rejects.toMatchObject({\n code: 'ENOENT'\n });\n });\n\n it('exists is true for a written key and false otherwise', async () => {\n const { fs } = makeFs({ 'a/b': 'x' });\n expect(await fs.exists('a/b')).toBe(true);\n expect(await fs.exists('a/z')).toBe(false);\n });\n\n it('exists is true for a prefix that has children (a \"directory\")', async () => {\n const { fs } = makeFs({ 'journal/root1/0001-leaf': '' });\n expect(await fs.exists('journal')).toBe(true);\n });\n\n it('readdir returns immediate child names (dirs + files)', async () => {\n const { fs } = makeFs({\n 'journal/root1/0001-a': '',\n 'journal/root2/0002-b': ''\n });\n expect((await fs.readdir('journal')).sort()).toEqual(['root1', 'root2']);\n expect(await fs.readdir('journal/root1')).toEqual(['0001-a']);\n });\n\n it('rename copies bytes to the new key and removes the old', async () => {\n const { fs } = makeFs({ 'a/old': 'payload' });\n await fs.rename('a/old', 'a/new');\n expect(await fs.exists('a/old')).toBe(false);\n expect(await fs.readFile('a/new', 'utf8')).toBe('payload');\n });\n\n it('stat reports a file for an object and a directory for a prefix', async () => {\n const { fs } = makeFs({ 'd/child': 'x' });\n const fileStat = await fs.stat('d/child');\n expect(fileStat.isFile()).toBe(true);\n expect(fileStat.isDirectory()).toBe(false);\n const dirStat = await fs.stat('d');\n expect(dirStat.isDirectory()).toBe(true);\n expect(dirStat.isFile()).toBe(false);\n });\n\n it('rm recursive deletes the prefix subtree AND the key itself', async () => {\n const { fs } = makeFs({\n p: 'self',\n 'p/a': '1',\n 'p/b/c': '2'\n });\n await fs.rm('p', { recursive: true });\n expect(await fs.exists('p')).toBe(false);\n expect(await fs.exists('p/a')).toBe(false);\n expect(await fs.exists('p/b/c')).toBe(false);\n });\n\n it('retries a read-after-write 404 then succeeds (bounded)', async () => {\n const { fs, client } = makeFs();\n let gets = 0;\n const realSend = client.send;\n client.send = vi.fn(async (cmd: unknown) => {\n if (cmd instanceof GetObjectCommand) {\n gets += 1;\n if (gets === 1) {\n const err = new Error('NoSuchKey') as Error & {\n name: string;\n $metadata: { httpStatusCode: number };\n };\n err.name = 'NoSuchKey';\n err.$metadata = { httpStatusCode: 404 };\n throw err;\n }\n }\n return realSend(cmd);\n });\n client.store.set('k', new TextEncoder().encode('v'));\n expect(await fs.readFile('k', 'utf8')).toBe('v');\n expect(gets).toBe(2);\n });\n});\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scoped.test.d.ts","sourceRoot":"","sources":["../../../../test/workspace/scoped.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { InMemoryWorkspaceFS } from '../../src/workspace/in-memory.js';
|
|
3
|
+
import { sanitizeWorkspaceId, ScopedWorkspaceFS } from '../../src/workspace/scoped.js';
|
|
4
|
+
describe('sanitizeWorkspaceId', () => {
|
|
5
|
+
it('accepts a plain id and a namespaced id', () => {
|
|
6
|
+
expect(sanitizeWorkspaceId('default')).toBe('default');
|
|
7
|
+
expect(sanitizeWorkspaceId('u/usr_8842')).toBe('u/usr_8842');
|
|
8
|
+
expect(sanitizeWorkspaceId('s/abc-123')).toBe('s/abc-123');
|
|
9
|
+
});
|
|
10
|
+
it.each([
|
|
11
|
+
['', 'empty'],
|
|
12
|
+
['/abs', 'leading slash'],
|
|
13
|
+
['..', 'dot-dot'],
|
|
14
|
+
['a/../b', 'traversal segment'],
|
|
15
|
+
['a//b', 'empty segment'],
|
|
16
|
+
['a/', 'trailing slash'],
|
|
17
|
+
['a\\b', 'backslash'],
|
|
18
|
+
['a\x01b', 'control char']
|
|
19
|
+
])('rejects %s (%s) with EINVAL', (id) => {
|
|
20
|
+
expect(() => sanitizeWorkspaceId(id)).toThrowError(expect.objectContaining({ code: 'EINVAL' }));
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
describe('ScopedWorkspaceFS', () => {
|
|
24
|
+
it('prefixes writes/reads under workspaces/<id>/', async () => {
|
|
25
|
+
const inner = new InMemoryWorkspaceFS();
|
|
26
|
+
const scoped = new ScopedWorkspaceFS(inner, 'u/usr_8842');
|
|
27
|
+
await scoped.writeFile('objects/ab/cd.scene', 'hello');
|
|
28
|
+
expect(await inner.readFile('workspaces/u/usr_8842/objects/ab/cd.scene', 'utf8')).toBe('hello');
|
|
29
|
+
expect(await scoped.readFile('objects/ab/cd.scene', 'utf8')).toBe('hello');
|
|
30
|
+
});
|
|
31
|
+
it('isolates two workspaces sharing one inner fs', async () => {
|
|
32
|
+
const inner = new InMemoryWorkspaceFS();
|
|
33
|
+
const a = new ScopedWorkspaceFS(inner, 'u/a');
|
|
34
|
+
const b = new ScopedWorkspaceFS(inner, 'u/b');
|
|
35
|
+
await a.writeFile('f', 'A');
|
|
36
|
+
await b.writeFile('f', 'B');
|
|
37
|
+
expect(await a.readFile('f', 'utf8')).toBe('A');
|
|
38
|
+
expect(await b.readFile('f', 'utf8')).toBe('B');
|
|
39
|
+
});
|
|
40
|
+
it('scopes readdir input and returns unprefixed names', async () => {
|
|
41
|
+
const inner = new InMemoryWorkspaceFS();
|
|
42
|
+
const scoped = new ScopedWorkspaceFS(inner, 'ws');
|
|
43
|
+
await scoped.writeFile('journal/root1/0000000000000005-abc', '');
|
|
44
|
+
expect(await scoped.readdir('journal')).toEqual(['root1']);
|
|
45
|
+
});
|
|
46
|
+
it('scopes both paths of a rename', async () => {
|
|
47
|
+
const inner = new InMemoryWorkspaceFS();
|
|
48
|
+
const scoped = new ScopedWorkspaceFS(inner, 'ws');
|
|
49
|
+
await scoped.writeFile('a', 'x');
|
|
50
|
+
await scoped.rename('a', 'b');
|
|
51
|
+
expect(await scoped.exists('a')).toBe(false);
|
|
52
|
+
expect(await scoped.readFile('b', 'utf8')).toBe('x');
|
|
53
|
+
expect(await inner.exists('workspaces/ws/b')).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
it('rejects an illegal workspaceId at construction', () => {
|
|
56
|
+
expect(() => new ScopedWorkspaceFS(new InMemoryWorkspaceFS(), '../escape')).toThrowError(expect.objectContaining({ code: 'EINVAL' }));
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
//# sourceMappingURL=scoped.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scoped.test.js","sourceRoot":"","sources":["../../../../test/workspace/scoped.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { describe, expect, it } from 'vitest';\nimport { InMemoryWorkspaceFS } from '../../src/workspace/in-memory.js';\nimport {\n sanitizeWorkspaceId,\n ScopedWorkspaceFS\n} from '../../src/workspace/scoped.js';\n\ndescribe('sanitizeWorkspaceId', () => {\n it('accepts a plain id and a namespaced id', () => {\n expect(sanitizeWorkspaceId('default')).toBe('default');\n expect(sanitizeWorkspaceId('u/usr_8842')).toBe('u/usr_8842');\n expect(sanitizeWorkspaceId('s/abc-123')).toBe('s/abc-123');\n });\n\n it.each([\n ['', 'empty'],\n ['/abs', 'leading slash'],\n ['..', 'dot-dot'],\n ['a/../b', 'traversal segment'],\n ['a//b', 'empty segment'],\n ['a/', 'trailing slash'],\n ['a\\\\b', 'backslash'],\n ['a\\x01b', 'control char']\n ])('rejects %s (%s) with EINVAL', (id) => {\n expect(() => sanitizeWorkspaceId(id)).toThrowError(\n expect.objectContaining({ code: 'EINVAL' })\n );\n });\n});\n\ndescribe('ScopedWorkspaceFS', () => {\n it('prefixes writes/reads under workspaces/<id>/', async () => {\n const inner = new InMemoryWorkspaceFS();\n const scoped = new ScopedWorkspaceFS(inner, 'u/usr_8842');\n await scoped.writeFile('objects/ab/cd.scene', 'hello');\n expect(\n await inner.readFile('workspaces/u/usr_8842/objects/ab/cd.scene', 'utf8')\n ).toBe('hello');\n expect(await scoped.readFile('objects/ab/cd.scene', 'utf8')).toBe('hello');\n });\n\n it('isolates two workspaces sharing one inner fs', async () => {\n const inner = new InMemoryWorkspaceFS();\n const a = new ScopedWorkspaceFS(inner, 'u/a');\n const b = new ScopedWorkspaceFS(inner, 'u/b');\n await a.writeFile('f', 'A');\n await b.writeFile('f', 'B');\n expect(await a.readFile('f', 'utf8')).toBe('A');\n expect(await b.readFile('f', 'utf8')).toBe('B');\n });\n\n it('scopes readdir input and returns unprefixed names', async () => {\n const inner = new InMemoryWorkspaceFS();\n const scoped = new ScopedWorkspaceFS(inner, 'ws');\n await scoped.writeFile('journal/root1/0000000000000005-abc', '');\n expect(await scoped.readdir('journal')).toEqual(['root1']);\n });\n\n it('scopes both paths of a rename', async () => {\n const inner = new InMemoryWorkspaceFS();\n const scoped = new ScopedWorkspaceFS(inner, 'ws');\n await scoped.writeFile('a', 'x');\n await scoped.rename('a', 'b');\n expect(await scoped.exists('a')).toBe(false);\n expect(await scoped.readFile('b', 'utf8')).toBe('x');\n expect(await inner.exists('workspaces/ws/b')).toBe(true);\n });\n\n it('rejects an illegal workspaceId at construction', () => {\n expect(\n () => new ScopedWorkspaceFS(new InMemoryWorkspaceFS(), '../escape')\n ).toThrowError(expect.objectContaining({ code: 'EINVAL' }));\n });\n});\n"]}
|