@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.
- package/lib/index.js +269 -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 +34 -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 +21 -20
- package/lib/upload.d.ts.map +1 -1
- package/lib/upload.js +65 -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 +43 -12
- package/src/__tests__/probes.test.ts +41 -0
- package/src/index.ts +291 -362
- package/src/issue.ts +47 -46
- package/src/lenient.ts +39 -0
- package/src/mcp.ts +46 -215
- package/src/native-artifacts.ts +42 -44
- package/src/probes.ts +75 -0
- package/src/react-native.ts +16 -9
- package/src/upload.ts +78 -68
- 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,95 @@
|
|
|
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.
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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
|
|
53
|
-
const
|
|
54
|
-
if (
|
|
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
|
-
|
|
58
|
-
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
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
|
-
})
|