@tikoci/rosetta 0.8.6 → 0.8.8
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/package.json +1 -1
- package/src/browse.ts +5 -3
- package/src/eval/retrieval.ts +469 -0
- package/src/eval/self-supervised.ts +585 -0
- package/src/mcp-contract.test.ts +263 -0
- package/src/query.test.ts +43 -0
- package/src/query.ts +39 -2
- package/src/release.test.ts +11 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp-contract.test.ts — MCP tool surface contract tests (Phase 2).
|
|
3
|
+
*
|
|
4
|
+
* Guards against silent breaking changes to the MCP tool registry:
|
|
5
|
+
* - Block A: Frozen 13-tool list + workflow-arrow (→) convention in descriptions
|
|
6
|
+
* - Block B: Token-budget guardrails for 10 canonical queries (rough chars/4)
|
|
7
|
+
* - Block C: Shape snapshots — fingerprint the *contract* (keys, counts,
|
|
8
|
+
* classifier output), NOT the corpus. Deliberately omits page IDs
|
|
9
|
+
* and titles so DB refreshes don't churn snapshots.
|
|
10
|
+
*
|
|
11
|
+
* These are fast, deterministic, CI-runnable structural tests. No LLM calls,
|
|
12
|
+
* no network. Use the real local DB (ros-help.db) for stable FTS results.
|
|
13
|
+
*
|
|
14
|
+
* When adding/removing/renaming a tool: update EXPECTED_TOOLS below AND add
|
|
15
|
+
* a CHANGELOG entry under [Unreleased] → Added/Changed/Removed. The test is
|
|
16
|
+
* designed to force an explicit decision.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, expect, test } from "bun:test";
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
|
|
22
|
+
const ROOT = path.resolve(import.meta.dirname, "..");
|
|
23
|
+
|
|
24
|
+
// Block A (static file parse) runs unconditionally. Blocks B and C need the
|
|
25
|
+
// real populated DB singleton. When another test file (extract-videos,
|
|
26
|
+
// query, etc.) has already pinned the db.ts singleton to :memory:, we skip
|
|
27
|
+
// the DB-dependent blocks — running `bun test src/mcp-contract.test.ts`
|
|
28
|
+
// alone exercises them, and CI's `bun test` still gets Block A coverage.
|
|
29
|
+
const { searchAll } = await import("./query.ts");
|
|
30
|
+
const { DB_PATH, getDbStats } = await import("./db.ts");
|
|
31
|
+
|
|
32
|
+
// getDbStats() throws if tables don't exist (clean checkout before any DB build).
|
|
33
|
+
// Guard defensively: any failure → treat as "DB not usable" and skip B/C.
|
|
34
|
+
function dbPagesOrZero(): number {
|
|
35
|
+
try {
|
|
36
|
+
return getDbStats().pages;
|
|
37
|
+
} catch {
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const dbPages = dbPagesOrZero();
|
|
43
|
+
const dbIsReal = DB_PATH !== ":memory:" && dbPages > 100;
|
|
44
|
+
const skipReason = dbIsReal
|
|
45
|
+
? ""
|
|
46
|
+
: `DB singleton is "${DB_PATH}" (pages=${dbPages}); run \`bun test src/mcp-contract.test.ts\` solo against a populated DB for Blocks B/C.`;
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Block A: Frozen tool registry
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
describe("Frozen tool registry", () => {
|
|
53
|
+
const EXPECTED_TOOLS = [
|
|
54
|
+
"routeros_search",
|
|
55
|
+
"routeros_get_page",
|
|
56
|
+
"routeros_lookup_property",
|
|
57
|
+
"routeros_command_tree",
|
|
58
|
+
"routeros_stats",
|
|
59
|
+
"routeros_search_changelogs",
|
|
60
|
+
"routeros_dude_search",
|
|
61
|
+
"routeros_dude_get_page",
|
|
62
|
+
"routeros_command_version_check",
|
|
63
|
+
"routeros_command_diff",
|
|
64
|
+
"routeros_device_lookup",
|
|
65
|
+
"routeros_search_tests",
|
|
66
|
+
"routeros_current_versions",
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
test("exactly 13 tools registered", () => {
|
|
70
|
+
const mcpSrc = readFileSync(path.join(ROOT, "src/mcp.ts"), "utf-8");
|
|
71
|
+
// Extract tool names from server.registerTool("<name>", patterns
|
|
72
|
+
const toolMatches = mcpSrc.matchAll(/server\.registerTool\(\s*["']([^"']+)["']/g);
|
|
73
|
+
const foundTools = Array.from(toolMatches, (m) => m[1]);
|
|
74
|
+
|
|
75
|
+
expect(foundTools.length).toBe(13);
|
|
76
|
+
expect(foundTools.sort()).toEqual(EXPECTED_TOOLS.sort());
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("all tools have workflow arrow (→) in description", () => {
|
|
80
|
+
const mcpSrc = readFileSync(path.join(ROOT, "src/mcp.ts"), "utf-8");
|
|
81
|
+
|
|
82
|
+
// Terminal/informational tools that don't have natural follow-ups
|
|
83
|
+
// (identified during Phase 2 implementation as missing workflow arrows)
|
|
84
|
+
const KNOWN_EXCEPTIONS = ["routeros_stats", "routeros_current_versions"];
|
|
85
|
+
|
|
86
|
+
// Extract each complete registerTool block (tool name to closing paren before next registerTool)
|
|
87
|
+
// Split on registerTool calls, then extract name + description from each block
|
|
88
|
+
const toolBlocks = mcpSrc.split(/(?=server\.registerTool\()/);
|
|
89
|
+
|
|
90
|
+
const toolsWithoutArrow: string[] = [];
|
|
91
|
+
|
|
92
|
+
for (const block of toolBlocks) {
|
|
93
|
+
// Extract tool name
|
|
94
|
+
const nameMatch = block.match(/server\.registerTool\(\s*["']([^"']+)["']/);
|
|
95
|
+
if (!nameMatch) continue;
|
|
96
|
+
|
|
97
|
+
const toolName = nameMatch[1];
|
|
98
|
+
|
|
99
|
+
// Extract description (from `description:` to the closing `inputSchema:`)
|
|
100
|
+
// This captures the full description including embedded backticks
|
|
101
|
+
const descMatch = block.match(/description:\s*`([\s\S]*?)`\s*,\s*inputSchema:/);
|
|
102
|
+
if (!descMatch) {
|
|
103
|
+
// Some tools might not have inputSchema, try alternate pattern
|
|
104
|
+
const altMatch = block.match(/description:\s*`([\s\S]*?)`\s*,?\s*\}/);
|
|
105
|
+
if (!altMatch) continue;
|
|
106
|
+
|
|
107
|
+
const description = altMatch[1];
|
|
108
|
+
if (!description.includes("→") && !KNOWN_EXCEPTIONS.includes(toolName)) {
|
|
109
|
+
toolsWithoutArrow.push(toolName);
|
|
110
|
+
}
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const description = descMatch[1];
|
|
115
|
+
if (!description.includes("→") && !KNOWN_EXCEPTIONS.includes(toolName)) {
|
|
116
|
+
toolsWithoutArrow.push(toolName);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (toolsWithoutArrow.length > 0) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`Tools lacking workflow arrow (→) convention: ${toolsWithoutArrow.join(", ")}`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Ensure we found tool descriptions (sanity check for regex)
|
|
127
|
+
const totalFound = Array.from(
|
|
128
|
+
mcpSrc.matchAll(/server\.registerTool\(\s*["']([^"']+)["']/g),
|
|
129
|
+
).length;
|
|
130
|
+
expect(totalFound).toBe(13);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Block B: Token-budget guardrails
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
describe.skipIf(!dbIsReal)(`Token-budget guardrails${dbIsReal ? "" : ` [skipped: ${skipReason}]`}`, () => {
|
|
139
|
+
/**
|
|
140
|
+
* Rough token estimator: 1 token ≈ 4 chars for JSON.
|
|
141
|
+
* This is a guardrail to catch 10x regressions, not precise billing.
|
|
142
|
+
*/
|
|
143
|
+
function estimateTokens(obj: unknown): number {
|
|
144
|
+
return Math.ceil(JSON.stringify(obj).length / 4);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const QUERIES: Array<{ query: string; limit: number; budget: number }> = [
|
|
148
|
+
{ query: "dhcp server", limit: 8, budget: 8000 },
|
|
149
|
+
{ query: "/ip/firewall/filter", limit: 8, budget: 8000 },
|
|
150
|
+
{ query: "bridge vlan", limit: 8, budget: 8000 },
|
|
151
|
+
{ query: "hAP ax3", limit: 8, budget: 6000 },
|
|
152
|
+
{ query: "what changed in 7.22.1", limit: 8, budget: 8000 },
|
|
153
|
+
{ query: "BGP", limit: 8, budget: 8000 },
|
|
154
|
+
{ query: "container setup", limit: 8, budget: 8000 },
|
|
155
|
+
{ query: "disabled property", limit: 8, budget: 6000 },
|
|
156
|
+
{ query: "CAPsMAN", limit: 20, budget: 16000 }, // hunger knob test
|
|
157
|
+
{ query: "firewall filter chain", limit: 8, budget: 8000 },
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
for (const { query, limit, budget } of QUERIES) {
|
|
161
|
+
test(`"${query}" (limit=${limit}) ≤ ${budget} tokens`, () => {
|
|
162
|
+
const result = searchAll(query, limit);
|
|
163
|
+
const tokens = estimateTokens(result);
|
|
164
|
+
|
|
165
|
+
if (tokens > budget) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`Token budget exceeded: "${query}" | actual=${tokens} tokens | budget=${budget}`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Log for the record (visible on test run)
|
|
172
|
+
console.log(` ✓ "${query}" (limit=${limit}): ${tokens} tokens`);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Block C: Response-shape invariants
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
//
|
|
181
|
+
// This block asserts shape contracts that hold on any populated DB — no
|
|
182
|
+
// file-based snapshots (they coupled to the local dev DB's extraction state
|
|
183
|
+
// and drifted against the full CI-built DB's richer `related_buckets`). The
|
|
184
|
+
// invariants here protect against silent breakage of the searchAll return
|
|
185
|
+
// shape while staying portable across DBs of varying richness.
|
|
186
|
+
//
|
|
187
|
+
// Corpus-linked expectations ("this page must rank for this query") live in
|
|
188
|
+
// fixtures/eval/queries.json (Phase 0) — that's the right surface for them.
|
|
189
|
+
|
|
190
|
+
describe.skipIf(!dbIsReal)(`Response-shape invariants${dbIsReal ? "" : ` [skipped: ${skipReason}]`}`, () => {
|
|
191
|
+
type Invariant = {
|
|
192
|
+
query: string;
|
|
193
|
+
limit: number;
|
|
194
|
+
classifier_expected: Record<string, unknown>;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const INVARIANTS: Invariant[] = [
|
|
198
|
+
{ query: "dhcp server", limit: 8, classifier_expected: { topics: ["dhcp"] } },
|
|
199
|
+
{
|
|
200
|
+
query: "bridge vlan",
|
|
201
|
+
limit: 8,
|
|
202
|
+
classifier_expected: { command_path: "/bridge/vlan", topics: ["bridge", "vlan"] },
|
|
203
|
+
},
|
|
204
|
+
{ query: "hAP ax3", limit: 8, classifier_expected: { device: "hAP" } },
|
|
205
|
+
{
|
|
206
|
+
query: "what changed in 7.22.1",
|
|
207
|
+
limit: 8,
|
|
208
|
+
classifier_expected: { version: "7.22.1" },
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
query: "/ip/firewall/filter",
|
|
212
|
+
limit: 8,
|
|
213
|
+
classifier_expected: {
|
|
214
|
+
command_path: "/ip/firewall/filter",
|
|
215
|
+
topics: ["ip", "firewall", "filter"],
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
];
|
|
219
|
+
|
|
220
|
+
for (const { query, limit, classifier_expected } of INVARIANTS) {
|
|
221
|
+
test(`shape: "${query}"`, () => {
|
|
222
|
+
const result = searchAll(query, limit);
|
|
223
|
+
|
|
224
|
+
// Top-level keys
|
|
225
|
+
expect(result).toHaveProperty("query", query);
|
|
226
|
+
expect(result).toHaveProperty("classified");
|
|
227
|
+
expect(result).toHaveProperty("pages");
|
|
228
|
+
expect(result).toHaveProperty("related");
|
|
229
|
+
expect(result).toHaveProperty("next_steps");
|
|
230
|
+
expect(result).toHaveProperty("total_pages");
|
|
231
|
+
|
|
232
|
+
// Classifier output is DB-independent (pure regex) — assert exact subset
|
|
233
|
+
for (const [k, v] of Object.entries(classifier_expected)) {
|
|
234
|
+
expect(result.classified).toHaveProperty(k, v);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Pages: at least one hit on a populated DB, bounded by limit
|
|
238
|
+
expect(Array.isArray(result.pages)).toBe(true);
|
|
239
|
+
expect(result.pages.length).toBeGreaterThan(0);
|
|
240
|
+
expect(result.pages.length).toBeLessThanOrEqual(limit);
|
|
241
|
+
expect(result.total_pages).toBeGreaterThanOrEqual(result.pages.length);
|
|
242
|
+
for (const page of result.pages) {
|
|
243
|
+
expect(page).toHaveProperty("id");
|
|
244
|
+
expect(page).toHaveProperty("title");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Related block: always an object; buckets that are present are arrays
|
|
248
|
+
expect(typeof result.related).toBe("object");
|
|
249
|
+
for (const [bucket, entries] of Object.entries(result.related)) {
|
|
250
|
+
if (Array.isArray(entries)) {
|
|
251
|
+
expect(entries.length).toBeGreaterThan(0);
|
|
252
|
+
} else {
|
|
253
|
+
// command_node is a single object when present
|
|
254
|
+
expect(entries).toBeTruthy();
|
|
255
|
+
}
|
|
256
|
+
expect(bucket.length).toBeGreaterThan(0);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// next_steps: array of hint strings
|
|
260
|
+
expect(Array.isArray(result.next_steps)).toBe(true);
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
});
|
package/src/query.test.ts
CHANGED
|
@@ -43,6 +43,7 @@ const {
|
|
|
43
43
|
searchDeviceTests,
|
|
44
44
|
getTestResultMeta,
|
|
45
45
|
normalizeDeviceQuery,
|
|
46
|
+
truncateDeviceTestResultsPrefer512,
|
|
46
47
|
searchVideos,
|
|
47
48
|
searchDude,
|
|
48
49
|
getDudePage,
|
|
@@ -1426,6 +1427,48 @@ describe("dataset CSV exports", () => {
|
|
|
1426
1427
|
});
|
|
1427
1428
|
});
|
|
1428
1429
|
|
|
1430
|
+
describe("truncateDeviceTestResultsPrefer512", () => {
|
|
1431
|
+
test("keeps all 512B rows when truncating", () => {
|
|
1432
|
+
const rows = [
|
|
1433
|
+
{ packet_size: 1518, id: "a" },
|
|
1434
|
+
{ packet_size: 512, id: "b" },
|
|
1435
|
+
{ packet_size: 64, id: "c" },
|
|
1436
|
+
{ packet_size: 512, id: "d" },
|
|
1437
|
+
];
|
|
1438
|
+
const res = truncateDeviceTestResultsPrefer512(rows, 2);
|
|
1439
|
+
|
|
1440
|
+
expect(res.rows.map((r) => r.id)).toEqual(["b", "d"]);
|
|
1441
|
+
expect(res.omitted).toBe(2);
|
|
1442
|
+
});
|
|
1443
|
+
|
|
1444
|
+
test("fills remaining slots with non-512 rows", () => {
|
|
1445
|
+
const rows = [
|
|
1446
|
+
{ packet_size: 1518, id: "a" },
|
|
1447
|
+
{ packet_size: 512, id: "b" },
|
|
1448
|
+
{ packet_size: 64, id: "c" },
|
|
1449
|
+
{ packet_size: 512, id: "d" },
|
|
1450
|
+
];
|
|
1451
|
+
const res = truncateDeviceTestResultsPrefer512(rows, 3);
|
|
1452
|
+
|
|
1453
|
+
expect(res.rows.map((r) => r.id)).toEqual(["b", "d", "a"]);
|
|
1454
|
+
expect(res.omitted).toBe(1);
|
|
1455
|
+
});
|
|
1456
|
+
|
|
1457
|
+
test("returns all 512B rows even if they exceed maxRows", () => {
|
|
1458
|
+
const rows = [
|
|
1459
|
+
{ packet_size: 512, id: "a" },
|
|
1460
|
+
{ packet_size: 512, id: "b" },
|
|
1461
|
+
{ packet_size: 1518, id: "c" },
|
|
1462
|
+
{ packet_size: 512, id: "d" },
|
|
1463
|
+
{ packet_size: 64, id: "e" },
|
|
1464
|
+
];
|
|
1465
|
+
const res = truncateDeviceTestResultsPrefer512(rows, 2);
|
|
1466
|
+
|
|
1467
|
+
expect(res.rows.map((r) => r.id)).toEqual(["a", "b", "d"]);
|
|
1468
|
+
expect(res.omitted).toBe(2);
|
|
1469
|
+
});
|
|
1470
|
+
});
|
|
1471
|
+
|
|
1429
1472
|
describe("getTestResultMeta", () => {
|
|
1430
1473
|
test("returns distinct values", () => {
|
|
1431
1474
|
const meta = getTestResultMeta();
|
package/src/query.ts
CHANGED
|
@@ -298,8 +298,8 @@ export function searchAll(query: string, limit = DEFAULT_LIMIT): SearchAllRespon
|
|
|
298
298
|
path: node.path,
|
|
299
299
|
type: node.type,
|
|
300
300
|
description: node.description,
|
|
301
|
-
...(node.page_id
|
|
302
|
-
? { linked_page: { id: node.page_id, title: node.page_title
|
|
301
|
+
...(node.page_id && node.page_title && node.page_url
|
|
302
|
+
? { linked_page: { id: node.page_id, title: node.page_title, url: node.page_url } }
|
|
303
303
|
: {}),
|
|
304
304
|
};
|
|
305
305
|
}
|
|
@@ -1330,6 +1330,43 @@ export type DeviceTestResult = {
|
|
|
1330
1330
|
throughput_mbps: number | null;
|
|
1331
1331
|
};
|
|
1332
1332
|
|
|
1333
|
+
/**
|
|
1334
|
+
* Truncate benchmark rows for display while always keeping all 512-byte rows.
|
|
1335
|
+
*
|
|
1336
|
+
* Used by both TUI and MCP-facing adapters when they need a compact view.
|
|
1337
|
+
* If the 512B bucket itself exceeds maxRows, return all 512B rows anyway.
|
|
1338
|
+
*/
|
|
1339
|
+
export function truncateDeviceTestResultsPrefer512<T extends { packet_size: number }>(
|
|
1340
|
+
rows: T[],
|
|
1341
|
+
maxRows: number,
|
|
1342
|
+
): { rows: T[]; omitted: number } {
|
|
1343
|
+
if (rows.length === 0) return { rows: [], omitted: 0 };
|
|
1344
|
+
|
|
1345
|
+
const safeMax = Math.max(0, Math.floor(maxRows));
|
|
1346
|
+
const rows512 = rows.filter((r) => r.packet_size === 512);
|
|
1347
|
+
const non512 = rows.filter((r) => r.packet_size !== 512);
|
|
1348
|
+
|
|
1349
|
+
if (safeMax === 0) {
|
|
1350
|
+
return { rows: rows512, omitted: rows.length - rows512.length };
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
if (rows.length <= safeMax) {
|
|
1354
|
+
return { rows, omitted: 0 };
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
if (rows512.length === 0) {
|
|
1358
|
+
return { rows: rows.slice(0, safeMax), omitted: rows.length - safeMax };
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
if (rows512.length >= safeMax) {
|
|
1362
|
+
return { rows: rows512, omitted: rows.length - rows512.length };
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
const takeNon512 = safeMax - rows512.length;
|
|
1366
|
+
const selected = [...rows512, ...non512.slice(0, takeNon512)];
|
|
1367
|
+
return { rows: selected, omitted: rows.length - selected.length };
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1333
1370
|
/** Map Unicode superscript digits to ASCII equivalents for product name matching.
|
|
1334
1371
|
* MikroTik uses ² and ³ in product names (hAP ax³, hAP ac²), but users type ASCII. */
|
|
1335
1372
|
const SUPERSCRIPT_TO_ASCII: [string, string][] = [
|
package/src/release.test.ts
CHANGED
|
@@ -390,9 +390,20 @@ describe("release.yml", () => {
|
|
|
390
390
|
const src = readText(".github/workflows/release.yml");
|
|
391
391
|
expect(src).toContain("bun run typecheck");
|
|
392
392
|
expect(src).toContain("bun test");
|
|
393
|
+
expect(src).toContain("bun test src/mcp-contract.test.ts");
|
|
393
394
|
expect(src).toContain("bun run lint");
|
|
394
395
|
});
|
|
395
396
|
|
|
397
|
+
test("runs MCP contract tests against the real built DB before eval/release", () => {
|
|
398
|
+
const src = readText(".github/workflows/release.yml");
|
|
399
|
+
const contractIdx = src.indexOf("bun test src/mcp-contract.test.ts");
|
|
400
|
+
const evalIdx = src.indexOf("MCP retrieval eval (Phase 0, non-blocking)");
|
|
401
|
+
const buildIdx = src.indexOf("Build release artifacts");
|
|
402
|
+
expect(contractIdx).toBeGreaterThan(0);
|
|
403
|
+
expect(contractIdx).toBeLessThan(evalIdx);
|
|
404
|
+
expect(contractIdx).toBeLessThan(buildIdx);
|
|
405
|
+
});
|
|
406
|
+
|
|
396
407
|
test("creates GitHub Release", () => {
|
|
397
408
|
const src = readText(".github/workflows/release.yml");
|
|
398
409
|
expect(src).toContain("gh release create");
|