From dfca035749e3b2f2443099f5582dd551e9a0e745 Mon Sep 17 00:00:00 2001 From: sunlei Date: Wed, 24 Jun 2026 20:16:16 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DNapCat=20WebUI?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E7=AE=A1=E7=90=86=E4=B8=8E=E7=99=BB=E5=BD=95?= =?UTF-8?q?SSE=E7=8A=B6=E6=80=81=E7=9B=91=E8=A7=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 2 + .../proxy/napcat-webui-proxy.service.ts | 2 +- src/common/services/tool.service.ts | 8 +- .../login/qqbot-napcat-login.service.ts | 140 +++++++++- .../proxy-rewrite.spec.ts | 47 ++++ .../gateway-proxy.service.spec.ts | 6 +- .../qqbot-napcat-login.service.spec.ts | 254 ++++++++++++++++++ 7 files changed, 454 insertions(+), 5 deletions(-) diff --git a/API.md b/API.md index d92cfe1..b6e94fb 100644 --- a/API.md +++ b/API.md @@ -383,6 +383,8 @@ NapCat Chinese Desktop Runtime v8 使用 KT `NapCatQQ` fork 源码构建出的 ` NapCat WebUI Gateway 是独立部署的内部代理服务,生产镜像由 `dockerfile.gateway` 打包 `dist/apps/napcat-webui-gateway/main.js`,K8s 服务名为 `kt-napcat-webui-gateway`,端口 `48086`。API 侧只通过内部路由 `NAPCAT_WEBUI_GATEWAY_INTERNAL_BASE_URL` 创建、续期、撤销会话和交换一次性 ticket;浏览器只访问公开前缀 `NAPCAT_WEBUI_GATEWAY_PUBLIC_BASE_URL` 下的代理页面、静态资源和 WebSocket 转发,不能直连 NapCat 容器 WebUI。 +Gateway 只改写 NapCat HTML/JS/CSS 中需要浏览器直连的绝对根路径:`/webui/*`、`/api/*`、`/files/*` 和 `/plugin/*`。NapCat 文件管理的 `File` 路由属于 axios `baseURL="/api"` 下的 API 子路径,页面源码里的 `"/File/list"` 必须保持原样,由浏览器最终请求 `/api/File/list`;不能把 `/File/*` 当作独立静态根路径改写到 Gateway session 前缀,否则会形成 `/webui/api/napcat-webui/session/.../File/list` 并让文件管理拿到 HTML。 + 必需环境变量:`NAPCAT_WEBUI_GATEWAY_INTERNAL_BASE_URL`、`NAPCAT_WEBUI_GATEWAY_PUBLIC_BASE_URL`、`NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET`、`NAPCAT_WEBUI_GATEWAY_REDIS_HOST`、`NAPCAT_WEBUI_GATEWAY_REDIS_PORT`、`NAPCAT_WEBUI_GATEWAY_SESSION_TTL_MS`、`NAPCAT_WEBUI_GATEWAY_TICKET_TTL_MS`、`NAPCAT_WEBUI_GATEWAY_UPSTREAM_TIMEOUT_MS`。生产 `NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET` 只来自 Jenkins 私有 `.env.production` 生成的 `kt-template-online-api-env` Secret,不写入 Git 或 manifest 字面量。 部署验收使用:`pnpm exec jest --runTestsByPath test/modules/qqbot/napcat-webui-gateway/gateway-deployment.spec.ts --runInBand`、`pnpm run typecheck`、`pnpm run build`、`Test-Path .\dist\apps\napcat-webui-gateway\main.js`、`git diff --check`。安全验收要求浏览器永远不接收 WebUI token、Credential、上游 URL/端口、Docker 拓扑、Redis 地址或内部 secret。 diff --git a/src/apps/napcat-webui-gateway/infrastructure/proxy/napcat-webui-proxy.service.ts b/src/apps/napcat-webui-gateway/infrastructure/proxy/napcat-webui-proxy.service.ts index 391e509..c062077 100644 --- a/src/apps/napcat-webui-gateway/infrastructure/proxy/napcat-webui-proxy.service.ts +++ b/src/apps/napcat-webui-gateway/infrastructure/proxy/napcat-webui-proxy.service.ts @@ -141,7 +141,7 @@ export function rewriteNapcatTextResponse(input: RewriteTextResponseInput) { )}/webui`; const rewritten = input.body.replace( - /(^|[\s"'`(=,:])\/(webui|api|File|plugin)(?=\/|[?#"'`)]|$)/g, + /(^|[\s"'`(=,:])\/(webui|api|files|plugin)(?=\/|[?#"'`)]|$)/g, (_match, leader: string, root: string) => `${leader}${gatewayWebuiPrefix}/${root}`, ); diff --git a/src/common/services/tool.service.ts b/src/common/services/tool.service.ts index fe7b109..98ecd7a 100644 --- a/src/common/services/tool.service.ts +++ b/src/common/services/tool.service.ts @@ -581,7 +581,13 @@ export class ToolsService { * @param err - 异常或失败对象;提取状态码、错误体、堆栈或失败原因。 */ isNapcatAlreadyLoggedInError(err: unknown) { - return this.getErrorMessage(err).includes('QQ Is Logined'); + const message = this.getErrorMessage(err); + return ( + message.includes('QQ Is Logined') || + (message.includes('当前账号') && + message.includes('已登录') && + message.includes('无法重复登录')) + ); } /** diff --git a/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts b/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts index e6dec3a..cfe26ad 100644 --- a/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts +++ b/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts @@ -34,6 +34,10 @@ type PendingQrcodeUpdateOptions = { clearStaleQrcode?: boolean; requireFresh?: boolean; }; +type ScanStatusMonitorDeadline = { + expiresAt: number; + qrcode: string; +}; @Injectable() export class QqbotNapcatLoginService { @@ -45,6 +49,14 @@ export class QqbotNapcatLoginService { string, Set<(event: QqbotLoginScanEvent) => void> > = {}; + private readonly scanStatusMonitorTimers: Record< + string, + NodeJS.Timeout | undefined + > = {}; + private readonly scanStatusMonitorDeadlines: Record< + string, + ScanStatusMonitorDeadline | undefined + > = {}; private readonly refreshStartTasks: Record< string, Promise | undefined @@ -53,7 +65,10 @@ export class QqbotNapcatLoginService { /** * 清理 NapCat回调状态。 */ - clear: () => this.loginSessionStore.clear(), + clear: () => { + this.stopAllScanStatusMonitors(); + this.loginSessionStore.clear(); + }, /** * 读取 NapCat回调数据。 * @param sessionId - NapCat ID;定位本次读取、更新、删除或关联的NapCat。 @@ -1507,6 +1522,9 @@ export class QqbotNapcatLoginService { status, step, }); + if (this.shouldMonitorScanStatus(session)) { + this.startScanStatusMonitor(session); + } } /** @@ -1709,10 +1727,130 @@ export class QqbotNapcatLoginService { * @param sessionId - NapCat ID;定位本次读取、更新、删除或关联的NapCat。 */ private cleanupSessionEvents(sessionId: string) { + this.stopScanStatusMonitor(sessionId); delete this.sessionEventLogCache[sessionId]; delete this.sessionEventListenerCache[sessionId]; } + /** + * Determines whether a pending QR session needs server-side polling so SSE can progress without browser polling. + * @param session - Login session whose QR, preparation flags, and terminal status decide monitor ownership. + * @returns True when the backend should keep reconciling NapCat status for this session. + */ + private shouldMonitorScanStatus(session: QqbotLoginScanSession) { + return ( + session.status === 'pending' && + !!session.qrcode && + !session.preparingContainer && + !session.preparingRelogin + ); + } + + /** + * Starts one bounded status monitor for a QR session and lets the timer avoid holding the Node process open. + * @param session - Pending QR session whose id is used to drive later status reconciliation. + */ + private startScanStatusMonitor(session: QqbotLoginScanSession) { + this.ensureScanStatusMonitorDeadline(session); + if (this.hasScanStatusMonitorDeadlinePassed(session)) { + void this.expireSession(session); + return; + } + if (this.scanStatusMonitorTimers[session.id]) return; + const timer = setTimeout(() => { + this.scanStatusMonitorTimers[session.id] = undefined; + void this.runScanStatusMonitor(session.id); + }, this.getLoginPollIntervalMs()); + timer.unref?.(); + this.scanStatusMonitorTimers[session.id] = timer; + } + + /** + * Stops any server-side status monitor for a login session. + * @param sessionId - Session key whose timer should no longer reconcile NapCat state. + */ + private stopScanStatusMonitor(sessionId: string) { + const timer = this.scanStatusMonitorTimers[sessionId]; + if (timer) clearTimeout(timer); + delete this.scanStatusMonitorTimers[sessionId]; + delete this.scanStatusMonitorDeadlines[sessionId]; + } + + /** + * Stops every server-side QR status monitor before the backing session store is cleared. + */ + private stopAllScanStatusMonitors() { + Object.keys(this.scanStatusMonitorTimers).forEach((sessionId) => { + this.stopScanStatusMonitor(sessionId); + }); + } + + /** + * Captures the active QR code deadline so monitor polling cannot extend the same QR session forever. + * @param session - Pending QR session whose current QR URL and expiry form the monitor deadline snapshot. + */ + private ensureScanStatusMonitorDeadline(session: QqbotLoginScanSession) { + if (!session.qrcode) return; + const current = this.scanStatusMonitorDeadlines[session.id]; + if (current?.qrcode === session.qrcode) return; + this.scanStatusMonitorDeadlines[session.id] = { + expiresAt: session.expiresAt, + qrcode: session.qrcode, + }; + } + + /** + * Checks the monitor-owned QR deadline instead of the session expiry that status polling may renew. + * @param session - Pending QR session whose monitor snapshot decides terminal expiry. + * @returns True when the monitored QR should be expired by the backend monitor. + */ + private hasScanStatusMonitorDeadlinePassed( + session: QqbotLoginScanSession, + ) { + const deadline = this.scanStatusMonitorDeadlines[session.id]; + if (deadline && session.qrcode && deadline.qrcode !== session.qrcode) { + this.ensureScanStatusMonitorDeadline(session); + return false; + } + return !!deadline && Date.now() > deadline.expiresAt; + } + + /** + * Polls the same status path used by Admin so SSE can emit success/expired events after QR generation. + * @param sessionId - Pending QR session id to reload from the session store before each poll. + */ + private async runScanStatusMonitor(sessionId: string) { + try { + const session = await this.loginSessionStore.get(sessionId); + if (!session || !this.shouldMonitorScanStatus(session)) return; + if (this.hasScanStatusMonitorDeadlinePassed(session)) { + await this.expireSession(session); + return; + } + const result = await this.status(sessionId); + if (result.status !== 'pending') return; + const current = + this.loginSessionStore.getCached(sessionId) || + (await this.loginSessionStore.get(sessionId)); + if (current && this.shouldMonitorScanStatus(current)) { + if (this.hasScanStatusMonitorDeadlinePassed(current)) { + await this.expireSession(current); + return; + } + this.startScanStatusMonitor(current); + } + } catch { + const current = this.loginSessionStore.getCached(sessionId); + if (current && this.shouldMonitorScanStatus(current)) { + if (this.hasScanStatusMonitorDeadlinePassed(current)) { + await this.expireSession(current); + return; + } + this.startScanStatusMonitor(current); + } + } + } + /** * 执行 NapCat 登录运行态流程。 * @param session - session 输入;使用 `status`、`errorMessage`、`expiresAt`、`qrcode` 字段生成结果。 diff --git a/test/apps/napcat-webui-gateway/proxy-rewrite.spec.ts b/test/apps/napcat-webui-gateway/proxy-rewrite.spec.ts index fca189d..ba577be 100644 --- a/test/apps/napcat-webui-gateway/proxy-rewrite.spec.ts +++ b/test/apps/napcat-webui-gateway/proxy-rewrite.spec.ts @@ -351,6 +351,11 @@ describe('NapcatWebuiProxyService redirect rewriting', () => { let credentialClient: { getCredential: jest.Mock; }; + let upstreamRequests: Array<{ + authorization?: string; + method?: string; + url?: string; + }>; /** * Starts a tiny NapCat-like upstream that redirects `/webui` to `/webui/`. @@ -358,6 +363,25 @@ describe('NapcatWebuiProxyService redirect rewriting', () => { */ async function startRedirectUpstream() { const server = createServer((req, res) => { + upstreamRequests.push({ + authorization: req.headers.authorization, + method: req.method, + url: req.url, + }); + + if (req.url === '/api/File/list?path=%2F') { + res.statusCode = HttpStatus.OK; + res.setHeader('content-type', 'application/json; charset=UTF-8'); + res.end( + JSON.stringify({ + code: 0, + data: [{ isDirectory: true, name: 'app', size: 0 }], + message: 'success', + }), + ); + return; + } + if (req.url === '/webui') { res.statusCode = HttpStatus.MOVED_PERMANENTLY; res.setHeader('content-type', 'text/html; charset=UTF-8'); @@ -386,6 +410,7 @@ describe('NapcatWebuiProxyService redirect rewriting', () => { } beforeEach(async () => { + upstreamRequests = []; const started = await startRedirectUpstream(); upstream = started.server; upstreamBaseUrl = started.baseUrl; @@ -446,4 +471,26 @@ describe('NapcatWebuiProxyService redirect rewriting', () => { `/napcat-webui/session/${SESSION_ID}/webui/webui/`, ); }); + + it('forwards NapCat File API requests under /api without duplicating the Gateway session prefix', async () => { + const response = await request(app.getHttpServer()) + .get(`/napcat-webui/session/${SESSION_ID}/webui/api/File/list?path=%2F`) + .expect(HttpStatus.OK); + + expect(response.body).toEqual({ + code: 0, + data: [{ isDirectory: true, name: 'app', size: 0 }], + message: 'success', + }); + expect(upstreamRequests).toContainEqual({ + authorization: 'Bearer credential-1', + method: 'GET', + url: '/api/File/list?path=%2F', + }); + expect( + upstreamRequests.some((item) => + item.url?.includes('/api/napcat-webui/session/'), + ), + ).toBe(false); + }); }); diff --git a/test/modules/qqbot/napcat-webui-gateway/gateway-proxy.service.spec.ts b/test/modules/qqbot/napcat-webui-gateway/gateway-proxy.service.spec.ts index 84b7f08..9b11adc 100644 --- a/test/modules/qqbot/napcat-webui-gateway/gateway-proxy.service.spec.ts +++ b/test/modules/qqbot/napcat-webui-gateway/gateway-proxy.service.spec.ts @@ -17,7 +17,7 @@ describe('NapcatWebuiProxyService response rewriting', () => { body: [ '', '', - '', + '', 'url("/webui/fonts/CustomFont.woff")', ].join(''), sessionId: 'sess_1', @@ -32,8 +32,10 @@ describe('NapcatWebuiProxyService response rewriting', () => { expect(rewritten).toContain( 'baseURL="/napcat-webui/session/sess_1/webui/api"', ); + expect(rewritten).toContain('file="/File/list?path=%2F"'); + expect(rewritten).not.toContain('/webui/api/napcat-webui/session'); expect(rewritten).toContain( - 'file="/napcat-webui/session/sess_1/webui/File/font/upload/webui"', + 'theme="/napcat-webui/session/sess_1/webui/files/theme.css"', ); expect(rewritten).toContain( 'url("/napcat-webui/session/sess_1/webui/webui/fonts/CustomFont.woff")', diff --git a/test/qqbot/account/qqbot-napcat-login.service.spec.ts b/test/qqbot/account/qqbot-napcat-login.service.spec.ts index 7bf1ec0..f35d74b 100644 --- a/test/qqbot/account/qqbot-napcat-login.service.spec.ts +++ b/test/qqbot/account/qqbot-napcat-login.service.spec.ts @@ -1054,6 +1054,66 @@ describe('QqbotNapcatLoginService', () => { ); }); + it('confirms real login status when quick login reports already logged in using the NapCat Chinese message', async () => { + const container = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-quick-already-online-cn', + name: 'napcat-1914728559', + }; + const accountService = { + ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), + }; + const containerService = { + bindAccount: jest.fn().mockResolvedValue(undefined), + resetRuntimeLoginState: jest.fn(), + restartRuntimeContainer: jest.fn(), + }; + const refreshService = new QqbotNapcatLoginService( + { get: jest.fn() } as unknown as ConfigService, + accountService as unknown as QqbotAccountService, + containerService as unknown as QqbotNapcatContainerService, + new ToolsService(), + ); + const session = (refreshService as any).createSession({ + accountId: 'account-1', + container, + expectedSelfId: '1914728559', + mode: 'refresh', + preparingRelogin: true, + status: 'pending', + }); + (refreshService as any).sessions.set(session.id, session); + mockWebuiLoginPost(refreshService, { + quickError: '当前账号(1914728559)已登录,无法重复登录', + }); + const getLoginStatus = jest + .spyOn(refreshService as any, 'getLoginStatus') + .mockResolvedValue({ isLogin: true }); + jest.spyOn(refreshService as any, 'getLoginInfo').mockResolvedValue({ + nickname: 'Mirror', + online: true, + uin: '1914728559', + }); + const refreshQrcode = jest + .spyOn(refreshService as any, 'refreshOrGetQrcode') + .mockResolvedValue('fallback-qrcode'); + + await (refreshService as any).prepareReloginQrcode(session, container); + + expect(getLoginStatus).toHaveBeenCalledWith(container, true); + expect(refreshQrcode).not.toHaveBeenCalled(); + expect(session.status).toBe('success'); + const events = (refreshService as any).sessionEventLogs.get(session.id); + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: 'NapCat 已登录,已确认真实在线状态', + step: 'login-success', + }), + ]), + ); + }); + it('keeps binding errors terminal after already-logged quick status is confirmed online', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', @@ -1114,6 +1174,200 @@ describe('QqbotNapcatLoginService', () => { expect(steps).toContain('relogin-error'); }); + it('monitors a ready qrcode session and publishes login success without frontend polling', async () => { + const container = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-monitor-qrcode', + name: 'napcat-monitor-qrcode', + }; + const accountService = { + ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), + }; + const containerService = { + bindAccount: jest.fn().mockResolvedValue(undefined), + }; + const monitorService = new QqbotNapcatLoginService( + { + get: jest.fn((key: string) => + key === 'QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS' ? 10 : undefined, + ), + } as unknown as ConfigService, + accountService as unknown as QqbotAccountService, + containerService as unknown as QqbotNapcatContainerService, + new ToolsService(), + ); + const session = (monitorService as any).createSession({ + accountId: 'account-1', + container, + expectedSelfId: '1914728559', + mode: 'refresh', + qrcode: 'https://txz.qq.com/p?k=ready&f=1600001615', + status: 'pending', + }); + (monitorService as any).sessions.set(session.id, session); + jest + .spyOn(monitorService as any, 'getSessionContainer') + .mockResolvedValue(container); + const getLoginStatus = jest + .spyOn(monitorService as any, 'getLoginStatus') + .mockResolvedValue({ isLogin: true }); + jest.spyOn(monitorService as any, 'getLoginInfo').mockResolvedValue({ + nick: 'Mirror', + online: true, + uin: '1914728559', + }); + + (monitorService as any).publishScanResultEvent( + session, + 'qrcode-ready', + 'success', + '登录二维码已生成', + ); + await new Promise((resolve) => setTimeout(resolve, 30)); + + expect(getLoginStatus).toHaveBeenCalledWith(container); + expect(session.status).toBe('success'); + expect((monitorService as any).sessionEventLogs.get(session.id)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ step: 'login-success' }), + ]), + ); + }); + + it('does not let the server-side qrcode monitor extend the original qrcode deadline on temporary errors', async () => { + const container = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-monitor-temporary-error', + name: 'napcat-monitor-temporary-error', + }; + const monitorService = new QqbotNapcatLoginService( + { + get: jest.fn((key: string) => { + if (key === 'QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS') return 10; + if (key === 'NAPCAT_LOGIN_QR_EXPIRE_MS') return 25; + return undefined; + }), + } as unknown as ConfigService, + { + ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), + } as unknown as QqbotAccountService, + { + bindAccount: jest.fn().mockResolvedValue(undefined), + } as unknown as QqbotNapcatContainerService, + new ToolsService(), + ); + const session = (monitorService as any).createSession({ + accountId: 'account-1', + container, + expectedSelfId: '1914728559', + mode: 'refresh', + qrcode: 'https://txz.qq.com/p?k=temporary&f=1600001615', + status: 'pending', + }); + (monitorService as any).sessions.set(session.id, session); + jest + .spyOn(monitorService as any, 'getSessionContainer') + .mockResolvedValue(container); + jest + .spyOn(monitorService as any, 'getLoginStatus') + .mockRejectedValue(new Error('NapCat 请求超时')); + const publishResult = jest.spyOn( + monitorService as any, + 'publishScanResultEvent', + ); + + (monitorService as any).publishScanResultEvent( + session, + 'qrcode-ready', + 'success', + '登录二维码已生成', + ); + await new Promise((resolve) => setTimeout(resolve, 80)); + + expect(session.status).toBe('expired'); + expect(publishResult).toHaveBeenCalledWith( + session, + 'session-expired', + 'error', + expect.any(String), + ); + }); + + it('refreshes the monitor deadline when a new qrcode is published while a timer is already active', async () => { + const container = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-monitor-new-qrcode', + name: 'napcat-monitor-new-qrcode', + }; + let qrTtlReads = 0; + const monitorService = new QqbotNapcatLoginService( + { + get: jest.fn((key: string) => { + if (key === 'QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS') return 60; + if (key === 'NAPCAT_LOGIN_QR_EXPIRE_MS') { + qrTtlReads += 1; + return qrTtlReads <= 2 ? 25 : 120; + } + return undefined; + }), + } as unknown as ConfigService, + { + ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), + } as unknown as QqbotAccountService, + { + bindAccount: jest.fn().mockResolvedValue(undefined), + } as unknown as QqbotNapcatContainerService, + new ToolsService(), + ); + const firstQrcode = 'https://txz.qq.com/p?k=first&f=1600001615'; + const nextQrcode = 'https://txz.qq.com/p?k=next&f=1600001615'; + const session = (monitorService as any).createSession({ + accountId: 'account-1', + container, + expectedSelfId: '1914728559', + mode: 'refresh', + qrcode: firstQrcode, + status: 'pending', + }); + (monitorService as any).sessions.set(session.id, session); + jest + .spyOn(monitorService as any, 'getSessionContainer') + .mockResolvedValue(container); + jest.spyOn(monitorService as any, 'getLoginStatus').mockResolvedValue({ + isLogin: false, + qrcodeurl: nextQrcode, + }); + const publishResult = jest.spyOn( + monitorService as any, + 'publishScanResultEvent', + ); + + (monitorService as any).publishScanResultEvent( + session, + 'qrcode-ready', + 'success', + '登录二维码已生成', + ); + await new Promise((resolve) => setTimeout(resolve, 10)); + session.qrcode = nextQrcode; + (monitorService as any).publishScanResultEvent( + session, + 'qrcode-ready', + 'success', + '登录二维码已刷新', + ); + await new Promise((resolve) => setTimeout(resolve, 80)); + + expect(session.status).toBe('pending'); + expect( + (monitorService as any).scanStatusMonitorDeadlines[session.id]?.qrcode, + ).toBe(nextQrcode); + expect( + publishResult.mock.calls.some((call) => call[1] === 'session-expired'), + ).toBe(false); + (monitorService as any).sessions.clear(); + }); + it('does not rebuild even when the refresh session already has a rebuild count', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/',