@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/lib/react-native.js
CHANGED
|
@@ -3,7 +3,8 @@ import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
-
import {
|
|
6
|
+
import { uploadArtifact } from './upload.js';
|
|
7
|
+
import { basename } from 'node:path';
|
|
7
8
|
/** Resolve `react-native/scripts/compose-source-maps.js` from the
|
|
8
9
|
* current project's node_modules. Returns null if react-native isn't
|
|
9
10
|
* installed or the version doesn't ship that script. */
|
|
@@ -52,14 +53,21 @@ export async function reactNativeUpload(opts) {
|
|
|
52
53
|
const composed = composeSourceMaps(opts.metroMap, opts.hermesMap);
|
|
53
54
|
try {
|
|
54
55
|
const paths = [composed, ...(opts.bundle ? [opts.bundle] : [])];
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
56
|
+
if (opts.dryRun)
|
|
57
|
+
return { files: paths };
|
|
58
|
+
let uploaded = 0;
|
|
59
|
+
for (const p of paths) {
|
|
60
|
+
await uploadArtifact({
|
|
61
|
+
apiUrl: opts.apiUrl,
|
|
62
|
+
token: opts.token,
|
|
63
|
+
release: opts.release,
|
|
64
|
+
kind: 'sourcemap',
|
|
65
|
+
path: p,
|
|
66
|
+
name: basename(p),
|
|
67
|
+
});
|
|
68
|
+
uploaded += 1;
|
|
69
|
+
}
|
|
70
|
+
return { files: paths, uploaded };
|
|
63
71
|
}
|
|
64
72
|
finally {
|
|
65
73
|
try {
|
package/lib/react-native.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"react-native.js","sourceRoot":"","sources":["../src/react-native.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAA;AAC9C,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,SAAS,CAAA;AACzD,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAC3C,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAA;AAChC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAA;AAEhC,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"react-native.js","sourceRoot":"","sources":["../src/react-native.ts"],"names":[],"mappings":"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"}
|
package/lib/upload.d.ts
CHANGED
|
@@ -1,24 +1,25 @@
|
|
|
1
|
-
export type
|
|
2
|
-
release: string;
|
|
3
|
-
token: string;
|
|
4
|
-
/** Sentori API base, e.g. https://sentori.golia.jp or your host. */
|
|
1
|
+
export type UploadOpts = {
|
|
5
2
|
apiUrl: string;
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
token: string;
|
|
4
|
+
release: string;
|
|
5
|
+
kind: 'dsym' | 'proguard' | 'sourcemap';
|
|
6
|
+
path: string;
|
|
7
|
+
/** Override the stored artifact name (defaults to the filename). */
|
|
8
|
+
name?: string;
|
|
9
9
|
};
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}[];
|
|
10
|
+
/** Gzip a payload for the wire (pass-through if it already is gzip),
|
|
11
|
+
* renaming `foo` → `foo.gz` to match. Shared by every artifact
|
|
12
|
+
* upload path so dSYM/mapping and sourcemap cannot drift. */
|
|
13
|
+
export declare function gzipForWire(bytes: Buffer | Uint8Array, name: string): {
|
|
14
|
+
wire: Uint8Array;
|
|
15
|
+
wireName: string;
|
|
17
16
|
};
|
|
18
|
-
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
export declare function
|
|
23
|
-
export declare function
|
|
17
|
+
/** Exact-range copy into a standalone ArrayBuffer. `wire.buffer` is
|
|
18
|
+
* NOT safe here: Node pools small Buffers, so the backing buffer can
|
|
19
|
+
* be 16 KB of pool (offset view) — a Blob built on it would ship
|
|
20
|
+
* garbage bytes around the payload. */
|
|
21
|
+
export declare function wireArrayBuffer(wire: Uint8Array): ArrayBuffer;
|
|
22
|
+
export declare function uploadArtifact(opts: UploadOpts): Promise<{
|
|
23
|
+
id: string;
|
|
24
|
+
}>;
|
|
24
25
|
//# sourceMappingURL=upload.d.ts.map
|
package/lib/upload.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"upload.d.ts","sourceRoot":"","sources":["../src/upload.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"upload.d.ts","sourceRoot":"","sources":["../src/upload.ts"],"names":[],"mappings":"AAiBA,MAAM,MAAM,UAAU,GAAG;IACvB,MAAM,EAAE,MAAM,CAAA;IACd,KAAK,EAAE,MAAM,CAAA;IACb,OAAO,EAAE,MAAM,CAAA;IACf,IAAI,EAAE,MAAM,GAAG,UAAU,GAAG,WAAW,CAAA;IACvC,IAAI,EAAE,MAAM,CAAA;IACZ,oEAAoE;IACpE,IAAI,CAAC,EAAE,MAAM,CAAA;CACd,CAAA;AAMD;;8DAE8D;AAC9D,wBAAgB,WAAW,CACzB,KAAK,EAAE,MAAM,GAAG,UAAU,EAC1B,IAAI,EAAE,MAAM,GACX;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAWxC;AAED;;;wCAGwC;AACxC,wBAAgB,eAAe,CAAC,IAAI,EAAE,UAAU,GAAG,WAAW,CAE7D;AAED,wBAAsB,cAAc,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,CAAC,CAoC9E"}
|
package/lib/upload.js
CHANGED
|
@@ -1,65 +1,73 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// the
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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.
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
14
|
+
import { basename } from 'node:path';
|
|
15
|
+
import { gzipSync } from 'node:zlib';
|
|
16
|
+
/** Transport-side limit on the current server (256 MB). Used only to
|
|
17
|
+
* produce a useful error message — the server is the authority. */
|
|
18
|
+
const WIRE_LIMIT = 256 * 1024 * 1024;
|
|
19
|
+
/** Gzip a payload for the wire (pass-through if it already is gzip),
|
|
20
|
+
* renaming `foo` → `foo.gz` to match. Shared by every artifact
|
|
21
|
+
* upload path so dSYM/mapping and sourcemap cannot drift. */
|
|
22
|
+
export function gzipForWire(bytes, name) {
|
|
23
|
+
const alreadyGz = bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b;
|
|
24
|
+
const wire = alreadyGz ? new Uint8Array(bytes) : new Uint8Array(gzipSync(bytes));
|
|
25
|
+
const wireName = alreadyGz || name.endsWith('.gz') ? name : `${name}.gz`;
|
|
26
|
+
if (wire.length > WIRE_LIMIT) {
|
|
27
|
+
throw new Error(`${name} is ${Math.round(wire.length / 1024 / 1024)} MB gzipped — ` +
|
|
28
|
+
`over the server's ${WIRE_LIMIT / 1024 / 1024} MB transport limit even compressed`);
|
|
29
29
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
return { wire, wireName };
|
|
31
|
+
}
|
|
32
|
+
/** Exact-range copy into a standalone ArrayBuffer. `wire.buffer` is
|
|
33
|
+
* NOT safe here: Node pools small Buffers, so the backing buffer can
|
|
34
|
+
* be 16 KB of pool (offset view) — a Blob built on it would ship
|
|
35
|
+
* garbage bytes around the payload. */
|
|
36
|
+
export function wireArrayBuffer(wire) {
|
|
37
|
+
return wire.buffer.slice(wire.byteOffset, wire.byteOffset + wire.byteLength);
|
|
35
38
|
}
|
|
36
|
-
export async function
|
|
37
|
-
const
|
|
38
|
-
if (
|
|
39
|
-
|
|
39
|
+
export async function uploadArtifact(opts) {
|
|
40
|
+
const bytes = readFileSync(opts.path);
|
|
41
|
+
if (bytes.length === 0)
|
|
42
|
+
throw new Error(`empty file: ${opts.path}`);
|
|
43
|
+
// Already-compressed input (foo.map.gz) is passed through; the
|
|
44
|
+
// server strips the .gz suffix from the stored name after inflating.
|
|
45
|
+
const { wire, wireName } = gzipForWire(bytes, opts.name ?? basename(opts.path));
|
|
40
46
|
const form = new FormData();
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
47
|
+
form.append('kind', opts.kind);
|
|
48
|
+
form.append('file', new Blob([wireArrayBuffer(wire)]), wireName);
|
|
49
|
+
const url = `${opts.apiUrl.replace(/\/+$/, '')}/v1/releases/${encodeURIComponent(opts.release)}/artifacts`;
|
|
50
|
+
let resp;
|
|
51
|
+
try {
|
|
52
|
+
resp = await fetch(url, {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: { Authorization: `Bearer ${opts.token}` },
|
|
55
|
+
body: form,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
// A proxy that hits its body-size cap tends to reset the
|
|
60
|
+
// connection instead of answering, which surfaces here as a bare
|
|
61
|
+
// network error. Say so — "fetch failed" cost one team a round of
|
|
62
|
+
// debugging before they found the 413 underneath.
|
|
63
|
+
throw new Error(`network error (${e instanceof Error ? e.message : String(e)}) — ` +
|
|
64
|
+
`if the file is large this can be a proxy body-size cap rejecting ` +
|
|
65
|
+
`the upload (${Math.round(wire.length / 1024 / 1024)} MB on the wire)`);
|
|
44
66
|
}
|
|
45
|
-
const base = opts.apiUrl.replace(/\/+$/, '');
|
|
46
|
-
const url = `${base}/admin/api/releases/${encodeURIComponent(opts.release)}/sourcemaps`;
|
|
47
|
-
const resp = await fetch(url, {
|
|
48
|
-
body: form,
|
|
49
|
-
headers: { Authorization: `Bearer ${opts.token}` },
|
|
50
|
-
method: 'POST',
|
|
51
|
-
});
|
|
52
67
|
if (!resp.ok) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
detail = await resp.text();
|
|
56
|
-
}
|
|
57
|
-
catch {
|
|
58
|
-
// ignore
|
|
59
|
-
}
|
|
60
|
-
throw new Error(`${resp.status} ${resp.statusText}${detail ? ` — ${detail.slice(0, 300)}` : ''}`);
|
|
68
|
+
const detail = await resp.text().catch(() => '');
|
|
69
|
+
throw new Error(`${resp.status} ${resp.statusText}${detail ? ` — ${detail.slice(0, 200)}` : ''}`);
|
|
61
70
|
}
|
|
62
|
-
|
|
63
|
-
return { artifacts: body.artifacts, files, uploaded: body.uploaded ?? files.length };
|
|
71
|
+
return (await resp.json());
|
|
64
72
|
}
|
|
65
73
|
//# sourceMappingURL=upload.js.map
|
package/lib/upload.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"upload.js","sourceRoot":"","sources":["../src/upload.ts"],"names":[],"mappings":"AAAA,
|
|
1
|
+
{"version":3,"file":"upload.js","sourceRoot":"","sources":["../src/upload.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goliapkg/sentori-cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Sentori CLI — upload source maps so dashboard stack traces resolve to your original source.",
|
|
5
5
|
"license": "Apache-2.0 OR MIT",
|
|
6
6
|
"author": "GOLIA K.K. <takagi@golia.jp> (https://golia.jp)",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/bun": "latest",
|
|
44
44
|
"@types/node": "*",
|
|
45
|
-
"typescript": "^
|
|
45
|
+
"typescript": "^7"
|
|
46
46
|
},
|
|
47
47
|
"publishConfig": {
|
|
48
48
|
"access": "public"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Build-time iron rule: upload failure exits 0 with a friendly,
|
|
2
|
+
// actionable note; --strict opts into a real non-zero.
|
|
3
|
+
|
|
4
|
+
import { describe, expect, test } from 'bun:test'
|
|
5
|
+
|
|
6
|
+
import { isStrict, lenientFail, stripStrict } from '../lenient.js'
|
|
7
|
+
|
|
8
|
+
describe('lenient upload contract', () => {
|
|
9
|
+
test('default: failure exits 0', () => {
|
|
10
|
+
const code = lenientFail(false, {
|
|
11
|
+
failure: 'network down',
|
|
12
|
+
impact: 'stacks stay minified',
|
|
13
|
+
retry: 'sentori-cli upload sourcemap ...',
|
|
14
|
+
})
|
|
15
|
+
expect(code).toBe(0)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('--strict: failure exits 1', () => {
|
|
19
|
+
const code = lenientFail(true, {
|
|
20
|
+
failure: 'network down',
|
|
21
|
+
impact: 'stacks stay minified',
|
|
22
|
+
retry: 'retry-cmd',
|
|
23
|
+
})
|
|
24
|
+
expect(code).toBe(1)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('strict flag parses and strips', () => {
|
|
28
|
+
expect(isStrict(['--release', 'r', '--strict'])).toBe(true)
|
|
29
|
+
expect(isStrict(['--release', 'r'])).toBe(false)
|
|
30
|
+
expect(stripStrict(['--strict', 'a'])).toEqual(['a'])
|
|
31
|
+
})
|
|
32
|
+
})
|
|
@@ -1,124 +1,32 @@
|
|
|
1
|
-
|
|
1
|
+
// The MCP tool surface mirrors the /api closed loop 1:1.
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
const ADMIN = {
|
|
6
|
-
apiUrl: 'https://api.example.com/',
|
|
7
|
-
projectId: 'proj-uuid',
|
|
8
|
-
token: 'sk_test',
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
const origFetch = globalThis.fetch
|
|
12
|
-
let calls: { body: string | null; headers: Headers; method: string; url: string }[]
|
|
3
|
+
import { describe, expect, test } from 'bun:test'
|
|
13
4
|
|
|
14
|
-
|
|
15
|
-
calls = []
|
|
16
|
-
})
|
|
17
|
-
afterEach(() => {
|
|
18
|
-
globalThis.fetch = origFetch
|
|
19
|
-
})
|
|
5
|
+
import { buildTools } from '../mcp.js'
|
|
20
6
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const b = init?.body
|
|
24
|
-
calls.push({
|
|
25
|
-
body: typeof b === 'string' ? b : null,
|
|
26
|
-
headers: new Headers(init?.headers),
|
|
27
|
-
method: init?.method ?? 'GET',
|
|
28
|
-
url: String(url),
|
|
29
|
-
})
|
|
30
|
-
return new Response(status === 204 ? '' : JSON.stringify(body), { status })
|
|
31
|
-
}) as typeof fetch
|
|
32
|
-
}
|
|
7
|
+
describe('mcp tools', () => {
|
|
8
|
+
const tools = buildTools()
|
|
33
9
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const names = new Set(tools.map((t) => t.name))
|
|
38
|
-
for (const expected of [
|
|
10
|
+
test('exactly the four closed-loop tools exist', () => {
|
|
11
|
+
expect(tools.map((t) => t.name).sort()).toEqual([
|
|
12
|
+
'sentori_issue_bundle',
|
|
39
13
|
'sentori_issue_list',
|
|
40
|
-
'
|
|
41
|
-
'
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
'sentori_issue_watch',
|
|
47
|
-
]) {
|
|
48
|
-
expect(names.has(expected)).toBe(true)
|
|
49
|
-
}
|
|
14
|
+
'sentori_issue_note',
|
|
15
|
+
'sentori_issue_resolve',
|
|
16
|
+
])
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('every tool has a description and an object schema', () => {
|
|
50
20
|
for (const t of tools) {
|
|
51
|
-
|
|
52
|
-
expect(typeof t.description).toBe('string')
|
|
53
|
-
expect(t.inputSchema).toBeDefined()
|
|
21
|
+
expect(t.description.length).toBeGreaterThan(20)
|
|
54
22
|
expect((t.inputSchema as { type?: string }).type).toBe('object')
|
|
55
23
|
}
|
|
56
24
|
})
|
|
57
|
-
})
|
|
58
25
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
await list.handler(
|
|
65
|
-
{ limit: 25, priority: 'p0', projectId: 'proj-uuid', status: 'active' },
|
|
66
|
-
ADMIN,
|
|
67
|
-
)
|
|
68
|
-
expect(calls.length).toBe(1)
|
|
69
|
-
expect(calls[0]!.url).toContain('/admin/api/projects/proj-uuid/issues?')
|
|
70
|
-
expect(calls[0]!.url).toContain('status=active')
|
|
71
|
-
expect(calls[0]!.url).toContain('priority=p0')
|
|
72
|
-
expect(calls[0]!.url).toContain('limit=25')
|
|
73
|
-
expect(calls[0]!.method).toBe('GET')
|
|
74
|
-
expect(calls[0]!.headers.get('authorization')).toBe('Bearer sk_test')
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
test('sentori_issue_get fetches issue + activity in parallel', async () => {
|
|
78
|
-
const tools = buildTools()
|
|
79
|
-
const get = tools.find((t) => t.name === 'sentori_issue_get')!
|
|
80
|
-
mockFetch(200, { stub: true })
|
|
81
|
-
await get.handler({ issueId: 'iss-uuid', projectId: 'proj-uuid' }, ADMIN)
|
|
82
|
-
expect(calls.length).toBe(2)
|
|
83
|
-
const paths = calls.map((c) => c.url).sort()
|
|
84
|
-
expect(paths[0]).toContain('/admin/api/projects/proj-uuid/issues/iss-uuid')
|
|
85
|
-
expect(paths[1]).toContain('/admin/api/projects/proj-uuid/issues/iss-uuid/activity')
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
test('sentori_issue_transition PATCHes status', async () => {
|
|
89
|
-
const tools = buildTools()
|
|
90
|
-
const t = tools.find((x) => x.name === 'sentori_issue_transition')!
|
|
91
|
-
mockFetch(200, { id: 'x' })
|
|
92
|
-
await t.handler({ issueId: 'iss', projectId: 'proj', status: 'resolved' }, ADMIN)
|
|
93
|
-
expect(calls[0]!.method).toBe('PATCH')
|
|
94
|
-
expect(JSON.parse(calls[0]!.body!).status).toBe('resolved')
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
test('sentori_issue_set_labels rejects non-string array entries', async () => {
|
|
98
|
-
const tools = buildTools()
|
|
99
|
-
const t = tools.find((x) => x.name === 'sentori_issue_set_labels')!
|
|
100
|
-
await expect(
|
|
101
|
-
t.handler({ issueId: 'iss', labels: ['ok', 42], projectId: 'proj' }, ADMIN),
|
|
102
|
-
).rejects.toThrow(/each label/)
|
|
103
|
-
})
|
|
104
|
-
|
|
105
|
-
test('sentori_issue_watch=true → PUT, watch=false → DELETE', async () => {
|
|
106
|
-
const tools = buildTools()
|
|
107
|
-
const t = tools.find((x) => x.name === 'sentori_issue_watch')!
|
|
108
|
-
mockFetch(204)
|
|
109
|
-
await t.handler({ issueId: 'iss', projectId: 'proj', watch: true }, ADMIN)
|
|
110
|
-
expect(calls[0]!.method).toBe('PUT')
|
|
111
|
-
calls.length = 0
|
|
112
|
-
await t.handler({ issueId: 'iss', projectId: 'proj', watch: false }, ADMIN)
|
|
113
|
-
expect(calls[0]!.method).toBe('DELETE')
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
test('handler surfaces a non-2xx as an Error', async () => {
|
|
117
|
-
const tools = buildTools()
|
|
118
|
-
const t = tools.find((x) => x.name === 'sentori_issue_comment')!
|
|
119
|
-
mockFetch(500, { error: 'boom' })
|
|
120
|
-
await expect(
|
|
121
|
-
t.handler({ body: 'hello', issueId: 'iss', projectId: 'proj' }, ADMIN),
|
|
122
|
-
).rejects.toThrow(/500/)
|
|
26
|
+
test('bundle/note/resolve require issueId', () => {
|
|
27
|
+
for (const name of ['sentori_issue_bundle', 'sentori_issue_note', 'sentori_issue_resolve']) {
|
|
28
|
+
const t = tools.find((x) => x.name === name)!
|
|
29
|
+
expect((t.inputSchema as { required?: string[] }).required).toContain('issueId')
|
|
30
|
+
}
|
|
123
31
|
})
|
|
124
32
|
})
|
|
@@ -13,8 +13,7 @@ import {
|
|
|
13
13
|
|
|
14
14
|
const ADMIN = {
|
|
15
15
|
apiUrl: 'https://api.example.com/',
|
|
16
|
-
|
|
17
|
-
token: 'sk_test',
|
|
16
|
+
token: 'sk_test',
|
|
18
17
|
}
|
|
19
18
|
|
|
20
19
|
let dir = ''
|
|
@@ -51,19 +50,17 @@ describe('uploadMapping', () => {
|
|
|
51
50
|
expect(calls).toHaveLength(1)
|
|
52
51
|
expect(calls[0]?.method).toBe('POST')
|
|
53
52
|
expect(calls[0]?.url).toBe(
|
|
54
|
-
'https://api.example.com/
|
|
53
|
+
'https://api.example.com/v1/releases/app%401.0.0%2B1/artifacts',
|
|
55
54
|
)
|
|
56
55
|
expect(calls[0]?.headers.get('authorization')).toBe('Bearer sk_test')
|
|
57
|
-
expect(calls[0]?.headers.get('content-type')).toBe('application/octet-stream')
|
|
58
|
-
expect(calls[0]?.body?.toString()).toContain('pg_map_id')
|
|
59
56
|
})
|
|
60
57
|
|
|
61
58
|
test('sets x-sentori-debug-id when given', async () => {
|
|
62
59
|
mockFetch()
|
|
63
60
|
const path = join(dir, 'mapping.txt')
|
|
64
61
|
await writeFile(path, 'x')
|
|
65
|
-
await uploadMapping({ ...ADMIN, debugId: '1234-uuid', path })
|
|
66
|
-
expect(calls[0]?.
|
|
62
|
+
await uploadMapping({ ...ADMIN, debugId: '1234-uuid', path, release: 'r' })
|
|
63
|
+
expect(calls[0]?.url).toContain('/v1/releases/r/artifacts')
|
|
67
64
|
})
|
|
68
65
|
|
|
69
66
|
test('throws on empty file', async () => {
|
|
@@ -96,11 +93,7 @@ describe('uploadDsym (explicit single-slice)', () => {
|
|
|
96
93
|
})
|
|
97
94
|
expect(calls).toHaveLength(1)
|
|
98
95
|
expect(calls[0]?.method).toBe('POST')
|
|
99
|
-
expect(calls[0]?.url).toContain('/
|
|
100
|
-
expect(calls[0]?.url).toContain('release=app%401.0.0%2B1')
|
|
101
|
-
expect(calls[0]?.url).toContain('objectName=Foo')
|
|
102
|
-
expect(calls[0]?.headers.get('x-sentori-debug-id')).toBe('1234ABCD-1234-1234-1234-1234567890AB')
|
|
103
|
-
expect(calls[0]?.headers.get('x-sentori-arch')).toBe('arm64')
|
|
96
|
+
expect(calls[0]?.url).toContain('/v1/releases/app%401.0.0%2B1/artifacts')
|
|
104
97
|
expect(r.slices).toEqual([{ arch: 'arm64', debugId: '1234ABCD-1234-1234-1234-1234567890AB' }])
|
|
105
98
|
})
|
|
106
99
|
})
|
|
@@ -130,3 +123,41 @@ describe('helpers', () => {
|
|
|
130
123
|
expect(slices).toEqual([])
|
|
131
124
|
})
|
|
132
125
|
})
|
|
126
|
+
|
|
127
|
+
describe('gzip on the wire', () => {
|
|
128
|
+
test('mapping upload body is gzip and filename gains .gz', async () => {
|
|
129
|
+
const captured: { name: string; bytes: Uint8Array }[] = []
|
|
130
|
+
globalThis.fetch = (async (_url: Request | string | URL, init?: RequestInit) => {
|
|
131
|
+
const form = init?.body as FormData
|
|
132
|
+
const file = form.get('file') as File
|
|
133
|
+
captured.push({
|
|
134
|
+
bytes: new Uint8Array(await file.arrayBuffer()),
|
|
135
|
+
name: file.name,
|
|
136
|
+
})
|
|
137
|
+
return new Response('{}', { status: 201 })
|
|
138
|
+
}) as typeof fetch
|
|
139
|
+
const path = join(dir, 'mapping.txt')
|
|
140
|
+
await writeFile(path, 'foo.bar -> a.b:\n'.repeat(1000))
|
|
141
|
+
await uploadMapping({ ...ADMIN, path, release: 'r' })
|
|
142
|
+
expect(captured).toHaveLength(1)
|
|
143
|
+
expect(captured[0]?.name).toEndWith('.gz')
|
|
144
|
+
expect(captured[0]?.bytes[0]).toBe(0x1f)
|
|
145
|
+
expect(captured[0]?.bytes[1]).toBe(0x8b)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('already-gzipped input passes through un-recompressed', async () => {
|
|
149
|
+
const { gzipSync } = await import('node:zlib')
|
|
150
|
+
const pre = gzipSync('already compressed')
|
|
151
|
+
const captured: Uint8Array[] = []
|
|
152
|
+
globalThis.fetch = (async (_url: Request | string | URL, init?: RequestInit) => {
|
|
153
|
+
const form = init?.body as FormData
|
|
154
|
+
const file = form.get('file') as File
|
|
155
|
+
captured.push(new Uint8Array(await file.arrayBuffer()))
|
|
156
|
+
return new Response('{}', { status: 201 })
|
|
157
|
+
}) as typeof fetch
|
|
158
|
+
const path = join(dir, 'mapping.txt.gz')
|
|
159
|
+
await writeFile(path, pre)
|
|
160
|
+
await uploadMapping({ ...ADMIN, path, release: 'r' })
|
|
161
|
+
expect(Buffer.from(captured[0] ?? [])).toEqual(pre)
|
|
162
|
+
})
|
|
163
|
+
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// The probe scanner: finds sentori.probe('REF') call sites, skips
|
|
2
|
+
// junk dirs, dedupes.
|
|
3
|
+
|
|
4
|
+
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
|
5
|
+
import { tmpdir } from 'node:os'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
9
|
+
|
|
10
|
+
import { scanProbes } from '../probes.js'
|
|
11
|
+
|
|
12
|
+
let dir = ''
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
dir = await mkdtemp(join(tmpdir(), 'sentori-probes-'))
|
|
15
|
+
})
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
await rm(dir, { force: true, recursive: true })
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('scanProbes', () => {
|
|
21
|
+
test('finds refs across quote styles and dedupes', async () => {
|
|
22
|
+
await writeFile(
|
|
23
|
+
join(dir, 'a.ts'),
|
|
24
|
+
`sentori.probe('SENT-1'); probe("SENT-2", {x:1}); sentori.probe(\`SENT-1\`)`,
|
|
25
|
+
)
|
|
26
|
+
await writeFile(join(dir, 'b.tsx'), `if (bad) { sentori.probe('SENT-3') }`)
|
|
27
|
+
expect(scanProbes(dir)).toEqual(['SENT-1', 'SENT-2', 'SENT-3'])
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('skips node_modules and non-source files', async () => {
|
|
31
|
+
await mkdir(join(dir, 'node_modules'), { recursive: true })
|
|
32
|
+
await writeFile(join(dir, 'node_modules', 'x.ts'), `probe('NOPE')`)
|
|
33
|
+
await writeFile(join(dir, 'notes.md'), `probe('ALSO-NOPE')`)
|
|
34
|
+
expect(scanProbes(dir)).toEqual([])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('dynamic refs are invisible (documented limitation)', async () => {
|
|
38
|
+
await writeFile(join(dir, 'c.ts'), 'sentori.probe(ref)')
|
|
39
|
+
expect(scanProbes(dir)).toEqual([])
|
|
40
|
+
})
|
|
41
|
+
})
|