@goliapkg/sentori-cli 0.6.0 → 1.1.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.
Files changed (47) hide show
  1. package/lib/index.js +269 -340
  2. package/lib/index.js.map +1 -1
  3. package/lib/issue.d.ts +16 -18
  4. package/lib/issue.d.ts.map +1 -1
  5. package/lib/issue.js +29 -32
  6. package/lib/issue.js.map +1 -1
  7. package/lib/lenient.d.ts +13 -0
  8. package/lib/lenient.d.ts.map +1 -0
  9. package/lib/lenient.js +25 -0
  10. package/lib/lenient.js.map +1 -0
  11. package/lib/mcp.d.ts +4 -2
  12. package/lib/mcp.d.ts.map +1 -1
  13. package/lib/mcp.js +43 -220
  14. package/lib/mcp.js.map +1 -1
  15. package/lib/native-artifacts.d.ts +0 -1
  16. package/lib/native-artifacts.d.ts.map +1 -1
  17. package/lib/native-artifacts.js +34 -41
  18. package/lib/native-artifacts.js.map +1 -1
  19. package/lib/probes.d.ts +10 -0
  20. package/lib/probes.d.ts.map +1 -0
  21. package/lib/probes.js +75 -0
  22. package/lib/probes.js.map +1 -0
  23. package/lib/react-native.d.ts.map +1 -1
  24. package/lib/react-native.js +17 -9
  25. package/lib/react-native.js.map +1 -1
  26. package/lib/upload.d.ts +21 -20
  27. package/lib/upload.d.ts.map +1 -1
  28. package/lib/upload.js +65 -57
  29. package/lib/upload.js.map +1 -1
  30. package/package.json +2 -2
  31. package/src/__tests__/lenient.test.ts +32 -0
  32. package/src/__tests__/mcp.test.ts +20 -112
  33. package/src/__tests__/native-artifacts.test.ts +43 -12
  34. package/src/__tests__/probes.test.ts +41 -0
  35. package/src/index.ts +291 -362
  36. package/src/issue.ts +47 -46
  37. package/src/lenient.ts +39 -0
  38. package/src/mcp.ts +46 -215
  39. package/src/native-artifacts.ts +42 -44
  40. package/src/probes.ts +75 -0
  41. package/src/react-native.ts +16 -9
  42. package/src/upload.ts +78 -68
  43. package/src/__tests__/issue.test.ts +0 -95
  44. package/src/__tests__/source-bundle-from-dir.test.ts +0 -85
  45. package/src/__tests__/source-bundle.test.ts +0 -105
  46. package/src/__tests__/upload.test.ts +0 -121
  47. package/src/source-bundle.ts +0 -234
