@velaro/cli 1.4.95 → 1.4.96
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/commands/logs.js +56 -28
- package/lib/log-search.js +100 -0
- package/lib/log-search.test.js +129 -0
- package/package.json +1 -1
package/lib/commands/logs.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getCredentials } from '../api.js';
|
|
2
2
|
import { readConfig, ENVS } from '../config.js';
|
|
3
|
+
import { completeLogSearch, describeCompleteness, describeRawMeta } from '../log-search.js';
|
|
3
4
|
|
|
4
5
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
5
6
|
|
|
@@ -60,39 +61,61 @@ async function searchLogs(argv) {
|
|
|
60
61
|
await requireAdmin(creds);
|
|
61
62
|
const msgBase = await getMsgBase(argv);
|
|
62
63
|
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
if (
|
|
66
|
-
|
|
67
|
-
if (argv.integration) params.set('integration', argv.integration);
|
|
68
|
-
if (argv.site) params.set('siteId', String(argv.site));
|
|
69
|
-
if (argv.source) params.set('source', argv.source);
|
|
70
|
-
if (argv.platform) params.set('platform', argv.platform);
|
|
71
|
-
if (argv.environment) params.set('environment', argv.environment);
|
|
72
|
-
if (argv.correlationId) params.set('correlationId', argv.correlationId);
|
|
73
|
-
if (argv.regex) params.set('regex', 'true');
|
|
74
|
-
|
|
75
|
-
const res = await fetch(`${msgBase}/superadmin/logs/search?${params}`, {
|
|
76
|
-
headers: {
|
|
77
|
-
Authorization: `Bearer ${creds.velaroToken}`,
|
|
78
|
-
'X-Internal-SiteId': String(creds.siteId),
|
|
79
|
-
},
|
|
80
|
-
signal: AbortSignal.timeout(20000),
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
if (!res.ok) {
|
|
84
|
-
console.error(`HTTP ${res.status}: ${await res.text().catch(() => '')}`);
|
|
64
|
+
const to = argv.to ? new Date(argv.to) : new Date();
|
|
65
|
+
const from = argv.from ? new Date(argv.from) : new Date(to.getTime() - parseDuration(argv.last ?? '2h'));
|
|
66
|
+
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || to <= from) {
|
|
67
|
+
console.error('Invalid time window: --from/--to must be ISO timestamps with --to after --from.');
|
|
85
68
|
process.exit(1);
|
|
86
69
|
}
|
|
70
|
+
const headers = { Authorization: `Bearer ${creds.velaroToken}`, 'X-Internal-SiteId': String(creds.siteId) };
|
|
71
|
+
const getJson = async (query) => {
|
|
72
|
+
const res = await fetch(`${msgBase}/superadmin/logs/search?${query}`, { headers, signal: AbortSignal.timeout(30000) });
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
console.error(`HTTP ${res.status}: ${await res.text().catch(() => '')}`);
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
return res.json();
|
|
78
|
+
};
|
|
87
79
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
80
|
+
// --raw: the server's own q= search (kept for parity). Proven 2026-10-14 to under-report (q=[HttpFallback]
|
|
81
|
+
// returned 0-2 rows while 136 existed), so it now always prints the server's searchPath/scannedRows/truncated.
|
|
82
|
+
if (argv.raw) {
|
|
83
|
+
const params = new URLSearchParams({ from: from.toISOString(), to: to.toISOString(), take: String(argv.take ?? 50) });
|
|
84
|
+
if (argv.query) params.set('q', argv.query);
|
|
85
|
+
if (argv.level) params.set('level', argv.level.toUpperCase());
|
|
86
|
+
if (argv.integration) params.set('integration', argv.integration);
|
|
87
|
+
if (argv.site) params.set('siteId', String(argv.site));
|
|
88
|
+
if (argv.source) params.set('source', argv.source);
|
|
89
|
+
if (argv.platform) params.set('platform', argv.platform);
|
|
90
|
+
if (argv.environment) params.set('environment', argv.environment);
|
|
91
|
+
if (argv.correlationId) params.set('correlationId', argv.correlationId);
|
|
92
|
+
if (argv.regex) params.set('regex', 'true');
|
|
93
|
+
const resp = await getJson(params.toString());
|
|
94
|
+
const entries = extractLogEntries(resp);
|
|
95
|
+
console.log(`${DIM}${describeRawMeta(resp)}${RESET}`);
|
|
96
|
+
if (!entries.length) { console.log('No results (raw server search; see the line above before trusting that).'); return; }
|
|
97
|
+
console.log(`\n${DIM}${entries.length} result(s) from ${from.toISOString().slice(0, 16)} UTC${RESET}\n`);
|
|
98
|
+
for (const e of entries) printEntry(e);
|
|
99
|
+
console.log('');
|
|
91
100
|
return;
|
|
92
101
|
}
|
|
93
102
|
|
|
94
|
-
|
|
95
|
-
|
|
103
|
+
// Default: COMPLETE read. Never sends q=: filters level/site/source/... server side, bisects any slice the
|
|
104
|
+
// server reports as capped (includeTotal), filters text client side, and says COMPLETE or INCOMPLETE.
|
|
105
|
+
const result = await completeLogSearch({
|
|
106
|
+
fetchJson: getJson,
|
|
107
|
+
from, to,
|
|
108
|
+
params: { level: argv.level, siteId: argv.site, source: argv.source, integration: argv.integration, platform: argv.platform, environment: argv.environment, correlationId: argv.correlationId },
|
|
109
|
+
contains: argv.regex ? undefined : argv.query,
|
|
110
|
+
regex: argv.regex ? argv.query : undefined,
|
|
111
|
+
logger: argv.logger,
|
|
112
|
+
maxRequests: argv.maxRequests ?? 200,
|
|
113
|
+
});
|
|
114
|
+
console.log(`${result.complete ? DIM : YEL}${describeCompleteness(result)}${RESET}`);
|
|
115
|
+
if (!result.entries.length) { console.log('No results.'); return; }
|
|
116
|
+
const shown = result.entries.slice(0, argv.take ?? 50);
|
|
117
|
+
console.log(`\n${DIM}showing ${shown.length} of ${result.entries.length} (newest first) from ${from.toISOString().slice(0, 16)} UTC${RESET}\n`);
|
|
118
|
+
for (const e of shown) printEntry(e);
|
|
96
119
|
console.log('');
|
|
97
120
|
}
|
|
98
121
|
|
|
@@ -696,7 +719,12 @@ export const logsCommand = {
|
|
|
696
719
|
.option('correlationId', { describe: 'Exact correlation/trace ID filter', type: 'string' })
|
|
697
720
|
.option('last', { describe: 'Time window: 30m, 2h, 1d', default: '2h', type: 'string' })
|
|
698
721
|
.option('regex', { describe: 'Treat query as regex', boolean: true, default: false })
|
|
699
|
-
.option('
|
|
722
|
+
.option('logger', { describe: 'Logger-name substring (client side)', type: 'string' })
|
|
723
|
+
.option('from', { describe: 'ISO start time (overrides --last)', type: 'string' })
|
|
724
|
+
.option('to', { describe: 'ISO end time (default now)', type: 'string' })
|
|
725
|
+
.option('raw', { describe: 'Use the server q= search (can under-report; prints searchPath/scannedRows/truncated)', boolean: true, default: false })
|
|
726
|
+
.option('max-requests', { describe: 'Request budget for the complete read', default: 200, type: 'number' })
|
|
727
|
+
.option('take', { describe: 'Max lines to print (complete read) / server page size (--raw, max 500)', default: 50, type: 'number' })
|
|
700
728
|
.option('env', { describe: 'staging or prod', type: 'string' }),
|
|
701
729
|
handler: searchLogs,
|
|
702
730
|
})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Complete Velaro Logger reads (2026-10-14).
|
|
2
|
+
//
|
|
3
|
+
// WHY: velaro-messaging's /superadmin/logs/search and /internal/logs/search silently under-report text queries.
|
|
4
|
+
// Proven live that day: q=[HttpFallback] over 3.9 h returned 0-2 rows while 136 existed, and q=AsrsNegotiateFailed
|
|
5
|
+
// (no level) returned 0 while 4 existed. The AI-search path only sees the newest 1,000 candidates and its answer was
|
|
6
|
+
// final; the exact path only sees the newest 5,000 rows of the window before filtering. Neither said so.
|
|
7
|
+
//
|
|
8
|
+
// HOW (no q= ever sent): level/siteId/source/integration/platform/environment/correlationId are pushed to the
|
|
9
|
+
// server (Table Storage OData, cheap and exact); includeTotal=true makes the server report totalCountCapped when a
|
|
10
|
+
// slice hit its scan cap; a capped slice is bisected until none is capped; text (contains/regex/logger) is
|
|
11
|
+
// filtered client side. The result says whether it is COMPLETE, so a 0 is never ambiguous.
|
|
12
|
+
//
|
|
13
|
+
// KEEP IN SYNC: this file is duplicated byte for byte in mcp/log-search.js (the npm packages ship separately);
|
|
14
|
+
// cli/lib/log-search.test.js fails if the two copies drift.
|
|
15
|
+
|
|
16
|
+
const MIN_SLICE_MS = 2000;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {object} o
|
|
20
|
+
* @param {(query: string) => Promise<any>} o.fetchJson GET `...search?<query>` and return parsed JSON (caller adds auth)
|
|
21
|
+
* @param {Date} o.from
|
|
22
|
+
* @param {Date} o.to
|
|
23
|
+
* @param {object} [o.params] server-side filters: level, siteId, source, integration, platform, environment, correlationId
|
|
24
|
+
* @param {string} [o.contains] case-insensitive substring of the message (client side)
|
|
25
|
+
* @param {string} [o.regex] regex on the message (client side)
|
|
26
|
+
* @param {string} [o.logger] substring of the logger name (client side)
|
|
27
|
+
* @param {number} [o.maxRequests=200]
|
|
28
|
+
*/
|
|
29
|
+
export async function completeLogSearch({ fetchJson, from, to, params = {}, contains, regex, logger, maxRequests = 200 }) {
|
|
30
|
+
const server = {};
|
|
31
|
+
for (const k of ['level', 'siteId', 'source', 'integration', 'platform', 'environment', 'correlationId']) {
|
|
32
|
+
if (params[k] !== undefined && params[k] !== null && params[k] !== '') server[k] = String(k === 'level' ? String(params[k]).toUpperCase() : params[k]);
|
|
33
|
+
}
|
|
34
|
+
const byId = new Map();
|
|
35
|
+
const unresolved = [];
|
|
36
|
+
let requests = 0;
|
|
37
|
+
let slices = 0;
|
|
38
|
+
let scanned = 0;
|
|
39
|
+
let budgetExhausted = false;
|
|
40
|
+
|
|
41
|
+
const call = async (a, b, cursor, total) => {
|
|
42
|
+
if (requests >= maxRequests) { budgetExhausted = true; return null; }
|
|
43
|
+
requests++;
|
|
44
|
+
const q = new URLSearchParams({ from: a.toISOString(), to: b.toISOString(), take: '500', ...server });
|
|
45
|
+
if (total) q.set('includeTotal', 'true');
|
|
46
|
+
if (cursor) q.set('cursor', cursor);
|
|
47
|
+
const r = await fetchJson(q.toString());
|
|
48
|
+
return Array.isArray(r) ? { entries: r } : (r || {});
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const walk = async (a, b) => {
|
|
52
|
+
const r = await call(a, b, null, true);
|
|
53
|
+
if (!r) { unresolved.push(`${a.toISOString()}..${b.toISOString()} (request budget)`); return; }
|
|
54
|
+
if (r.totalCountCapped === true && (b - a) > MIN_SLICE_MS) {
|
|
55
|
+
const mid = new Date(a.getTime() + Math.floor((b - a) / 2));
|
|
56
|
+
await walk(a, mid);
|
|
57
|
+
await walk(mid, b);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (r.totalCountCapped === true) unresolved.push(`${a.toISOString()}..${b.toISOString()} (still capped at <=2s)`);
|
|
61
|
+
slices++;
|
|
62
|
+
scanned += Number(r.totalCount ?? (r.entries?.length ?? 0));
|
|
63
|
+
for (const e of r.entries ?? []) byId.set(e.id ?? `${e.timestamp}|${e.message}`, e);
|
|
64
|
+
let cursor = r.nextCursor;
|
|
65
|
+
while (cursor) {
|
|
66
|
+
const r2 = await call(a, b, cursor, false);
|
|
67
|
+
if (!r2) { unresolved.push(`${a.toISOString()}..${b.toISOString()} (request budget while paging)`); break; }
|
|
68
|
+
for (const e of r2.entries ?? []) byId.set(e.id ?? `${e.timestamp}|${e.message}`, e);
|
|
69
|
+
cursor = r2.nextCursor;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
await walk(from, to);
|
|
74
|
+
|
|
75
|
+
let rows = [...byId.values()];
|
|
76
|
+
const msg = (e) => String(e.message ?? e.Message ?? '');
|
|
77
|
+
if (contains) { const c = contains.toLowerCase(); rows = rows.filter((e) => msg(e).toLowerCase().includes(c)); }
|
|
78
|
+
if (regex) { const re = new RegExp(regex, 'i'); rows = rows.filter((e) => re.test(msg(e))); }
|
|
79
|
+
if (logger) { const l = logger.toLowerCase(); rows = rows.filter((e) => String(e.logger ?? e.Logger ?? '').toLowerCase().includes(l)); }
|
|
80
|
+
rows.sort((x, y) => String(y.timestamp ?? '').localeCompare(String(x.timestamp ?? '')));
|
|
81
|
+
|
|
82
|
+
return { entries: rows, fetched: byId.size, scanned, requests, slices, complete: unresolved.length === 0 && !budgetExhausted, unresolved, budgetExhausted };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** One line a human or an LLM can trust: says COMPLETE or INCOMPLETE and how much was examined. */
|
|
86
|
+
export function describeCompleteness(r) {
|
|
87
|
+
const base = `${r.entries.length} matched of ${r.fetched} fetched (server scanned ${r.scanned}) in ${r.requests} request(s)/${r.slices} slice(s)`;
|
|
88
|
+
return r.complete
|
|
89
|
+
? `${base}; COMPLETE (no slice hit the server scan cap), so 0 matches means nothing was logged.`
|
|
90
|
+
: `${base}; INCOMPLETE (${r.unresolved.length} slice(s) unresolved${r.budgetExhausted ? ', request budget exhausted' : ''}): narrow the window or add level/site; a short or empty result is NOT proof of absence.`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** For raw (server-side q=) responses: surfaces searchPath/scannedRows/truncated so a 0 is never ambiguous. */
|
|
94
|
+
export function describeRawMeta(resp) {
|
|
95
|
+
if (!resp || Array.isArray(resp) || resp.scannedRows === undefined) {
|
|
96
|
+
return 'server did not report scan metadata (older deploy): treat an empty or short result as unverified, use the complete mode.';
|
|
97
|
+
}
|
|
98
|
+
const t = resp.truncated === true;
|
|
99
|
+
return `searchPath=${resp.searchPath} scannedRows=${resp.scannedRows} truncated=${t}${t ? ' (NOT proof of absence; continue with nextCursor or narrow the window)' : ''}${resp.nextCursor ? ' nextCursor=present' : ''}`;
|
|
100
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Regression (2026-10-14): velaro-messaging's logs search under-reported text queries (q=[HttpFallback] returned 0-2 rows
|
|
2
|
+
// while 136 existed; q=AsrsNegotiateFailed with no level returned 0 while 4 existed) because each scan only examined the
|
|
3
|
+
// newest N rows of the window and never said so. completeLogSearch must find rows behind an arbitrary pile of newer
|
|
4
|
+
// rows, say COMPLETE only when no slice hit the server cap, and never send q=.
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { completeLogSearch, describeCompleteness, describeRawMeta } from './log-search.js';
|
|
11
|
+
|
|
12
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
|
|
14
|
+
/** Mock of the messaging search endpoint: newest-first scan capped at `cap` rows BEFORE any filter other than level/site. */
|
|
15
|
+
function mockServer(rows, cap = 5000) {
|
|
16
|
+
const calls = [];
|
|
17
|
+
const fetchJson = async (query) => {
|
|
18
|
+
const p = new URLSearchParams(query);
|
|
19
|
+
calls.push(p);
|
|
20
|
+
assert.equal(p.get('q'), null, 'the complete read must never send q=');
|
|
21
|
+
assert.equal(p.get('regex'), null);
|
|
22
|
+
const from = new Date(p.get('from')).getTime();
|
|
23
|
+
const to = new Date(p.get('to')).getTime();
|
|
24
|
+
const lvl = p.get('level');
|
|
25
|
+
const site = p.get('siteId');
|
|
26
|
+
// newest first, server-side filters first, then the fetch cap (the real bug), then in-window filter
|
|
27
|
+
let pool = rows.filter((r) => (!lvl || r.level === lvl) && (!site || String(r.siteId) === site));
|
|
28
|
+
pool = pool.filter((r) => new Date(r.timestamp).getTime() >= from && new Date(r.timestamp).getTime() <= to);
|
|
29
|
+
pool.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
|
30
|
+
const capped = pool.length >= cap;
|
|
31
|
+
const fetched = pool.slice(0, cap);
|
|
32
|
+
const take = Number(p.get('take'));
|
|
33
|
+
let start = 0;
|
|
34
|
+
if (p.get('cursor')) start = fetched.findIndex((r) => r.id === p.get('cursor')) + 1;
|
|
35
|
+
const page = fetched.slice(start, start + take);
|
|
36
|
+
const more = start + take < fetched.length;
|
|
37
|
+
const out = { entries: page, nextCursor: more ? page[page.length - 1].id : null };
|
|
38
|
+
if (p.get('includeTotal') === 'true') { out.totalCount = fetched.length; out.totalCountCapped = capped; }
|
|
39
|
+
return out;
|
|
40
|
+
};
|
|
41
|
+
return { fetchJson, calls };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function makeRows(n, needles) {
|
|
45
|
+
const base = Date.UTC(2026, 9, 14, 13, 45, 0);
|
|
46
|
+
const rows = [];
|
|
47
|
+
for (let i = 0; i < n; i++) {
|
|
48
|
+
rows.push({ id: `r${String(i).padStart(7, '0')}`, timestamp: new Date(base - i * 1000).toISOString(), level: 'WARN', siteId: 20519, logger: 'VisitorLoggingService', message: needles.has(i) ? '[AsrsNegotiateFailed] site=20715' : `noise ${i}` });
|
|
49
|
+
}
|
|
50
|
+
return rows;
|
|
51
|
+
}
|
|
52
|
+
const W = (rows) => [new Date(rows[rows.length - 1].timestamp), new Date(rows[0].timestamp)];
|
|
53
|
+
|
|
54
|
+
test('the single raw call misses a needle behind 9,000 newer rows (the live bug shape)', async () => {
|
|
55
|
+
const rows = makeRows(12000, new Set([9000]));
|
|
56
|
+
const { fetchJson } = mockServer(rows, 5000);
|
|
57
|
+
const [from, to] = W(rows);
|
|
58
|
+
const raw = await fetchJson(new URLSearchParams({ from: from.toISOString(), to: to.toISOString(), take: '500', includeTotal: 'true' }).toString());
|
|
59
|
+
assert.equal(raw.totalCountCapped, true);
|
|
60
|
+
assert.equal(raw.entries.some((e) => e.message.includes('AsrsNegotiate')), false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('completeLogSearch finds the needle behind 9,000 newer rows and reports COMPLETE', async () => {
|
|
64
|
+
const rows = makeRows(12000, new Set([9000, 11000]));
|
|
65
|
+
const { fetchJson } = mockServer(rows, 5000);
|
|
66
|
+
const [from, to] = W(rows);
|
|
67
|
+
const r = await completeLogSearch({ fetchJson, from, to, contains: '[AsrsNegotiateFailed]', maxRequests: 200 });
|
|
68
|
+
assert.equal(r.entries.length, 2);
|
|
69
|
+
assert.equal(r.complete, true);
|
|
70
|
+
assert.ok(r.slices > 1, 'capped slices must have been bisected');
|
|
71
|
+
assert.match(describeCompleteness(r), /COMPLETE/);
|
|
72
|
+
assert.doesNotMatch(describeCompleteness(r), /INCOMPLETE/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('a request budget that runs out is INCOMPLETE, never a silent partial', async () => {
|
|
76
|
+
const rows = makeRows(12000, new Set([9000]));
|
|
77
|
+
const { fetchJson } = mockServer(rows, 5000);
|
|
78
|
+
const [from, to] = W(rows);
|
|
79
|
+
const r = await completeLogSearch({ fetchJson, from, to, contains: '[AsrsNegotiateFailed]', maxRequests: 2 });
|
|
80
|
+
assert.equal(r.complete, false);
|
|
81
|
+
assert.equal(r.budgetExhausted, true);
|
|
82
|
+
assert.match(describeCompleteness(r), /INCOMPLETE/);
|
|
83
|
+
assert.match(describeCompleteness(r), /NOT proof of absence/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('level and siteId are pushed server side; contains/regex/logger filter client side', async () => {
|
|
87
|
+
const rows = makeRows(300, new Set([10, 20]));
|
|
88
|
+
rows[5].logger = 'Other.Logger';
|
|
89
|
+
const { fetchJson, calls } = mockServer(rows, 5000);
|
|
90
|
+
const [from, to] = W(rows);
|
|
91
|
+
const r = await completeLogSearch({ fetchJson, from, to, params: { level: 'warn', siteId: 20519 }, regex: 'Asrs.*Failed', logger: 'visitorlogging' });
|
|
92
|
+
assert.equal(r.entries.length, 2);
|
|
93
|
+
assert.equal(calls[0].get('level'), 'WARN');
|
|
94
|
+
assert.equal(calls[0].get('siteId'), '20519');
|
|
95
|
+
assert.equal(r.complete, true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('a genuine 0 on a complete scan says so (0 means nothing was logged)', async () => {
|
|
99
|
+
const rows = makeRows(500, new Set());
|
|
100
|
+
const { fetchJson } = mockServer(rows, 5000);
|
|
101
|
+
const [from, to] = W(rows);
|
|
102
|
+
const r = await completeLogSearch({ fetchJson, from, to, contains: 'nope' });
|
|
103
|
+
assert.equal(r.entries.length, 0);
|
|
104
|
+
assert.match(describeCompleteness(r), /0 matches means nothing was logged/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('describeRawMeta surfaces searchPath/scannedRows/truncated and warns on old deploys', () => {
|
|
108
|
+
assert.match(describeRawMeta({ entries: [], searchPath: 'ai-search', scannedRows: 1000, truncated: true }), /truncated=true.*NOT proof of absence/);
|
|
109
|
+
assert.match(describeRawMeta({ entries: [], searchPath: 'table-storage', scannedRows: 911, truncated: false }), /scannedRows=911 truncated=false/);
|
|
110
|
+
assert.match(describeRawMeta([]), /older deploy/);
|
|
111
|
+
assert.match(describeRawMeta({ entries: [] }), /older deploy/);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('cli/lib/log-search.js and mcp/log-search.js are byte-identical (the npm packages ship separately)', () => {
|
|
115
|
+
const a = fs.readFileSync(path.join(here, 'log-search.js'), 'utf8');
|
|
116
|
+
const b = fs.readFileSync(path.join(here, '..', '..', 'mcp', 'log-search.js'), 'utf8');
|
|
117
|
+
assert.equal(a, b, 'copy cli/lib/log-search.js to mcp/log-search.js (or the reverse)');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('neither the CLI search handler nor the MCP tool sends q= in complete mode', () => {
|
|
121
|
+
const cli = fs.readFileSync(path.join(here, 'commands', 'logs.js'), 'utf8');
|
|
122
|
+
const mcp = fs.readFileSync(path.join(here, '..', '..', 'mcp', 'staff-server.js'), 'utf8');
|
|
123
|
+
for (const [name, src, marker] of [['cli', cli, 'Default: COMPLETE read'], ['mcp', mcp, 'Default: COMPLETE read']]) {
|
|
124
|
+
const tail = src.slice(src.indexOf(marker));
|
|
125
|
+
const handler = tail.slice(0, 1200);
|
|
126
|
+
assert.ok(handler.includes('completeLogSearch('), `${name}: complete mode must call completeLogSearch`);
|
|
127
|
+
assert.ok(!/params\.set\('q'/.test(handler), `${name}: complete mode must not set q=`);
|
|
128
|
+
}
|
|
129
|
+
});
|
package/package.json
CHANGED