@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.
- package/lib/index.js +268 -340
- package/lib/index.js.map +1 -1
- package/lib/issue.d.ts +16 -18
- package/lib/issue.d.ts.map +1 -1
- package/lib/issue.js +29 -32
- package/lib/issue.js.map +1 -1
- package/lib/lenient.d.ts +13 -0
- package/lib/lenient.d.ts.map +1 -0
- package/lib/lenient.js +25 -0
- package/lib/lenient.js.map +1 -0
- package/lib/mcp.d.ts +4 -2
- package/lib/mcp.d.ts.map +1 -1
- package/lib/mcp.js +43 -220
- package/lib/mcp.js.map +1 -1
- package/lib/native-artifacts.d.ts +0 -1
- package/lib/native-artifacts.d.ts.map +1 -1
- package/lib/native-artifacts.js +30 -41
- package/lib/native-artifacts.js.map +1 -1
- package/lib/probes.d.ts +10 -0
- package/lib/probes.d.ts.map +1 -0
- package/lib/probes.js +75 -0
- package/lib/probes.js.map +1 -0
- package/lib/react-native.d.ts.map +1 -1
- package/lib/react-native.js +17 -9
- package/lib/react-native.js.map +1 -1
- package/lib/upload.d.ts +10 -21
- package/lib/upload.d.ts.map +1 -1
- package/lib/upload.js +20 -57
- package/lib/upload.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/lenient.test.ts +32 -0
- package/src/__tests__/mcp.test.ts +20 -112
- package/src/__tests__/native-artifacts.test.ts +5 -12
- package/src/__tests__/probes.test.ts +41 -0
- package/src/index.ts +290 -362
- package/src/issue.ts +47 -46
- package/src/lenient.ts +39 -0
- package/src/mcp.ts +46 -215
- package/src/native-artifacts.ts +37 -44
- package/src/probes.ts +75 -0
- package/src/react-native.ts +16 -9
- package/src/upload.ts +30 -71
- package/src/__tests__/issue.test.ts +0 -95
- package/src/__tests__/source-bundle-from-dir.test.ts +0 -85
- package/src/__tests__/source-bundle.test.ts +0 -105
- package/src/__tests__/upload.test.ts +0 -121
- 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
|
+
}
|
package/src/react-native.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
2
|
-
|
|
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
|
-
|
|
5
|
-
|
|
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
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
53
|
-
const
|
|
54
|
-
if (
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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
|
-
|
|
71
|
-
|
|
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
|
|
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
|
-
})
|