package/src/probes.ts ADDED
@@ -0,0 +1,75 @@
1
+ // `sentori-cli probes sync` — the tripwire registry scan
2
+ // (design.md §2). Statically scans source for `sentori.probe('REF')`
3
+ // / `probe("REF")` call sites and registers the refs against a
4
+ // release, so the server can tell a silent probe (fix holding) from
5
+ // deleted code.
6
+
7
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
8
+ import { join } from 'node:path'
9
+
10
+ const PROBE_RE = /\bprobe\(\s*['"`]([^'"`]{1,200})['"`]/g
11
+ const SCAN_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'])
12
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'lib', 'dist', 'build', '.expo'])
13
+ const MAX_FILES = 20_000
14
+
15
+ export function scanProbes(root: string): string[] {
16
+ const refs = new Set<string>()
17
+ let seen = 0
18
+ const walk = (dir: string): void => {
19
+ if (seen > MAX_FILES) return
20
+ let entries: string[]
21
+ try {
22
+ entries = readdirSync(dir)
23
+ } catch {
24
+ return
25
+ }
26
+ for (const name of entries) {
27
+ if (SKIP_DIRS.has(name)) continue
28
+ const p = join(dir, name)
29
+ let st
30
+ try {
31
+ st = statSync(p)
32
+ } catch {
33
+ continue
34
+ }
35
+ if (st.isDirectory()) {
36
+ walk(p)
37
+ } else if (st.isFile()) {
38
+ const dot = name.lastIndexOf('.')
39
+ if (dot === -1 || !SCAN_EXTS.has(name.slice(dot))) continue
40
+ seen += 1
41
+ try {
42
+ const text = readFileSync(p, 'utf8')
43
+ for (const m of text.matchAll(PROBE_RE)) {
44
+ const ref = m[1]
45
+ if (ref) refs.add(ref)
46
+ }
47
+ } catch {
48
+ // unreadable file — skip
49
+ }
50
+ }
51
+ }
52
+ }
53
+ walk(root)
54
+ return [...refs].sort()
55
+ }
56
+
57
+ export async function syncProbes(opts: {
58
+ apiUrl: string
59
+ token: string
60
+ release: string
61
+ refs: string[]
62
+ }): Promise<{ registered: number }> {
63
+ const resp = await fetch(`${opts.apiUrl.replace(/\/+$/, '')}/api/probes:sync`, {
64
+ method: 'POST',
65
+ headers: {
66
+ Authorization: `Bearer ${opts.token}`,
67
+ 'Content-Type': 'application/json',
68
+ },
69
+ body: JSON.stringify({ release: opts.release, refs: opts.refs }),
70
+ })
71
+ if (!resp.ok) {
72
+ throw new Error(`probes:sync ${resp.status} ${await resp.text().catch(() => '')}`)
73
+ }
74
+ return (await resp.json()) as { registered: number }
75
+ }
@@ -4,7 +4,8 @@ import { createRequire } from 'node:module'
4
4
  import { tmpdir } from 'node:os'
5
5
  import { join } from 'node:path'
6
6
 
7
- import { uploadSourcemaps } from './upload.js'
7
+ import { uploadArtifact } from './upload.js'
8
+ import { basename } from 'node:path'
8
9
 
9
10
  /** Resolve `react-native/scripts/compose-source-maps.js` from the
10
11
  * current project's node_modules. Returns null if react-native isn't
@@ -68,14 +69,20 @@ export async function reactNativeUpload(opts: RnUploadOptions): Promise<{
68
69
  const composed = composeSourceMaps(opts.metroMap, opts.hermesMap)
69
70
  try {
70
71
  const paths = [composed, ...(opts.bundle ? [opts.bundle] : [])]
71
- const r = await uploadSourcemaps({
72
- apiUrl: opts.apiUrl,
73
- dryRun: opts.dryRun,
74
- paths,
75
- release: opts.release,
76
- token: opts.token,
77
- })
78
- return { files: r.files, uploaded: r.uploaded }
72
+ if (opts.dryRun) return { files: paths }
73
+ let uploaded = 0
74
+ for (const p of paths) {
75
+ await uploadArtifact({
76
+ apiUrl: opts.apiUrl,
77
+ token: opts.token,
78
+ release: opts.release,
79
+ kind: 'sourcemap',
80
+ path: p,
81
+ name: basename(p),
82
+ })
83
+ uploaded += 1
84
+ }
85
+ return { files: paths, uploaded }
79
86
  } finally {
80
87
  try {
81
88
  rmSync(composed, { force: true })
package/src/upload.ts CHANGED
@@ -1,85 +1,95 @@
1
- import { readFile, readdir, stat } from 'node:fs/promises'
2
- import { basename, join } from 'node:path'
1
+ // Unified symbolication-artifact upload: sourcemap / dsym / proguard
2
+ // all land on `POST /v1/releases/{release}/artifacts` (multipart
3
+ // `kind` + `file`), authenticated with an api-scope token. A late
4
+ // upload triggers retro-symbolication server-side, which is what
5
+ // makes the lenient exit-0 contract honest — nothing is lost
6
+ // forever.
7
+ //
8
+ // Uploads are gzipped on the wire (server ≥ 2.1.0 inflates
9
+ // transparently). Symbolication artifacts compress well — a plain
10
+ // R8 mapping ~10:1, DWARF ~3:1 — and the server's transport cap is
11
+ // smaller than its decompressed cap precisely so that the big ones
12
+ // (a real RN app's main dSYM runs hundreds of MB) fit as gzip.
3
13
 
4
- // Files worth uploading: source maps, and the bundle they map (so the
5
- // dashboard can show the minified line too if a frame falls outside
6
- // the map). `.jsbundle` (iOS) and `.bundle` (Android) are RN's bundle
7
- // names; `.hbc` is the Hermes bytecode bundle.
8
- const UPLOADABLE = /\.(map|js|jsbundle|bundle|hbc)$/i
14
+ import { readFileSync } from 'node:fs'
15
+ import { basename } from 'node:path'
16
+ import { gzipSync } from 'node:zlib'
9
17
 
10
- export type UploadOptions = {
11
- release: string
12
- token: string
13
- /** Sentori API base, e.g. https://sentori.golia.jp or your host. */
18
+ export type UploadOpts = {
14
19
  apiUrl: string
15
- /** Files or directories. Directories are scanned one level deep. */
16
- paths: string[]
17
- dryRun?: boolean
20
+ token: string
21
+ release: string
22
+ kind: 'dsym' | 'proguard' | 'sourcemap'
23
+ path: string
24
+ /** Override the stored artifact name (defaults to the filename). */
25
+ name?: string
18
26
  }
19
27
 
20
- export type UploadResult = {
21
- files: string[]
22
- uploaded?: number
23
- artifacts?: { kind: string; name: string }[]
24
- }
28
+ /** Transport-side limit on the current server (256 MB). Used only to
29
+ * produce a useful error message — the server is the authority. */
30
+ const WIRE_LIMIT = 256 * 1024 * 1024
25
31
 
26
- /** Resolve `paths` (files or dirs) to a deduped list of files to upload.
27
- * A directory contributes its top-level `.map` / `.js` / `.bundle` /
28
- * `.hbc` files; a file given explicitly is taken as-is (even if its
29
- * extension isn't in the list — the caller asked for it). */
30
- export async function collectFiles(paths: string[]): Promise<string[]> {
31
- const out: string[] = []
32
- for (const p of paths) {
33
- const s = await stat(p).catch(() => null)
34
- if (!s) throw new Error(`no such file or directory: ${p}`)
35
- if (s.isDirectory()) {
36
- for (const entry of await readdir(p)) {
37
- const full = join(p, entry)
38
- const es = await stat(full).catch(() => null)
39
- if (es?.isFile() && UPLOADABLE.test(entry)) out.push(full)
40
- }
41
- } else {
42
- out.push(p)
43
- }
44
- }
45
- const deduped = [...new Set(out)]
46
- if (deduped.length === 0) {
47
- throw new Error('no .map / .js / .bundle files found in the given path(s)')
32
+ /** Gzip a payload for the wire (pass-through if it already is gzip),
33
+ * renaming `foo` `foo.gz` to match. Shared by every artifact
34
+ * upload path so dSYM/mapping and sourcemap cannot drift. */
35
+ export function gzipForWire(
36
+ bytes: Buffer | Uint8Array,
37
+ name: string,
38
+ ): { wire: Uint8Array; wireName: string } {
39
+ const alreadyGz = bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b
40
+ const wire = alreadyGz ? new Uint8Array(bytes) : new Uint8Array(gzipSync(bytes))
41
+ const wireName = alreadyGz || name.endsWith('.gz') ? name : `${name}.gz`
42
+ if (wire.length > WIRE_LIMIT) {
43
+ throw new Error(
44
+ `${name} is ${Math.round(wire.length / 1024 / 1024)} MB gzipped — ` +
45
+ `over the server's ${WIRE_LIMIT / 1024 / 1024} MB transport limit even compressed`,
46
+ )
48
47
  }
