@goliapkg/sentori-cli 0.6.0 → 1.0.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 +268 -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 +30 -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 +10 -21
  27. package/lib/upload.d.ts.map +1 -1
  28. package/lib/upload.js +20 -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 +5 -12
  34. package/src/__tests__/probes.test.ts +41 -0
  35. package/src/index.ts +290 -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 +37 -44
  40. package/src/probes.ts +75 -0
  41. package/src/react-native.ts +16 -9
  42. package/src/upload.ts +30 -71
  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,44 @@
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.
3
7
 
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
8
+ import { readFileSync } from 'node:fs'
9
+ import { basename } from 'node:path'
9
10
 
10
- export type UploadOptions = {
11
- release: string
12
- token: string
13
- /** Sentori API base, e.g. https://sentori.golia.jp or your host. */
11
+ export type UploadOpts = {
14
12
  apiUrl: string
15
- /** Files or directories. Directories are scanned one level deep. */
16
- paths: string[]
17
- dryRun?: boolean
18
- }
19
-
20
- export type UploadResult = {
21
- files: string[]
22
- uploaded?: number
23
- artifacts?: { kind: string; name: string }[]
24
- }
25
-
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)')
48
- }
49
- return deduped
13
+ token: string
14
+ release: string
15
+ kind: 'dsym' | 'proguard' | 'sourcemap'
16
+ path: string
17
+ /** Override the stored artifact name (defaults to the filename). */
18
+ name?: string
50
19
  }
51
20
 
52
- export async function uploadSourcemaps(opts: UploadOptions): Promise<UploadResult> {
53
- const files = await collectFiles(opts.paths)
54
- if (opts.dryRun) return { files }
21
+ export async function uploadArtifact(opts: UploadOpts): Promise<{ id: string }> {
22
+ const bytes = readFileSync(opts.path)
23
+ if (bytes.length === 0) throw new Error(`empty file: ${opts.path}`)
55
24
 
56
25
  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
- }
26
+ form.append('kind', opts.kind)
27
+ form.append(
28
+ 'file',
29
+ new Blob([new Uint8Array(bytes)]),
30
+ opts.name ?? basename(opts.path),
31
+ )
61
32
 
62
- const base = opts.apiUrl.replace(/\/+$/, '')
63
- const url = `${base}/admin/api/releases/${encodeURIComponent(opts.release)}/sourcemaps`
33
+ const url = `${opts.apiUrl.replace(/\/+$/, '')}/v1/releases/${encodeURIComponent(opts.release)}/artifacts`
64
34
  const resp = await fetch(url, {
65
- body: form,
66
- headers: { Authorization: `Bearer ${opts.token}` },
67
35
  method: 'POST',
36
+ headers: { Authorization: `Bearer ${opts.token}` },
37
+ body: form,
68
38
  })
69
39
  if (!resp.ok) {
70
- let detail = ''
71
- try {
72
- detail = await resp.text()
73
- } catch {
74
- // ignore
75
- }
76
- throw new Error(
77
- `${resp.status} ${resp.statusText}${detail ? ` — ${detail.slice(0, 300)}` : ''}`,
78
- )
79
- }
80
- const body = (await resp.json().catch(() => ({}))) as {
81
- artifacts?: { kind: string; name: string }[]
82
- uploaded?: number
40
+ const detail = await resp.text().catch(() => '')
41
+ throw new Error(`${resp.status} ${resp.statusText}${detail ? ` — ${detail.slice(0, 200)}` : ''}`)
83
42
  }
84
- return { artifacts: body.artifacts, files, uploaded: body.uploaded ?? files.length }
43
+ return (await resp.json()) as { id: string }
85
44
  }
