feat(assets): 优化移动端分析体验并记录 AI 提问日志
This commit is contained in:
@@ -1,3 +1,10 @@
|
||||
import { beforeEach } from 'node:test';
|
||||
import { questionLog } from './question-log.js';
|
||||
beforeEach(t => {
|
||||
if (!('mock' in t)) throw new Error('Expected test context');
|
||||
t.mock.method(questionLog, 'start', async () => 'synthetic-question');
|
||||
t.mock.method(questionLog, 'finish', async () => {});
|
||||
});
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { Hono } from "hono";
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import { beforeEach } from 'node:test';
|
||||
import { questionLog } from './question-log.js';
|
||||
beforeEach(t => {
|
||||
if (!('mock' in t)) throw new Error('Expected test context');
|
||||
t.mock.method(questionLog, 'start', async () => 'synthetic-question');
|
||||
t.mock.method(questionLog, 'finish', async () => {});
|
||||
});
|
||||
import assert from "node:assert/strict";
|
||||
import test, { type TestContext } from "node:test";
|
||||
import { Hono } from "hono";
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import { beforeEach } from 'node:test';
|
||||
import { questionLog } from './question-log.js';
|
||||
beforeEach(t => {
|
||||
if (!('mock' in t)) throw new Error('Expected test context');
|
||||
t.mock.method(questionLog, 'start', async () => 'synthetic-question');
|
||||
t.mock.method(questionLog, 'finish', async () => {});
|
||||
});
|
||||
import assert from "node:assert/strict";
|
||||
import test, { type TestContext } from "node:test";
|
||||
import { Hono } from "hono";
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import { beforeEach } from 'node:test';
|
||||
import { questionLog } from './question-log.js';
|
||||
beforeEach(t => {
|
||||
if (!('mock' in t)) throw new Error('Expected test context');
|
||||
t.mock.method(questionLog, 'start', async () => 'synthetic-question');
|
||||
t.mock.method(questionLog, 'finish', async () => {});
|
||||
});
|
||||
import assert from "node:assert/strict";
|
||||
import test, { type TestContext } from "node:test";
|
||||
import { Hono } from "hono";
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import { beforeEach } from 'node:test';
|
||||
import { questionLog } from './question-log.js';
|
||||
beforeEach(t => {
|
||||
if (!('mock' in t)) throw new Error('Expected test context');
|
||||
t.mock.method(questionLog, 'start', async () => 'synthetic-question');
|
||||
t.mock.method(questionLog, 'finish', async () => {});
|
||||
});
|
||||
import assert from "node:assert/strict";
|
||||
import test, { type TestContext } from "node:test";
|
||||
import { Hono } from "hono";
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "./session.js";
|
||||
import { resultScope, followupSuggestions } from "./presentation.js";
|
||||
import { gapStore } from "./gaps.js";
|
||||
import { questionLog, questionOutcome, finishQuestionLog } from "./question-log.js";
|
||||
import {
|
||||
CATEGORY_LABELS,
|
||||
GAP_STATUSES,
|
||||
@@ -99,6 +100,10 @@ router.post("/feedback", async (c) => {
|
||||
const session = getSession(c.get("user"), body.subject, body.sessionId),
|
||||
node = getNode(session, body.resultId)!;
|
||||
if (!node.flagged) {
|
||||
const logId = await questionLog.start(c.get("user"), { message: node.question || node.demand,
|
||||
subject: body.subject, sessionId: session.id, baseResultId: node.id }, "feedback");
|
||||
const saved = await finishQuestionLog(logId, { outcome: "feedback", reason: "unsatisfied", resultId: node.id });
|
||||
if (!saved) return c.json({ error: "反馈已接收,处理状态暂未保存,请稍后重试。" }, 503);
|
||||
await gapStore.record(c.get("user").userId, node.demand, "unsatisfied");
|
||||
node.flagged = true;
|
||||
}
|
||||
@@ -220,15 +225,24 @@ router.post("/ask", async (c) => {
|
||||
)
|
||||
return c.json({ error: "请输入有效问题或选择分析操作。" }, 400);
|
||||
const input: AssistantRequest = { ...raw, subject: raw.subject || null };
|
||||
let logId: string;
|
||||
try {
|
||||
logId = await questionLog.start(user, input);
|
||||
} catch {
|
||||
return c.json({ error: "提问记录暂不可用,本次查询未执行,请稍后重试。" }, 503);
|
||||
}
|
||||
const release = acquire(user.userId);
|
||||
if (!release)
|
||||
if (!release) {
|
||||
await finishQuestionLog(logId, { outcome: "rejected", reason: "busy" });
|
||||
return c.json({ error: "上一条查询仍在执行,请稍后重试。" }, 429);
|
||||
}
|
||||
let session, base;
|
||||
try {
|
||||
session = getSession(user, input.subject, input.sessionId);
|
||||
base = getNode(session, input.baseResultId);
|
||||
} catch {
|
||||
release();
|
||||
await finishQuestionLog(logId, { outcome: "rejected", reason: "session_expired" });
|
||||
return c.json({ error: "会话已过期或范围已变化,请新建对话。" }, 409);
|
||||
}
|
||||
const currentSession = session,
|
||||
@@ -238,6 +252,7 @@ router.post("/ask", async (c) => {
|
||||
stream.onAbort(() => controller.abort());
|
||||
const emit = (event: object) =>
|
||||
stream.writeSSE({ data: JSON.stringify(event) });
|
||||
let logFinished = false;
|
||||
let demand = "查询执行",
|
||||
reason: GapReason | undefined;
|
||||
try {
|
||||
@@ -516,8 +531,17 @@ router.post("/ask", async (c) => {
|
||||
type: "step",
|
||||
label: "分析完成",
|
||||
});
|
||||
logFinished = true;
|
||||
const saved = await finishQuestionLog(logId, { outcome: questionOutcome(result, gapReason),
|
||||
reason: gapReason, sessionId: currentSession.id, result });
|
||||
if (!saved) result.warning = [result.warning, "提问已记录,处理结果暂未保存。"].filter(Boolean).join(" ");
|
||||
await emit({ type: "result", result });
|
||||
} catch {
|
||||
if (!logFinished) {
|
||||
logFinished = true;
|
||||
await finishQuestionLog(logId, { outcome: controller.signal.aborted ? "cancelled" : "failed",
|
||||
reason: controller.signal.aborted ? "client_aborted" : "generation", sessionId: currentSession.id });
|
||||
}
|
||||
if (!controller.signal.aborted) {
|
||||
let recorded = false;
|
||||
try {
|
||||
@@ -534,6 +558,8 @@ router.post("/ask", async (c) => {
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (!logFinished) await finishQuestionLog(logId, { outcome: controller.signal.aborted ? "cancelled" : "failed",
|
||||
reason: controller.signal.aborted ? "client_aborted" : "unfinished", sessionId: currentSession.id });
|
||||
release();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import { beforeEach } from 'node:test';
|
||||
import { questionLog } from './question-log.js';
|
||||
beforeEach(t => {
|
||||
if (!('mock' in t)) throw new Error('Expected test context');
|
||||
t.mock.method(questionLog, 'start', async () => 'synthetic-question');
|
||||
t.mock.method(questionLog, 'finish', async () => {});
|
||||
});
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { Hono } from "hono";
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Hono } from 'hono';
|
||||
import pool from '../../db.js';
|
||||
import router from './index.js';
|
||||
import { questionLog, questionOutcome, businessTypes, finishQuestionLog, redactQuestion, type QuestionCompletion } from './question-log.js';
|
||||
import { vehicleRepository } from '../vehicles/repository.js';
|
||||
import { gapStore } from './gaps.js';
|
||||
import { acquire } from './session.js';
|
||||
import type { AuthUser } from '../../auth/types.js';
|
||||
import type { AssistantResult } from '../../../shared/assets/assistant.js';
|
||||
|
||||
const user: AuthUser = { userId: 'log-test', userName: '测试用户', loginName: 'test', roles: [], permissionLevel: 'personal', depCode: 'D', depName: '测试部门' };
|
||||
const result = (extra: Partial<AssistantResult> = {}): AssistantResult => ({
|
||||
plan: { kind: 'assets', category: 'inventory', groupBy: 'region', view: 'table', filters: {} },
|
||||
title: '库存', summary: '库存共 2 台', rows: [], groups: [], metrics: [], source: 'test', fetchedAt: '', ...extra,
|
||||
});
|
||||
function appFor(u = user) {
|
||||
const app = new Hono<{ Variables: { user: AuthUser } }>();
|
||||
app.use('*', async (c, next) => { c.set('user', u); await next(); });
|
||||
app.route('/ai', router);
|
||||
return app;
|
||||
}
|
||||
function ask(app: ReturnType<typeof appFor>, body: object = { message: '库存有多少车?', subject: null }) {
|
||||
return app.request('/ai/ask', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
test('each question has its own durable ID, full text and attributed preview identity; SQL is parameterized', async t => {
|
||||
const calls: { sql: string; values: unknown[] }[] = [];
|
||||
t.mock.method(pool, 'query', async (options: { sql: string }, values: unknown[]) => { calls.push({ sql: options.sql, values }); return [{ affectedRows: 1 }, []]; });
|
||||
const preview = { ...user, preview: { actorId: 'real-actor', sessionId: 'preview-session' } };
|
||||
const message = '按区域统计库存'.repeat(100) + " ' OR 1=1 --";
|
||||
const first = await questionLog.start(preview, { message, subject: '公司甲' });
|
||||
const second = await questionLog.start(preview, { message, subject: '公司甲' });
|
||||
assert.notEqual(first, second);
|
||||
assert.equal(calls[0].values[1], 'real-actor');
|
||||
assert.equal(calls[0].values[2], user.userId);
|
||||
assert.equal(calls[0].values[6], 1);
|
||||
assert.equal(calls[0].values[12], message);
|
||||
assert.ok(!calls[0].sql.includes(message));
|
||||
await questionLog.finish(first, { outcome: 'success', sessionId: 'session', result: result() });
|
||||
assert.match(calls[2].sql, /outcome='processing'/);
|
||||
assert.equal(calls[2].values[4], '["inventory"]');
|
||||
assert.match(String(calls[2].values[6]), /库存共 2 台/);
|
||||
assert.ok(!String(calls[2].values[6]).includes('rows'));
|
||||
});
|
||||
|
||||
test('outcomes and business categories separate partial, help, empty, unsupported and failure', () => {
|
||||
assert.equal(questionOutcome(result()), 'success');
|
||||
for (const value of ['partial', 'empty', 'unsupported', 'help', 'clarification'] as const) {
|
||||
const r = result({ outcome: value });
|
||||
if (value === 'clarification') r.plan.kind = 'clarify';
|
||||
assert.equal(questionOutcome(r), value);
|
||||
}
|
||||
assert.equal(questionOutcome(result(), 'generation'), 'failed');
|
||||
assert.deepEqual(businessTypes(result({ sections: [
|
||||
{ id: 'a', title: '库存', question: '库存', display: 'table', result: result() },
|
||||
{ id: 'b', title: '交车', question: '交车', display: 'table', result: result({ plan: { ...result().plan, kind: 'flow' } }) },
|
||||
] })), ['inventory', 'flow']);
|
||||
assert.equal(redactQuestion('库存 sk-12345678 password=secret123 Bearer token123'), '库存 [凭据已隐藏] password=[凭据已隐藏] Bearer [凭据已隐藏]');
|
||||
});
|
||||
|
||||
test('storage outage rejects the request before querying data or invoking AI', async t => {
|
||||
t.mock.method(questionLog, 'start', async () => { throw new Error('private DB error'); });
|
||||
const query = t.mock.method(vehicleRepository, 'getVehicles', async () => []);
|
||||
const response = await ask(appFor());
|
||||
assert.equal(response.status, 503);
|
||||
assert.equal(query.mock.callCount(), 0);
|
||||
assert.ok(!(await response.text()).includes('private DB'));
|
||||
});
|
||||
|
||||
test('concurrency rejection and expired session are still durable classified questions', async t => {
|
||||
const start = t.mock.method(questionLog, 'start', async () => 'id');
|
||||
const finish = t.mock.method(questionLog, 'finish', async (_id: string, _completion: QuestionCompletion) => {});
|
||||
const release = acquire(user.userId)!;
|
||||
try { assert.equal((await ask(appFor())).status, 429); } finally { release(); }
|
||||
assert.equal((await ask(appFor(), { message: '追问', subject: null, sessionId: 'missing' })).status, 409);
|
||||
assert.equal(start.mock.callCount(), 2);
|
||||
assert.equal(finish.mock.calls[0].arguments[1].reason, 'busy');
|
||||
assert.equal(finish.mock.calls[1].arguments[1].reason, 'session_expired');
|
||||
});
|
||||
|
||||
test('failed queries are logged for preview users without creating gap feedback', async t => {
|
||||
const start = t.mock.method(questionLog, 'start', async () => 'id');
|
||||
const finish = t.mock.method(questionLog, 'finish', async (_id: string, _completion: QuestionCompletion) => {});
|
||||
const gap = t.mock.method(gapStore, 'record', async () => {});
|
||||
t.mock.method(vehicleRepository, 'getVehicles', async () => { throw new Error('private failure'); });
|
||||
const response = await ask(appFor({ ...user, preview: { actorId: 'actor', sessionId: 'preview' } }));
|
||||
const text = await response.text();
|
||||
assert.match(text, /查询未完成/);
|
||||
assert.ok(!text.includes('private failure'));
|
||||
assert.equal(start.mock.callCount(), 1);
|
||||
assert.equal(finish.mock.calls[0].arguments[1].outcome, 'failed');
|
||||
assert.equal(gap.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
test('finalization retries transient failures and reports persistent failures', async t => {
|
||||
let calls = 0, alwaysFail = false;
|
||||
t.mock.method(questionLog, 'finish', async () => { if (++calls === 1 || alwaysFail) throw new Error('transient'); });
|
||||
assert.equal(await finishQuestionLog('id', { outcome: 'success' }), true);
|
||||
assert.equal(calls, 2);
|
||||
alwaysFail = true;
|
||||
const warning = t.mock.method(console, 'error', () => {});
|
||||
assert.equal(await finishQuestionLog('id', { outcome: 'failed' }), false);
|
||||
assert.deepEqual(warning.mock.calls[0].arguments, ['asset_ai_question_completion_failed', 'id']);
|
||||
});
|
||||
|
||||
test('successful questions, follow-ups, actions and feedback keep distinct operation types', async t => {
|
||||
const statements: unknown[][] = [];
|
||||
t.mock.method(pool, 'query', async (options: { sql?: string } | string, values: unknown[]) => {
|
||||
if (typeof options !== 'string' && options.sql?.includes('bi_asset_ai_questions')) statements.push(values);
|
||||
return [[], []];
|
||||
});
|
||||
t.mock.method(vehicleRepository, 'getVehicles', async () => []);
|
||||
t.mock.method(gapStore, 'record', async () => {});
|
||||
const old = process.env.ASSET_AI_EXTERNAL_ENABLED;
|
||||
process.env.ASSET_AI_EXTERNAL_ENABLED = '0';
|
||||
t.after(() => { if (old === undefined) delete process.env.ASSET_AI_EXTERNAL_ENABLED; else process.env.ASSET_AI_EXTERNAL_ENABLED = old; });
|
||||
const app = appFor({ ...user, userId: 'success-test' });
|
||||
const response = await ask(app);
|
||||
const events = (await response.text()).split('\n').filter(line => line.startsWith('data:')).map(line => JSON.parse(line.slice(5)));
|
||||
const r = events.find(event => event.type === 'result')?.result as AssistantResult;
|
||||
assert.ok(r, JSON.stringify(events));
|
||||
assert.equal(statements[0][11], 'question');
|
||||
assert.ok(['success', 'empty'].includes(String(statements[1][0])));
|
||||
assert.equal(statements[1][2], r.sessionId);
|
||||
assert.equal(statements[1][3], r.resultId);
|
||||
assert.deepEqual(JSON.parse(String(statements[1][4])), ['inventory']);
|
||||
await questionLog.start(user, { message: '按区域看', subject: null, sessionId: r.sessionId });
|
||||
assert.equal(statements.at(-1)![11], 'followup');
|
||||
await questionLog.start(user, { subject: null, sessionId: r.sessionId, baseResultId: r.resultId, action: { type: 'category', category: 'abnormal' } });
|
||||
assert.equal(statements.at(-1)![11], 'action');
|
||||
assert.deepEqual(JSON.parse(String(statements.at(-1)![13])), { type: 'category', category: 'abnormal' });
|
||||
const feedback = await app.request('/ai/feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: r.sessionId, resultId: r.resultId, subject: null }) });
|
||||
assert.equal(feedback.status, 200);
|
||||
assert.equal(statements.at(-2)![11], 'feedback');
|
||||
assert.equal(statements.at(-1)![0], 'feedback');
|
||||
});
|
||||
|
||||
test('client cancellation finalizes the durable question and releases the user query lock', async t => {
|
||||
t.mock.method(questionLog, 'start', async () => 'cancel-id');
|
||||
let complete!: (value: QuestionCompletion) => void;
|
||||
const completed = new Promise<QuestionCompletion>(resolve => { complete = resolve; });
|
||||
t.mock.method(questionLog, 'finish', async (_id: string, completion: QuestionCompletion) => { complete(completion); });
|
||||
let continueQuery!: () => void;
|
||||
t.mock.method(vehicleRepository, 'getVehicles', async () => { await new Promise<void>(resolve => { continueQuery = resolve; }); return []; });
|
||||
const cancelledUser = { ...user, userId: 'cancel-test' };
|
||||
const response = await ask(appFor(cancelledUser));
|
||||
const reader = response.body!.getReader();
|
||||
await reader.read();
|
||||
await reader.cancel();
|
||||
continueQuery();
|
||||
const completion = await completed;
|
||||
assert.equal(completion.outcome, 'cancelled');
|
||||
// Finalizer releases the lock after the asynchronous database update.
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
const release = acquire(cancelledUser.userId);
|
||||
assert.ok(release);
|
||||
release();
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import pool from '../../db.js';
|
||||
import type { AuthUser } from '../../auth/types.js';
|
||||
import type { AssistantRequest, AssistantResult, GapReason } from '../../../shared/assets/assistant.js';
|
||||
|
||||
export type QuestionOutcome = 'processing' | 'success' | 'empty' | 'partial' | 'clarification' | 'help' | 'unsupported' | 'failed' | 'cancelled' | 'rejected' | 'feedback';
|
||||
export type QuestionCompletion = {
|
||||
outcome: QuestionOutcome;
|
||||
sessionId?: string;
|
||||
resultId?: string;
|
||||
result?: AssistantResult;
|
||||
reason?: string;
|
||||
};
|
||||
// Credentials are never useful for question analysis. Keep the rest of the accepted question intact.
|
||||
export function redactQuestion(value: string): string {
|
||||
return value.replace(/sk-[a-z0-9_-]{8,}/gi, '[凭据已隐藏]')
|
||||
.replace(/(Bearer\s+)[\w.+/=-]+/gi, '$1[凭据已隐藏]')
|
||||
.replace(/((?:password|passwd|api[_-]?key|access[_-]?token|secret|密码|口令)\s*[:=:]\s*)[^\s,,;;]+/gi, '$1[凭据已隐藏]');
|
||||
}
|
||||
export function questionOutcome(result: AssistantResult, reason?: GapReason): QuestionOutcome {
|
||||
if (result.outcome === 'partial' || result.unanswered?.length) return 'partial';
|
||||
if (reason === 'unsupported' || result.outcome === 'unsupported') return 'unsupported';
|
||||
if (reason === 'generation') return 'failed';
|
||||
if (result.outcome === 'help' || result.answerKind === 'help') return 'help';
|
||||
if (result.plan.kind === 'clarify') return 'clarification';
|
||||
if (result.outcome === 'empty' || reason === 'empty') return 'empty';
|
||||
return 'success';
|
||||
}
|
||||
export function businessTypes(result?: AssistantResult): string[] {
|
||||
if (!result) return [];
|
||||
const items = result.sections?.flatMap(section => section.result ? [section.result] : []) || [result];
|
||||
return [...new Set(items.map(item => item.plan.kind === 'flow' ? 'flow' : item.plan.kind === 'clarify' ? 'general' : item.plan.category))];
|
||||
}
|
||||
export const questionLog = {
|
||||
async start(user: AuthUser, input: AssistantRequest, eventType?: 'feedback'): Promise<string> {
|
||||
const id = randomUUID();
|
||||
await pool.query({ sql: `INSERT INTO bi_asset_ai_questions
|
||||
(id,actor_id,user_id,login_name,user_name,permission_level,is_preview,preview_session_id,
|
||||
session_id,base_result_id,subject,event_type,question,action_json,outcome,started_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,'processing',UTC_TIMESTAMP(3))`, timeout: 5000 }, [
|
||||
id, user.preview?.actorId || user.userId, user.userId, user.loginName, user.userName,
|
||||
user.permissionLevel, user.preview ? 1 : 0, user.preview?.sessionId || null,
|
||||
input.sessionId || null, input.baseResultId || null, input.subject,
|
||||
eventType || (input.action ? 'action' : input.sessionId ? 'followup' : 'question'),
|
||||
input.message ? redactQuestion(input.message) : null,
|
||||
input.action ? JSON.stringify(input.action, (_key, value) => typeof value === "string" ? redactQuestion(value) : value) : null,
|
||||
]);
|
||||
return id;
|
||||
},
|
||||
async finish(id: string, completion: QuestionCompletion): Promise<void> {
|
||||
const result = completion.result;
|
||||
// Persist the textual answer and classification, not a duplicate of vehicle detail datasets.
|
||||
await pool.query({ sql: `UPDATE bi_asset_ai_questions SET outcome=?,reason=?,session_id=COALESCE(?,session_id),
|
||||
result_id=?,business_types=?,engine=?,answer=?,finished_at=UTC_TIMESTAMP(3),
|
||||
duration_ms=TIMESTAMPDIFF(MICROSECOND,started_at,UTC_TIMESTAMP(3)) DIV 1000
|
||||
WHERE id=? AND outcome='processing'`, timeout: 5000 }, [
|
||||
completion.outcome, completion.reason || null, completion.sessionId || null,
|
||||
completion.resultId || result?.resultId || null, JSON.stringify(businessTypes(result)),
|
||||
result?.engine || null, result ? redactQuestion(JSON.stringify({ summary: result.summary, warning: result.warning,
|
||||
unanswered: result.unanswered, sections: result.sections?.map(section => ({ title: section.title, summary: section.result?.summary })) })) : null, id,
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
/** A failed update never erases the durable original question. Retry once and expose failure to the caller. */
|
||||
export async function finishQuestionLog(id: string, completion: QuestionCompletion): Promise<boolean> {
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try { await questionLog.finish(id, completion); return true; } catch { /* retry transient failures */ }
|
||||
}
|
||||
console.error('asset_ai_question_completion_failed', id);
|
||||
return false;
|
||||
}
|
||||
@@ -1,3 +1,10 @@
|
||||
import { beforeEach } from 'node:test';
|
||||
import { questionLog } from './question-log.js';
|
||||
beforeEach(t => {
|
||||
if (!('mock' in t)) throw new Error('Expected test context');
|
||||
t.mock.method(questionLog, 'start', async () => 'synthetic-question');
|
||||
t.mock.method(questionLog, 'finish', async () => {});
|
||||
});
|
||||
import assert from "node:assert/strict";
|
||||
import test, { type TestContext } from "node:test";
|
||||
import { Hono } from "hono";
|
||||
|
||||
Reference in New Issue
Block a user