@goliapkg/sentori-cli 0.5.2 → 0.6.0

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.
@@ -0,0 +1,193 @@
1
+ // v1.2 W3.a — `sentori-cli upload source-bundle`.
2
+ //
3
+ // Uploads a pre-built `*.tar.gz` archive of the project's source tree
4
+ // for one platform. Server stores it under
5
+ // `release_artifacts` with `kind = source_bundle_<platform>`; on
6
+ // click-frame, the dashboard's FrameSourceDrawer pulls the matching
7
+ // source file out of the archive and shows ±N lines.
8
+ //
9
+ // We intentionally keep the CLI side small: the operator (or CI)
10
+ // already knows how to build a tarball. v1.3 may add an
11
+ // auto-bundle-from-directory mode; for now you do:
12
+ //
13
+ // tar -czf ios-source.tar.gz Sources/
14
+ // sentori-cli upload source-bundle --project <uuid> \
15
+ // --release myapp@1.0.0 --platform ios --path ios-source.tar.gz
16
+ import { spawnSync } from 'node:child_process';
17
+ import { mkdtemp, readFile, readdir, stat, writeFile } from 'node:fs/promises';
18
+ import { tmpdir } from 'node:os';
19
+ import { join, relative, sep as pathSep } from 'node:path';
20
+ const PLATFORMS = new Set(['ios', 'android']);
21
+ /** v1.2 W3.d — per-platform file extensions the CLI bundles when
22
+ * `--from-dir` mode picks files from a project tree. Kept narrow on
23
+ * purpose: source-bundle is only useful for native source viewing,
24
+ * and including non-source files would balloon the archive without
25
+ * helping the lookup path. */
26
+ const EXTENSIONS = {
27
+ android: ['.kt', '.java'],
28
+ ios: ['.swift', '.m', '.mm', '.h', '.hpp'],
29
+ };
30
+ const SKIP_DIRS = new Set([
31
+ '.git',
32
+ 'node_modules',
33
+ 'Pods',
34
+ 'build',
35
+ 'DerivedData',
36
+ '.gradle',
37
+ '.build',
38
+ 'target',
39
+ ]);
40
+ export async function uploadSourceBundle(opts) {
41
+ if (!PLATFORMS.has(opts.platform)) {
42
+ throw new Error(`--platform must be 'ios' or 'android' (got '${opts.platform}')`);
43
+ }
44
+ if (!opts.release) {
45
+ throw new Error('--release is required for source-bundle uploads');
46
+ }
47
+ // v1.2 W3.d: when `opts.path` is a directory, bundle it on the fly
48
+ // instead of requiring the operator to pre-build the archive.
49
+ let archivePath = opts.path;
50
+ let cleanup;
51
+ try {
52
+ const st = await stat(opts.path);
53
+ if (st.isDirectory()) {
54
+ const built = await buildSourceBundleFromDir(opts.path, opts.platform);
55
+ archivePath = built.path;
56
+ cleanup = built.cleanup;
57
+ }
58
+ }
59
+ catch (e) {
60
+ if (e.code !== 'ENOENT')
61
+ throw e;
62
+ throw new Error(`source path not found: ${opts.path}`);
63
+ }
64
+ try {
65
+ return await uploadPrebuiltTarGz({ ...opts, path: archivePath });
66
+ }
67
+ finally {
68
+ if (cleanup)
69
+ await cleanup();
70
+ }
71
+ }
72
+ async function uploadPrebuiltTarGz(opts) {
73
+ const body = await readFile(opts.path);
74
+ if (body.length === 0)
75
+ throw new Error(`empty archive: ${opts.path}`);
76
+ // Same gzip magic check the server enforces. Failing early here
77
+ // saves a network round-trip when an operator accidentally points
78
+ // at a raw .tar.
79
+ if (body.length < 2 || body[0] !== 0x1f || body[1] !== 0x8b) {
80
+ throw new Error(`${opts.path} does not look like a gzip stream (expected 1f 8b magic) — ` +
81
+ `did you mean to gzip it first? \`tar -czf <out>.tar.gz <dir>/\``);
82
+ }
83
+ if (!opts.release)
84
+ throw new Error('--release is required for source-bundle uploads');
85
+ const base = opts.apiUrl.replace(/\/+$/, '');
86
+ const q = new URLSearchParams();
87
+ q.set('release', opts.release);
88
+ q.set('platform', opts.platform);
89
+ if (opts.module)
90
+ q.set('module', opts.module);
91
+ const url = `${base}/admin/api/projects/${encodeURIComponent(opts.projectId)}/source-bundles?${q.toString()}`;
92
+ const resp = await fetch(url, {
93
+ body,
94
+ headers: {
95
+ Authorization: `Bearer ${opts.token}`,
96
+ 'Content-Type': 'application/gzip',
97
+ },
98
+ method: 'POST',
99
+ });
100
+ if (!resp.ok) {
101
+ let detail = '';
102
+ try {
103
+ detail = await resp.text();
104
+ }
105
+ catch {
106
+ // ignore
107
+ }
108
+ throw new Error(`${resp.status} ${resp.statusText}${detail ? ` — ${detail.slice(0, 300)}` : ''}`);
109
+ }
110
+ const parsed = await resp.json();
111
+ if (!parsed ||
112
+ typeof parsed !== 'object' ||
113
+ typeof parsed.contentHash !== 'string') {
114
+ throw new Error(`unexpected response shape: ${JSON.stringify(parsed)}`);
115
+ }
116
+ const r = parsed;
117
+ return { contentHash: r.contentHash, kind: r.kind, sizeBytes: r.sizeBytes };
118
+ }
119
+ /** Walk `dir`, pick platform-relevant source files, and tar.gz them
120
+ * into a temp file. Caller is responsible for invoking `cleanup()`.
121
+ * Uses the system `tar` binary; that's portable enough on macOS +
122
+ * Linux + WSL, which covers every realistic CI environment Sentori
123
+ * ships to. Native Windows CI without WSL would need to gzip the
124
+ * archive themselves (the pre-built-path mode still works). */
125
+ export async function buildSourceBundleFromDir(dir, platform) {
126
+ const exts = EXTENSIONS[platform];
127
+ const files = [];
128
+ await walk(dir, dir, exts, files);
129
+ if (files.length === 0) {
130
+ throw new Error(`no ${platform} source files (${exts.join(', ')}) found under ${dir} — ` +
131
+ `pass --platform with the right value or check your source layout`);
132
+ }
133
+ files.sort();
134
+ // Write the file list to a temp file, then have tar consume it via
135
+ // -T —. Avoids the per-arg shell length cap on huge projects.
136
+ const tmp = await mkdtemp(join(tmpdir(), 'sentori-srcbun-'));
137
+ const listPath = join(tmp, 'files.txt');
138
+ const archivePath = join(tmp, `${platform}-source.tar.gz`);
139
+ await writeFile(listPath, files.join('\n'));
140
+ const r = spawnSync('tar', ['-czf', archivePath, '-C', dir, '-T', listPath]);
141
+ if (r.status !== 0) {
142
+ throw new Error(`tar failed (status ${r.status}): ${(r.stderr ?? '').toString().slice(0, 300)}`);
143
+ }
144
+ return {
145
+ cleanup: async () => {
146
+ try {
147
+ await readdir(tmp).then(async (entries) => {
148
+ for (const e of entries) {
149
+ await unlinkIfExists(join(tmp, e));
150
+ }
151
+ });
152
+ await unlinkIfExists(tmp, true);
153
+ }
154
+ catch {
155
+ // best-effort cleanup
156
+ }
157
+ },
158
+ path: archivePath,
159
+ };
160
+ }
161
+ async function walk(root, dir, exts, out) {
162
+ const entries = await readdir(dir, { withFileTypes: true });
163
+ for (const e of entries) {
164
+ if (e.name.startsWith('.') && e.isDirectory() && e.name !== '.') {
165
+ // Hidden directory: skip (.git, .gradle, .build…). The known
166
+ // ones are listed in SKIP_DIRS for clarity, but any leading-dot
167
+ // dir is also skipped as a safety net.
168
+ continue;
169
+ }
170
+ if (SKIP_DIRS.has(e.name))
171
+ continue;
172
+ const full = join(dir, e.name);
173
+ if (e.isDirectory()) {
174
+ await walk(root, full, exts, out);
175
+ }
176
+ else if (e.isFile()) {
177
+ const lower = e.name.toLowerCase();
178
+ if (exts.some((ext) => lower.endsWith(ext))) {
179
+ out.push(relative(root, full).split(pathSep).join('/'));
180
+ }
181
+ }
182
+ }
183
+ }
184
+ async function unlinkIfExists(path, isDir = false) {
185
+ const { rm } = await import('node:fs/promises');
186
+ try {
187
+ await rm(path, { force: true, recursive: isDir });
188
+ }
189
+ catch {
190
+ // ignore
191
+ }
192
+ }
193
+ //# sourceMappingURL=source-bundle.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"source-bundle.js","sourceRoot":"","sources":["../src/source-bundle.ts"],"names":[],"mappings":"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"}
package/lib/upload.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export type UploadOptions = {
2
2
  release: string;
3
3
  token: string;
4
- /** Sentori API base, e.g. https://api.sentori.golia.jp or your host. */
4
+ /** Sentori API base, e.g. https://sentori.golia.jp or your host. */
5
5
  apiUrl: string;
6
6
  /** Files or directories. Directories are scanned one level deep. */
7
7
  paths: string[];
@@ -1 +1 @@
1
- {"version":3,"file":"upload.d.ts","sourceRoot":"","sources":["../src/upload.ts"],"names":[],"mappings":"AASA,MAAM,MAAM,aAAa,GAAG;IAC1B,OAAO,EAAE,MAAM,CAAA;IACf,KAAK,EAAE,MAAM,CAAA;IACb,wEAAwE;IACxE,MAAM,EAAE,MAAM,CAAA;IACd,oEAAoE;IACpE,KAAK,EAAE,MAAM,EAAE,CAAA;IACf,MAAM,CAAC,EAAE,OAAO,CAAA;CACjB,CAAA;AAED,MAAM,MAAM,YAAY,GAAG;IACzB,KAAK,EAAE,MAAM,EAAE,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,SAAS,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;CAC7C,CAAA;AAED;;;8DAG8D;AAC9D,wBAAsB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAoBrE;AAED,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,aAAa,GAAG,OAAO,CAAC,YAAY,CAAC,CAiCjF"}
1
+ {"version":3,"file":"upload.d.ts","sourceRoot":"","sources":["../src/upload.ts"],"names":[],"mappings":"AASA,MAAM,MAAM,aAAa,GAAG;IAC1B,OAAO,EAAE,MAAM,CAAA;IACf,KAAK,EAAE,MAAM,CAAA;IACb,oEAAoE;IACpE,MAAM,EAAE,MAAM,CAAA;IACd,oEAAoE;IACpE,KAAK,EAAE,MAAM,EAAE,CAAA;IACf,MAAM,CAAC,EAAE,OAAO,CAAA;CACjB,CAAA;AAED,MAAM,MAAM,YAAY,GAAG;IACzB,KAAK,EAAE,MAAM,EAAE,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,SAAS,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;CAC7C,CAAA;AAED;;;8DAG8D;AAC9D,wBAAsB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAoBrE;AAED,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,aAAa,GAAG,OAAO,CAAC,YAAY,CAAC,CAiCjF"}
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@goliapkg/sentori-cli",
3
- "version": "0.5.2",
4
- "description": "Sentori CLI \u2014 upload source maps so dashboard stack traces resolve to your original source.",
5
- "license": "MIT",
3
+ "version": "0.6.0",
4
+ "description": "Sentori CLI upload source maps so dashboard stack traces resolve to your original source.",
5
+ "license": "Apache-2.0 OR MIT",
6
+ "author": "GOLIA K.K. <takagi@golia.jp> (https://golia.jp)",
6
7
  "homepage": "https://sentori.golia.jp",
7
8
  "repository": {
8
9
  "type": "git",
@@ -0,0 +1,124 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+
3
+ import { buildTools } from '../mcp.js'
4
+
5
+ const ADMIN = {
6
+ apiUrl: 'https://api.example.com/',
7
+ projectId: 'proj-uuid',
8
+ token: 'sk_test',
9
+ }
10
+
11
+ const origFetch = globalThis.fetch
12
+ let calls: { body: string | null; headers: Headers; method: string; url: string }[]
13
+
14
+ beforeEach(() => {
15
+ calls = []
16
+ })
17
+ afterEach(() => {
18
+ globalThis.fetch = origFetch
19
+ })
20
+
21
+ function mockFetch(status = 200, body: unknown = {}): void {
22
+ globalThis.fetch = (async (url: Request | string | URL, init?: RequestInit) => {
23
+ const b = init?.body
24
+ calls.push({
25
+ body: typeof b === 'string' ? b : null,
26
+ headers: new Headers(init?.headers),
27
+ method: init?.method ?? 'GET',
28
+ url: String(url),
29
+ })
30
+ return new Response(status === 204 ? '' : JSON.stringify(body), { status })
31
+ }) as typeof fetch
32
+ }
33
+
34
+ describe('buildTools', () => {
35
+ test('returns the expected set of tools with required input schema fields', () => {
36
+ const tools = buildTools()
37
+ const names = new Set(tools.map((t) => t.name))
38
+ for (const expected of [
39
+ 'sentori_issue_list',
40
+ 'sentori_issue_get',
41
+ 'sentori_issue_comment',
42
+ 'sentori_issue_transition',
43
+ 'sentori_issue_assign',
44
+ 'sentori_issue_set_priority',
45
+ 'sentori_issue_set_labels',
46
+ 'sentori_issue_watch',
47
+ ]) {
48
+ expect(names.has(expected)).toBe(true)
49
+ }
50
+ for (const t of tools) {
51
+ // Every tool advertises a description + an input schema.
52
+ expect(typeof t.description).toBe('string')
53
+ expect(t.inputSchema).toBeDefined()
54
+ expect((t.inputSchema as { type?: string }).type).toBe('object')
55
+ }
56
+ })
57
+ })
58
+
59
+ describe('tool handlers proxy to admin API', () => {
60
+ test('sentori_issue_list calls GET .../issues with filters', async () => {
61
+ const tools = buildTools()
62
+ const list = tools.find((t) => t.name === 'sentori_issue_list')!
63
+ mockFetch(200, [])
64
+ await list.handler(
65
+ { limit: 25, priority: 'p0', projectId: 'proj-uuid', status: 'active' },
66
+ ADMIN,
67
+ )
68
+ expect(calls.length).toBe(1)
69
+ expect(calls[0]!.url).toContain('/admin/api/projects/proj-uuid/issues?')
70
+ expect(calls[0]!.url).toContain('status=active')
71
+ expect(calls[0]!.url).toContain('priority=p0')
72
+ expect(calls[0]!.url).toContain('limit=25')
73
+ expect(calls[0]!.method).toBe('GET')
74
+ expect(calls[0]!.headers.get('authorization')).toBe('Bearer sk_test')
75
+ })
76
+
77
+ test('sentori_issue_get fetches issue + activity in parallel', async () => {
78
+ const tools = buildTools()
79
+ const get = tools.find((t) => t.name === 'sentori_issue_get')!
80
+ mockFetch(200, { stub: true })
81
+ await get.handler({ issueId: 'iss-uuid', projectId: 'proj-uuid' }, ADMIN)
82
+ expect(calls.length).toBe(2)
83
+ const paths = calls.map((c) => c.url).sort()
84
+ expect(paths[0]).toContain('/admin/api/projects/proj-uuid/issues/iss-uuid')
85
+ expect(paths[1]).toContain('/admin/api/projects/proj-uuid/issues/iss-uuid/activity')
86
+ })
87
+
88
+ test('sentori_issue_transition PATCHes status', async () => {
89
+ const tools = buildTools()
90
+ const t = tools.find((x) => x.name === 'sentori_issue_transition')!
91
+ mockFetch(200, { id: 'x' })
92
+ await t.handler({ issueId: 'iss', projectId: 'proj', status: 'resolved' }, ADMIN)
93
+ expect(calls[0]!.method).toBe('PATCH')
94
+ expect(JSON.parse(calls[0]!.body!).status).toBe('resolved')
95
+ })
96
+
97
+ test('sentori_issue_set_labels rejects non-string array entries', async () => {
98
+ const tools = buildTools()
99
+ const t = tools.find((x) => x.name === 'sentori_issue_set_labels')!
100
+ await expect(
101
+ t.handler({ issueId: 'iss', labels: ['ok', 42], projectId: 'proj' }, ADMIN),
102
+ ).rejects.toThrow(/each label/)
103
+ })
104
+
105
+ test('sentori_issue_watch=true → PUT, watch=false → DELETE', async () => {
106
+ const tools = buildTools()
107
+ const t = tools.find((x) => x.name === 'sentori_issue_watch')!
108
+ mockFetch(204)
109
+ await t.handler({ issueId: 'iss', projectId: 'proj', watch: true }, ADMIN)
110
+ expect(calls[0]!.method).toBe('PUT')
111
+ calls.length = 0
112
+ await t.handler({ issueId: 'iss', projectId: 'proj', watch: false }, ADMIN)
113
+ expect(calls[0]!.method).toBe('DELETE')
114
+ })
115
+
116
+ test('handler surfaces a non-2xx as an Error', async () => {
117
+ const tools = buildTools()
118
+ const t = tools.find((x) => x.name === 'sentori_issue_comment')!
119
+ mockFetch(500, { error: 'boom' })
120
+ await expect(
121
+ t.handler({ body: 'hello', issueId: 'iss', projectId: 'proj' }, ADMIN),
122
+ ).rejects.toThrow(/500/)
123
+ })
124
+ })
@@ -0,0 +1,85 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
7
+
8
+ import { buildSourceBundleFromDir } from '../source-bundle.js'
9
+
10
+ let dir = ''
11
+ beforeEach(async () => {
12
+ dir = await mkdtemp(join(tmpdir(), 'sentori-cli-fromdir-'))
13
+ await mkdir(join(dir, 'Sources', 'MyApp'), { recursive: true })
14
+ await writeFile(join(dir, 'Sources', 'MyApp', 'View.swift'), '// swift\n')
15
+ await writeFile(join(dir, 'Sources', 'MyApp', 'Util.m'), '// objc\n')
16
+ await mkdir(join(dir, 'android', 'app', 'src'), { recursive: true })
17
+ await writeFile(join(dir, 'android', 'app', 'src', 'MainActivity.kt'), '// kotlin\n')
18
+ // Skips: hidden + skip-list + non-matching ext.
19
+ await mkdir(join(dir, 'node_modules', 'foo'), { recursive: true })
20
+ await writeFile(join(dir, 'node_modules', 'foo', 'index.swift'), '// should skip\n')
21
+ await mkdir(join(dir, 'Pods'), { recursive: true })
22
+ await writeFile(join(dir, 'Pods', 'a.swift'), '// should skip\n')
23
+ await mkdir(join(dir, '.git'), { recursive: true })
24
+ await writeFile(join(dir, '.git', 'config'), '\n')
25
+ await writeFile(join(dir, 'README.md'), '# readme\n')
26
+ })
27
+ afterEach(async () => {
28
+ await rm(dir, { force: true, recursive: true })
29
+ })
30
+
31
+ function listEntries(tarPath: string): string[] {
32
+ const r = spawnSync('tar', ['-tzf', tarPath])
33
+ if (r.status !== 0) throw new Error(`tar -t failed: ${r.stderr?.toString()}`)
34
+ return r.stdout
35
+ .toString()
36
+ .split('\n')
37
+ .map((s) => s.trim())
38
+ .filter(Boolean)
39
+ }
40
+
41
+ describe('buildSourceBundleFromDir', () => {
42
+ test('ios bundle includes .swift + .m + .h, excludes android + node_modules + Pods', async () => {
43
+ const { cleanup, path } = await buildSourceBundleFromDir(dir, 'ios')
44
+ try {
45
+ const entries = listEntries(path)
46
+ expect(entries).toContain('Sources/MyApp/View.swift')
47
+ expect(entries).toContain('Sources/MyApp/Util.m')
48
+ // No .kt
49
+ expect(entries.find((e) => e.endsWith('.kt'))).toBeUndefined()
50
+ // No node_modules
51
+ expect(entries.find((e) => e.startsWith('node_modules/'))).toBeUndefined()
52
+ // No Pods
53
+ expect(entries.find((e) => e.startsWith('Pods/'))).toBeUndefined()
54
+ // No .git
55
+ expect(entries.find((e) => e.startsWith('.git/'))).toBeUndefined()
56
+ // No README
57
+ expect(entries.find((e) => e.endsWith('.md'))).toBeUndefined()
58
+ } finally {
59
+ await cleanup()
60
+ }
61
+ })
62
+
63
+ test('android bundle picks .kt + .java only', async () => {
64
+ const { cleanup, path } = await buildSourceBundleFromDir(dir, 'android')
65
+ try {
66
+ const entries = listEntries(path)
67
+ expect(entries).toContain('android/app/src/MainActivity.kt')
68
+ expect(entries.find((e) => e.endsWith('.swift'))).toBeUndefined()
69
+ expect(entries.find((e) => e.endsWith('.m'))).toBeUndefined()
70
+ } finally {
71
+ await cleanup()
72
+ }
73
+ })
74
+
75
+ test('empty directory of the right ext throws a clear error', async () => {
76
+ const empty = await mkdtemp(join(tmpdir(), 'sentori-cli-empty-'))
77
+ try {
78
+ await expect(buildSourceBundleFromDir(empty, 'ios')).rejects.toThrow(
79
+ /no ios source files/,
80
+ )
81
+ } finally {
82
+ await rm(empty, { force: true, recursive: true })
83
+ }
84
+ })
85
+ })
@@ -0,0 +1,105 @@
1
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+
5
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
6
+
7
+ import { uploadSourceBundle } from '../source-bundle.js'
8
+
9
+ const ADMIN = {
10
+ apiUrl: 'https://api.example.com/',
11
+ projectId: 'proj-1',
12
+ release: 'myapp@1.0.0',
13
+ token: 'sk_test',
14
+ }
15
+
16
+ let dir = ''
17
+ const origFetch = globalThis.fetch
18
+ let calls: { body: Buffer | null; headers: Headers; url: string }[]
19
+
20
+ beforeEach(async () => {
21
+ dir = await mkdtemp(join(tmpdir(), 'sentori-cli-srcbun-'))
22
+ calls = []
23
+ })
24
+ afterEach(async () => {
25
+ globalThis.fetch = origFetch
26
+ await rm(dir, { force: true, recursive: true })
27
+ })
28
+
29
+ function mockFetch(status = 201, body: object = { contentHash: 'h', kind: 'source_bundle_ios', sizeBytes: 12 }): void {
30
+ globalThis.fetch = (async (url: Request | string | URL, init?: RequestInit) => {
31
+ const b = init?.body
32
+ calls.push({
33
+ body: b instanceof Uint8Array ? Buffer.from(b) : null,
34
+ headers: new Headers(init?.headers),
35
+ url: String(url),
36
+ })
37
+ return new Response(JSON.stringify(body), { status })
38
+ }) as typeof fetch
39
+ }
40
+
41
+ describe('uploadSourceBundle', () => {
42
+ test('rejects unknown platform', async () => {
43
+ const path = join(dir, 'a.tar.gz')
44
+ await writeFile(path, Buffer.from([0x1f, 0x8b, 0x08, 0x00]))
45
+ mockFetch()
46
+ await expect(
47
+ uploadSourceBundle({ ...ADMIN, path, platform: 'windows' as unknown as 'ios' }),
48
+ ).rejects.toThrow(/platform/)
49
+ })
50
+
51
+ test('rejects non-gzip body before hitting network', async () => {
52
+ const path = join(dir, 'not-gzip.tar.gz')
53
+ await writeFile(path, Buffer.from('plain text not a gzip stream'))
54
+ mockFetch()
55
+ await expect(
56
+ uploadSourceBundle({ ...ADMIN, path, platform: 'ios' }),
57
+ ).rejects.toThrow(/gzip|1f 8b/i)
58
+ expect(calls.length).toBe(0)
59
+ })
60
+
61
+ test('rejects empty body', async () => {
62
+ const path = join(dir, 'empty.tar.gz')
63
+ await writeFile(path, Buffer.alloc(0))
64
+ mockFetch()
65
+ await expect(uploadSourceBundle({ ...ADMIN, path, platform: 'ios' })).rejects.toThrow(
66
+ /empty/,
67
+ )
68
+ })
69
+
70
+ test('posts to /admin/api/projects/<id>/source-bundles with platform + release query', async () => {
71
+ const path = join(dir, 'src.tar.gz')
72
+ // gzip magic + minimal payload — server-side validation passes.
73
+ await writeFile(path, Buffer.from([0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00]))
74
+ mockFetch(201, { contentHash: 'abc123', kind: 'source_bundle_ios', sizeBytes: 6 })
75
+
76
+ const r = await uploadSourceBundle({ ...ADMIN, path, platform: 'ios' })
77
+
78
+ expect(calls.length).toBe(1)
79
+ expect(calls[0]!.url).toContain('/admin/api/projects/proj-1/source-bundles?')
80
+ expect(calls[0]!.url).toContain('platform=ios')
81
+ expect(calls[0]!.url).toContain('release=myapp%401.0.0')
82
+ expect(calls[0]!.headers.get('authorization')).toBe('Bearer sk_test')
83
+ expect(calls[0]!.headers.get('content-type')).toBe('application/gzip')
84
+ expect(r.kind).toBe('source_bundle_ios')
85
+ expect(r.contentHash).toBe('abc123')
86
+ })
87
+
88
+ test('android platform routes the same shape', async () => {
89
+ const path = join(dir, 'src.tar.gz')
90
+ await writeFile(path, Buffer.from([0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00]))
91
+ mockFetch(201, { contentHash: 'def', kind: 'source_bundle_android', sizeBytes: 6 })
92
+ const r = await uploadSourceBundle({ ...ADMIN, path, platform: 'android' })
93
+ expect(calls[0]!.url).toContain('platform=android')
94
+ expect(r.kind).toBe('source_bundle_android')
95
+ })
96
+
97
+ test('surfaces server 4xx/5xx as Error with snippet', async () => {
98
+ const path = join(dir, 'src.tar.gz')
99
+ await writeFile(path, Buffer.from([0x1f, 0x8b]))
100
+ mockFetch(500, { error: { code: 'bad', message: 'boom on server' } })
101
+ await expect(uploadSourceBundle({ ...ADMIN, path, platform: 'ios' })).rejects.toThrow(
102
+ /500/,
103
+ )
104
+ })
105
+ })