49
- return deduped
48
+ return { wire, wireName }
49
+ }
50
+
51
+ /** Exact-range copy into a standalone ArrayBuffer. `wire.buffer` is
52
+ * NOT safe here: Node pools small Buffers, so the backing buffer can
53
+ * be 16 KB of pool (offset view) — a Blob built on it would ship
54
+ * garbage bytes around the payload. */
55
+ export function wireArrayBuffer(wire: Uint8Array): ArrayBuffer {
56
+ return wire.buffer.slice(wire.byteOffset, wire.byteOffset + wire.byteLength) as ArrayBuffer
50
57
  }
51
58
 
52
- export async function uploadSourcemaps(opts: UploadOptions): Promise<UploadResult> {
53
- const files = await collectFiles(opts.paths)
54
- if (opts.dryRun) return { files }
59
+ export async function uploadArtifact(opts: UploadOpts): Promise<{ id: string }> {
60
+ const bytes = readFileSync(opts.path)
61
+ if (bytes.length === 0) throw new Error(`empty file: ${opts.path}`)
62
+
63
+ // Already-compressed input (foo.map.gz) is passed through; the
64
+ // server strips the .gz suffix from the stored name after inflating.
65
+ const { wire, wireName } = gzipForWire(bytes, opts.name ?? basename(opts.path))
55
66
 
56
67
  const form = new FormData()
57
- for (const f of files) {
58
- const buf = await readFile(f)
59
- form.append('file', new Blob([buf]), basename(f))
60
- }
68
+ form.append('kind', opts.kind)
69
+ form.append('file', new Blob([wireArrayBuffer(wire)]), wireName)
61
70
 
62
- const base = opts.apiUrl.replace(/\/+$/, '')
63
- const url = `${base}/admin/api/releases/${encodeURIComponent(opts.release)}/sourcemaps`
64
- const resp = await fetch(url, {
65
- body: form,
66
- headers: { Authorization: `Bearer ${opts.token}` },
67
- method: 'POST',
68
- })
69
- if (!resp.ok) {
70
- let detail = ''
71
- try {
72
- detail = await resp.text()
73
- } catch {
74
- // ignore
75
- }
71
+ const url = `${opts.apiUrl.replace(/\/+$/, '')}/v1/releases/${encodeURIComponent(opts.release)}/artifacts`
72
+ let resp: Response
73
+ try {
74
+ resp = await fetch(url, {
75
+ method: 'POST',
76
+ headers: { Authorization: `Bearer ${opts.token}` },
77
+ body: form,
78
+ })
79
+ } catch (e) {
80
+ // A proxy that hits its body-size cap tends to reset the
81
+ // connection instead of answering, which surfaces here as a bare
82
+ // network error. Say so — "fetch failed" cost one team a round of
83
+ // debugging before they found the 413 underneath.
76
84
  throw new Error(
77
- `${resp.status} ${resp.statusText}${detail ? ` ${detail.slice(0, 300)}` : ''}`,
85
+ `network error (${e instanceof Error ? e.message : String(e)}) — ` +
86
+ `if the file is large this can be a proxy body-size cap rejecting ` +
87
+ `the upload (${Math.round(wire.length / 1024 / 1024)} MB on the wire)`,
78
88
  )
79
89
  }
80
- const body = (await resp.json().catch(() => ({}))) as {
81
- artifacts?: { kind: string; name: string }[]
82
- uploaded?: number
90
+ if (!resp.ok) {
91
+ const detail = await resp.text().catch(() => '')
92
+ throw new Error(`${resp.status} ${resp.statusText}${detail ? ` — ${detail.slice(0, 200)}` : ''}`)
83
93
  }
84
- return { artifacts: body.artifacts, files, uploaded: body.uploaded ?? files.length }
94
+ return (await resp.json()) as { id: string }
85
95
  }
@@ -1,95 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
-
3
- import { formatIssueLine, issueList, issuePatch } from '../issue.js'
4
-
5
- const cfg = {
6
- apiUrl: 'https://api.example.com/',
7
- projectId: 'proj-1',
8
- token: 'sk_test',
9
- }
10
-
11
- const sample = {
12
- errorType: 'TypeError',
13
- eventCount: 17,
14
- id: 'issue-1',
15
- lastSeen: '2026-05-13T00:00:00Z',
16
- messageSample: 'undefined is not an object',
17
- status: 'active' as const,
18
- }
19
-
20
- const origFetch = globalThis.fetch
21
- let calls: { body: string | undefined; headers: Headers; method: string; url: string }[]
22
- beforeEach(() => {
23
- calls = []
24
- })
25
- afterEach(() => {
26
- globalThis.fetch = origFetch
27
- })
28
-
29
- function mockFetch(respBody: string, init: ResponseInit = { status: 200 }): void {
30
- globalThis.fetch = (async (url: Request | string | URL, opts?: RequestInit) => {
31
- calls.push({
32
- body: typeof opts?.body === 'string' ? opts.body : undefined,
33
- headers: new Headers(opts?.headers),
34
- method: opts?.method ?? 'GET',
35
- url: String(url),
36
- })
37
- return new Response(respBody, {
38
- headers: { 'content-type': 'application/json' },
39
- ...init,
40
- })
41
- }) as typeof fetch
42
- }
43
-
44
- describe('issueList', () => {
45
- test('GETs /admin/api/projects/<id>/issues with query params + Bearer', async () => {
46
- mockFetch(JSON.stringify([sample]))
47
- const rows = await issueList({ config: cfg, limit: 20, status: 'active' })
48
- expect(calls).toHaveLength(1)
49
- expect(calls[0]?.method).toBe('GET')
50
- expect(calls[0]?.url).toBe(
51
- 'https://api.example.com/admin/api/projects/proj-1/issues?status=active&limit=20',
52
- )
53
- expect(calls[0]?.headers.get('authorization')).toBe('Bearer sk_test')
54
- expect(rows).toHaveLength(1)
55
- expect(rows[0]?.id).toBe('issue-1')
56
- })
57
-
58
- test('omits empty query params', async () => {
59
- mockFetch(JSON.stringify([]))
60
- await issueList({ config: cfg })
61
- expect(calls[0]?.url).toBe('https://api.example.com/admin/api/projects/proj-1/issues')
62
- })
63
-
64
- test('throws with server detail on non-2xx', async () => {
65
- mockFetch('forbidden', { status: 403, statusText: 'Forbidden' })
66
- await expect(issueList({ config: cfg })).rejects.toThrow('403 Forbidden — forbidden')
67
- })
68
- })
69
-
70
- describe('issuePatch', () => {
71
- test('PATCHes /issues/<id> with the body', async () => {
72
- mockFetch(JSON.stringify({ ...sample, status: 'resolved' }))
73
- const updated = await issuePatch(cfg, 'issue-1', {
74
- resolvedInRelease: 'app@1.2.4+457',
75
- status: 'resolved',
76
- })
77
- expect(calls[0]?.method).toBe('PATCH')
78
- expect(calls[0]?.url).toBe('https://api.example.com/admin/api/projects/proj-1/issues/issue-1')
79
- expect(JSON.parse(calls[0]?.body ?? '{}')).toEqual({
80
- resolvedInRelease: 'app@1.2.4+457',
81
- status: 'resolved',
82
- })
83
- expect(updated.status).toBe('resolved')
84
- })
85
- })
86
-
87
- describe('formatIssueLine', () => {
88
- test('renders id + status + title + event count on one line', () => {
89
- const line = formatIssueLine(sample)
90
- expect(line).toContain('issue-1')
91
- expect(line).toContain('active')
92
- expect(line).toContain('TypeError: undefined is not an object')
93
- expect(line).toContain('17×')
94
- })
95
- })
@@ -1,85 +0,0 @@
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
- })
@@ -1,105 +0,0 @@
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
- })