@@ -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
- })
@@ -1,121 +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 { collectFiles, uploadSourcemaps } from '../upload.js'
8
-
9
- let dir = ''
10
- beforeEach(async () => {
11
- dir = await mkdtemp(join(tmpdir(), 'sentori-cli-test-'))
12
- await writeFile(join(dir, 'main.jsbundle'), '/*bundle*/')
13
- await writeFile(join(dir, 'main.jsbundle.map'), '{"version":3}')
14
- await writeFile(join(dir, 'app.js'), 'console.log(1)')
15
- await writeFile(join(dir, 'app.js.map'), '{"version":3}')
16
- await writeFile(join(dir, 'README.txt'), 'not a build artifact')
17
- })
18
- afterEach(async () => {
19
- await rm(dir, { force: true, recursive: true })
20
- })
21
-
22
- describe('collectFiles', () => {
23
- test('a directory yields its .map / .js / .bundle files, not others', async () => {
24
- const files = await collectFiles([dir])
25
- const names = files.map((f) => f.replace(`${dir}/`, '')).sort()
26
- expect(names).toEqual(['app.js', 'app.js.map', 'main.jsbundle', 'main.jsbundle.map'])
27
- })
28
-
29
- test('an explicit file is taken as-is regardless of extension', async () => {
30
- const files = await collectFiles([join(dir, 'README.txt')])
31
- expect(files).toEqual([join(dir, 'README.txt')])
32
- })
33
-
34
- test('dedupes when a file is named both directly and via its dir', async () => {
35
- const files = await collectFiles([dir, join(dir, 'app.js.map')])
36
- expect(files.filter((f) => f.endsWith('app.js.map'))).toHaveLength(1)
37
- })
38
-
39
- test('throws on a nonexistent path', async () => {
40
- await expect(collectFiles([join(dir, 'nope')])).rejects.toThrow('no such file or directory')
41
- })
42
-
43
- test('throws when a directory has no uploadable files', async () => {
44
- const empty = await mkdtemp(join(tmpdir(), 'sentori-cli-empty-'))
45
- try {
46
- await expect(collectFiles([empty])).rejects.toThrow('no .map')
47
- } finally {
48
- await rm(empty, { force: true, recursive: true })
49
- }
50
- })
51
- })
52
-
53
- describe('uploadSourcemaps', () => {
54
- test('dryRun returns the file list, makes no request', async () => {
55
- let called = false
56
- const orig = globalThis.fetch
57
- globalThis.fetch = (async () => {
58
- called = true
59
- return new Response()
60
- }) as typeof fetch
61
- try {
62
- const r = await uploadSourcemaps({
63
- apiUrl: 'https://api.example.com',
64
- dryRun: true,
65
- paths: [dir],
66
- release: 'app@1.0.0+1',
67
- token: 'st_pk_x',
68
- })
69
- expect(r.files).toHaveLength(4)
70
- expect(called).toBe(false)
71
- } finally {
72
- globalThis.fetch = orig
73
- }
74
- })
75
-
76
- test('POSTs multipart to /admin/api/releases/<release>/sourcemaps with the token', async () => {
77
- const calls: { headers: Headers; url: string }[] = []
78
- const orig = globalThis.fetch
79
- globalThis.fetch = (async (url: Request | string | URL, init?: RequestInit) => {
80
- calls.push({ headers: new Headers(init?.headers), url: String(url) })
81
- return new Response(JSON.stringify({ artifacts: [], uploaded: 4 }), {
82
- headers: { 'content-type': 'application/json' },
83
- status: 200,
84
- })
85
- }) as typeof fetch
86
- try {
87
- const r = await uploadSourcemaps({
88
- apiUrl: 'https://api.example.com/',
89
- paths: [dir],
90
- release: 'my app@1.0.0+1',
91
- token: 'st_pk_secret',
92
- })
93
- expect(calls).toHaveLength(1)
94
- expect(calls[0]?.url).toBe(
95
- 'https://api.example.com/admin/api/releases/my%20app%401.0.0%2B1/sourcemaps',
96
- )
97
- expect(calls[0]?.headers.get('authorization')).toBe('Bearer st_pk_secret')
98
- expect(r.uploaded).toBe(4)
99
- } finally {
100
- globalThis.fetch = orig
101
- }
102
- })
103
-
104
- test('throws with the server detail on a non-2xx response', async () => {
105
- const orig = globalThis.fetch
106
- globalThis.fetch = (async () =>
107
- new Response('release frozen', { status: 403, statusText: 'Forbidden' })) as typeof fetch
108
- try {
109
- await expect(
110
- uploadSourcemaps({
111
- apiUrl: 'https://api.example.com',
112
- paths: [dir],
113
- release: 'app@1.0.0+1',
114
- token: 'st_pk_x',
115
- }),
116
- ).rejects.toThrow('403 Forbidden — release frozen')
117
- } finally {
118
- globalThis.fetch = orig
119
- }
120
- })
121
- })