From 0a3a0525acddb03392eb611f2c6826340ae4ee2e Mon Sep 17 00:00:00 2001 From: li-chx Date: Thu, 29 Oct 2026 00:36:57 +0800 Subject: [PATCH 1/2] =?UTF-8?q?:bug:=20fix(bind):=20=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E7=BB=91=E5=AE=9A=E5=AE=B9=E9=94=99=E5=B9=B6?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E9=94=99=E8=AF=AF=E8=AF=8A=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/browser/bind.tsx | 97 ++++-- lib/i18n/locales/en.json | 4 + lib/i18n/locales/zh.json | 4 + scripts/tests/bind-native.test.ts | 485 ++++++++++++++++++++++++++ scripts/tests/bind-report.test.ts | 249 ++++++++++++++ scripts/tests/bind-script.test.ts | 531 +++++++++++++++++++++++++++++ scripts/tests/bind-test-helpers.ts | 51 +++ 7 files changed, 1393 insertions(+), 28 deletions(-) create mode 100644 scripts/tests/bind-native.test.ts create mode 100644 scripts/tests/bind-report.test.ts create mode 100644 scripts/tests/bind-script.test.ts create mode 100644 scripts/tests/bind-test-helpers.ts diff --git a/app/browser/bind.tsx b/app/browser/bind.tsx index e27cce2..4f38cb1 100644 --- a/app/browser/bind.tsx +++ b/app/browser/bind.tsx @@ -269,17 +269,11 @@ const INJECTED_JS = `(function(){ var giveUp=window.__talentTries>=8; function retry(){ if(giveUp){ - finish('bindingFailed'); + finish('profileUnavailable',{identityUnavailable:true}); return; } setTimeout(fetchTalentInfo,1000); } - function retryStudent(reason,stu,onlyFinal){ - if(stu!==undefined && (!onlyFinal || giveUp)){ - diagnostic('studentInfo','invalid-response',undefined,reason,stu); - } - retry(); - } function pick(arr,code){ var field=(arr||[]).find(function(item){return item.fieldCode===code;}); return field?String(field.fieldValue==null?'':field.fieldValue):''; @@ -296,6 +290,17 @@ const INJECTED_JS = `(function(){ } var sn=String(u.data.sn); var name=u.data.name||''; + function retryStudent(reason,stu,onlyFinal){ + if(stu!==undefined && (!onlyFinal || giveUp)){ + diagnostic('studentInfo','invalid-response',undefined,reason,stu); + } + if(giveUp){ + // 只使用本轮已验证的身份,解析失败时不保留半份附加资料。 + finish('info',{name:name,studentId:sn,cardId:'',college:'',eduLevel:''}); + return; + } + retry(); + } return Promise.all([ getJson('studentInfo','/information-center/xd/user/findStudentInfoWechat/'+encodeURIComponent(sn)).catch(function(){return undefined;}), getDictionary('colleges','/information-center/xd/commons/getColleges'), @@ -363,6 +368,7 @@ export default function BindScreen() { } | null>(null); const flowFinished = useRef(false); const preserveSession = useRef(false); + const screenActive = useRef(true); const reportBindError = useRef(createBindErrorReporter(reportError)).current; const setWebviewRef = useCallback((view: WebView | null) => { @@ -371,26 +377,32 @@ export default function BindScreen() { }, []); const handleLoadEnd = useCallback(() => { - if (webviewAttempt.id !== activeAttemptId.current || flowFinished.current) + if ( + !screenActive.current || + webviewAttempt.id !== activeAttemptId.current || + flowFinished.current + ) return; webview.current?.injectJavaScript(INJECTED_JS); }, [webviewAttempt.id]); useEffect(() => { + screenActive.current = true; return () => { + screenActive.current = false; if (!preserveSession.current) { CookieManager.clearAll(true).catch(() => {}); } }; }, []); - const handleFailure = () => { + const handleFailure = (message = t("user.bindFailedSub")) => { if (flowFinished.current) return; flowFinished.current = true; Toast.show({ type: "error", text1: t("user.bindFailed"), - text2: t("user.bindFailedSub"), + text2: message, position: "bottom", }); if (router.canGoBack()) router.back(); @@ -398,7 +410,11 @@ export default function BindScreen() { const recoverAppEntry = (failure: TalentLoginFailure) => { // 忽略旧 WebView 的延迟回调 - if (webviewAttempt.id !== activeAttemptId.current || flowFinished.current) + if ( + !screenActive.current || + webviewAttempt.id !== activeAttemptId.current || + flowFinished.current + ) return true; if (webviewAttempt.id !== 0 || !isRecoverableTalentRootError(failure)) return false; @@ -450,11 +466,14 @@ export default function BindScreen() { }; const onMessage = async (event: { nativeEvent: { data: string } }) => { - if (webviewAttempt.id !== activeAttemptId.current) return; + if (!screenActive.current || webviewAttempt.id !== activeAttemptId.current) + return; try { const msg = JSON.parse(event.nativeEvent.data); if (msg.type === "diagnostic") { reportBindError(msg); + } else if (flowFinished.current) { + return; } else if (msg.type === "bindingFailed") { handleFailure(); } else if (msg.type === "credentials") { @@ -463,36 +482,58 @@ export default function BindScreen() { password: msg.password, }; } else if (msg.type === "profileUnavailable") { - if (flowFinished.current) return; flowFinished.current = true; preserveSession.current = true; useOnboardingStore.getState().reset(); Toast.show({ type: "info", - text1: t("user.bindProfileUnavailable"), - text2: t("user.bindProfileUnavailableSub"), + text1: t( + msg.identityUnavailable === true + ? "user.bindIdentityUnavailable" + : "user.bindProfileUnavailable", + ), + text2: t( + msg.identityUnavailable === true + ? "user.bindIdentityUnavailableSub" + : "user.bindProfileUnavailableSub", + ), position: "bottom", }); router.replace({ pathname: "/onboarding", params: { step: "setup" }, }); - } else if (msg.type === "info" && pendingCredentials.current) { - if (flowFinished.current) return; + } else if (msg.type === "info") { + if (!pendingCredentials.current) { + reportError(new Error("Bind credentials unavailable"), { + module: "bind", + }); + handleFailure(t("user.bindCredentialsUnavailable")); + return; + } flowFinished.current = true; - preserveSession.current = true; const { username, password } = pendingCredentials.current; - await useUserBindStore - .getState() - .bind( - username, - msg.name, - password, - msg.cardId, - msg.college, - msg.eduLevel, - ); + try { + await useUserBindStore + .getState() + .bind( + username, + msg.name, + password, + msg.cardId, + msg.college, + msg.eduLevel, + ); + } catch (error) { + reportError(error, { module: "bind" }); + if (!screenActive.current) return; + flowFinished.current = false; + handleFailure(t("user.bindSaveFailed")); + return; + } + if (!screenActive.current) return; + preserveSession.current = true; Toast.show({ type: "success", diff --git a/lib/i18n/locales/en.json b/lib/i18n/locales/en.json index 08d2db7..d725f69 100644 --- a/lib/i18n/locales/en.json +++ b/lib/i18n/locales/en.json @@ -261,6 +261,10 @@ "bindPasswordError": "Incorrect password", "bindPasswordErrorSub": "Please link your WHUT account again", "bindFailedSub": "iWUT is temporarily unavailable, please try again later", + "bindIdentityUnavailable": "Account verification incomplete", + "bindIdentityUnavailableSub": "You can continue setup and try linking your account later", + "bindCredentialsUnavailable": "Login credentials are unavailable. Please sign in again to link your account", + "bindSaveFailed": "Unable to save your account. Please try again later", "bindProfileUnavailable": "Account verified", "bindProfileUnavailableSub": "Your profile is not available yet, but you can continue and import your timetable", "menuTools": "Tools", diff --git a/lib/i18n/locales/zh.json b/lib/i18n/locales/zh.json index f00bf0b..5c920e9 100644 --- a/lib/i18n/locales/zh.json +++ b/lib/i18n/locales/zh.json @@ -261,6 +261,10 @@ "bindPasswordError": "密码错误", "bindPasswordErrorSub": "请重新绑定智慧理工大账号", "bindFailedSub": "智慧理工大暂时无法访问,请稍后重试", + "bindIdentityUnavailable": "暂未完成账号验证", + "bindIdentityUnavailableSub": "可先继续设置,稍后再尝试绑定账号", + "bindCredentialsUnavailable": "未获取到登录凭据,请重新登录后绑定", + "bindSaveFailed": "无法保存账号信息,请稍后重试", "bindProfileUnavailable": "账号验证成功", "bindProfileUnavailableSub": "暂未获取到个人资料,可先继续导入课表", "menuTools": "工具", diff --git a/scripts/tests/bind-native.test.ts b/scripts/tests/bind-native.test.ts new file mode 100644 index 0000000..54cb11b --- /dev/null +++ b/scripts/tests/bind-native.test.ts @@ -0,0 +1,485 @@ +import { describe, expect, mock, test } from "bun:test"; +import { persist } from "zustand/middleware"; +import { createStore, type StoreApi } from "zustand/vanilla"; + +import { loadBindDeclarations, loadTsDeclarations } from "./bind-test-helpers"; + +interface BindState { + isBound: boolean; + studentId: string; + studentName: string; + cardId: string; + college: string; + eduLevel: string; + bind: (...args: string[]) => Promise; + getCredentials: () => Promise<{ username: string; password: string } | null>; +} + +type PageEvent = { + nativeEvent: { + url: string; + statusCode?: number; + code?: number; + description?: string; + domain?: string; + }; + preventDefault: () => void; +}; + +type WebViewProps = { + key: number; + source: { uri: string }; + ref: ( + view: { + injectJavaScript: (script: string) => void; + clearCache: (all: boolean) => void; + } | null, + ) => void; + onLoadEnd: () => void; + onMessage: (event: { nativeEvent: { data: string } }) => Promise; + onError: (event: PageEvent) => void; + onHttpError: (event: PageEvent) => void; +}; + +type ElementNode = { + type: unknown; + props: Record; + children: ElementNode[]; +}; + +function deferred() { + let resolve!: () => void; + let reject!: (error: Error) => void; + const promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + return { promise, resolve, reject }; +} + +function makeStore( + writePassword: ( + key: string, + password: string, + ) => Promise = async () => {}, +) { + const secureWrite = mock(writePassword); + const persistWrite = mock(() => {}); + const cookies = { clearAll: mock(async () => {}) }; + const { useUserBindStore: store } = loadTsDeclarations<{ + useUserBindStore: StoreApi; + }>("store/user-bind.ts", ["useUserBindStore"], { + create: () => createStore, + persist, + zustandStorage: { + getItem: () => null, + setItem: persistWrite, + removeItem: () => {}, + }, + SecureStore: { + setItemAsync: secureWrite, + getItemAsync: async () => "test-password", + deleteItemAsync: async () => {}, + }, + CookieManager: cookies, + }); + return { store, secureWrite, persistWrite, cookies }; +} + +/** Render the real screen with isolated hook/UI adapters; no app modules are mocked globally. */ +function mountBindScreen( + options: { + writePassword?: (key: string, password: string) => Promise; + canGoBack?: boolean; + } = {}, +) { + const user = makeStore(options.writePassword); + const hooks: unknown[] = []; + let hook = 0; + const cleanups: (() => void)[] = []; + const toast = mock((_value: Record) => {}); + const report = mock( + (_error: unknown, _context?: Record) => {}, + ); + const reset = mock(() => {}); + const router = { + canGoBack: () => options.canGoBack !== false, + back: mock(() => {}), + replace: mock((_route: unknown) => {}), + }; + const views: { + injectJavaScript: ReturnType void>>; + clearCache: ReturnType void>>; + }[] = []; + const { BindScreen } = loadBindDeclarations<{ + BindScreen: () => ElementNode; + }>(["BindScreen"], { + React: { + createElement: ( + type: unknown, + props: Record | null, + ...children: ElementNode[] + ) => ({ type, props: props ?? {}, children }), + }, + useRef: (initial: T) => { + const index = hook++; + hooks[index] ??= { current: initial }; + return hooks[index]; + }, + useState: (initial: T) => { + const index = hook++; + hooks[index] ??= { value: initial }; + const slot = hooks[index] as { value: T }; + return [ + slot.value, + (value: T) => { + slot.value = value; + }, + ]; + }, + useCallback: (callback: T) => { + hook++; + return callback; + }, + useEffect: (effect: () => void | (() => void)) => { + const index = hook++; + if (!hooks[index]) { + hooks[index] = true; + const cleanup = effect(); + if (cleanup) cleanups.push(cleanup); + } + }, + useMarkRouteInteractive: () => {}, + useT: () => (key: string) => key, + useUserBindStore: user.store, + useOnboardingStore: { getState: () => ({ reset }) }, + CookieManager: user.cookies, + logger: { extend: () => ({ info: () => {} }) }, + reportError: report, + router, + Toast: { show: toast }, + StackScreenContent: "container", + View: "view", + Stack: { Screen: "screen" }, + WebView: "webview", + IS_DEV: false, + }); + const findWebView = (node: ElementNode): WebViewProps | undefined => { + if (node.type === "webview") return node.props as unknown as WebViewProps; + return node.children.map(findWebView).find(Boolean); + }; + let props: WebViewProps | undefined; + const render = () => { + hook = 0; + const next = findWebView(BindScreen()); + if (!next) throw new Error("BindScreen did not render its WebView"); + if (!props || next.key !== props.key) { + props?.ref(null); + const view = { + injectJavaScript: mock((_script: string) => {}), + clearCache: mock((_all: boolean) => {}), + }; + views.push(view); + next.ref(view); + } + props = next; + return next; + }; + render(); + return { + ...user, + toast, + report, + reset, + router, + views, + render, + get props() { + return props!; + }, + send: (message: Record, target = props!) => + target.onMessage({ nativeEvent: { data: JSON.stringify(message) } }), + unmount: () => { + props?.ref(null); + cleanups.forEach((cleanup) => cleanup()); + }, + }; +} + +const credentials = { + type: "credentials", + username: "typed-account", + password: "test-password", +}; +const info = { + type: "info", + studentId: "server-student-id", + name: "Test user", + cardId: "", + college: "", + eduLevel: "", +}; +const rootUrl = "https://talent.whut.edu.cn/"; +function pageEvent( + url: string, + extra: Partial = {}, +): PageEvent { + return { nativeEvent: { url, ...extra }, preventDefault: mock(() => {}) }; +} + +describe("binding native screen", () => { + test.each([false, true])( + "profileUnavailable preserves session without binding (identityUnavailable=%s)", + async (identityUnavailable) => { + const screen = mountBindScreen(); + await screen.send(credentials); + await screen.send({ type: "profileUnavailable", identityUnavailable }); + await screen.send(info); + await screen.send({ type: "bindingFailed" }); + expect(screen.secureWrite).not.toHaveBeenCalled(); + expect(screen.store.getState().isBound).toBe(false); + expect(screen.reset).toHaveBeenCalledTimes(1); + expect(screen.router.replace).toHaveBeenCalledTimes(1); + expect(screen.router.replace).toHaveBeenCalledWith({ + pathname: "/onboarding", + params: { step: "setup" }, + }); + expect(screen.router.back).not.toHaveBeenCalled(); + expect(screen.toast).toHaveBeenCalledTimes(1); + expect(screen.toast.mock.calls[0]?.[0].text1).toBe( + identityUnavailable + ? "user.bindIdentityUnavailable" + : "user.bindProfileUnavailable", + ); + screen.unmount(); + expect(screen.cookies.clearAll).not.toHaveBeenCalled(); + }, + ); + + test("binding waits for SecureStore, preserves identity mapping, and ignores duplicate terminal messages", async () => { + const write = deferred(); + const screen = mountBindScreen({ writePassword: () => write.promise }); + await screen.send(credentials); + const completion = screen.send(info); + expect(screen.secureWrite).toHaveBeenCalledTimes(1); + expect(screen.secureWrite).toHaveBeenCalledWith( + "zhlgd_password", + "test-password", + ); + expect(screen.store.getState().isBound).toBe(false); + expect(screen.persistWrite).not.toHaveBeenCalled(); + expect(screen.router.back).not.toHaveBeenCalled(); + await screen.send(info); + await screen.send({ type: "profileUnavailable" }); + await screen.send({ type: "bindingFailed" }); + write.resolve(); + await completion; + expect(screen.secureWrite).toHaveBeenCalledTimes(1); + expect(screen.store.getState()).toMatchObject({ + isBound: true, + studentId: "typed-account", + studentName: "Test user", + cardId: "", + college: "", + eduLevel: "", + }); + expect(screen.router.back).toHaveBeenCalledTimes(1); + expect(screen.toast.mock.calls[0]?.[0].type).toBe("success"); + expect(JSON.stringify(screen.persistWrite.mock.calls)).not.toContain( + "test-password", + ); + screen.unmount(); + expect(screen.cookies.clearAll).not.toHaveBeenCalled(); + }); + + test("missing captured credentials ends with feedback rather than leaving info pending", async () => { + const screen = mountBindScreen(); + await screen.send(info); + expect(screen.secureWrite).not.toHaveBeenCalled(); + expect(screen.store.getState().isBound).toBe(false); + expect(screen.toast.mock.calls[0]?.[0]).toMatchObject({ + type: "error", + text2: "user.bindCredentialsUnavailable", + }); + expect(screen.router.back).toHaveBeenCalledTimes(1); + await screen.send(credentials); + await screen.send(info); + expect(screen.secureWrite).not.toHaveBeenCalled(); + screen.unmount(); + expect(screen.cookies.clearAll).toHaveBeenCalledTimes(1); + expect(screen.cookies.clearAll).toHaveBeenCalledWith(true); + }); + + test("SecureStore rejection reports and exits without persisting or preserving the session", async () => { + const failure = new Error("SecureStore unavailable"); + const screen = mountBindScreen({ + writePassword: async () => { + throw failure; + }, + }); + await screen.send(credentials); + await screen.send(info); + expect(screen.store.getState().isBound).toBe(false); + expect(screen.persistWrite).not.toHaveBeenCalled(); + expect(screen.report).toHaveBeenCalledWith( + failure, + expect.objectContaining({ module: "bind" }), + ); + expect(screen.toast.mock.calls[0]?.[0]).toMatchObject({ + type: "error", + text2: "user.bindSaveFailed", + }); + expect(screen.router.back).toHaveBeenCalledTimes(1); + await screen.send(info); + expect(screen.secureWrite).toHaveBeenCalledTimes(1); + screen.unmount(); + expect(screen.cookies.clearAll).toHaveBeenCalledTimes(1); + expect(screen.cookies.clearAll).toHaveBeenCalledWith(true); + }); + + test("bare-root 404 recovers once, preserves credentials, and rejects old WebView callbacks", async () => { + const screen = mountBindScreen(); + const old = screen.props; + await screen.send(credentials); + old.onHttpError(pageEvent(rootUrl, { statusCode: 404 })); + const recovered = screen.render(); + expect(recovered.key).toBe(1); + expect(recovered.source.uri).toBe( + `${rootUrl}information-center/center/index.html?appId=2`, + ); + expect(screen.cookies.clearAll).not.toHaveBeenCalled(); + expect(screen.views[0]?.clearCache).toHaveBeenCalledTimes(1); + expect(screen.views[0]?.clearCache).toHaveBeenCalledWith(true); + old.onLoadEnd(); + await screen.send({ ...credentials, username: "stale-account" }, old); + await screen.send(info, old); + await screen.send({ type: "profileUnavailable" }, old); + old.onHttpError(pageEvent(rootUrl, { statusCode: 500 })); + const staleError = pageEvent(rootUrl, { code: -8 }); + old.onError(staleError); + expect(staleError.preventDefault).toHaveBeenCalled(); + expect(screen.views[1]?.injectJavaScript).not.toHaveBeenCalled(); + expect(screen.report).not.toHaveBeenCalled(); + expect(screen.toast).not.toHaveBeenCalled(); + expect(screen.secureWrite).not.toHaveBeenCalled(); + recovered.onLoadEnd(); + expect(screen.views[1]?.injectJavaScript).toHaveBeenCalledTimes(1); + await screen.send(info); + expect(screen.store.getState().studentId).toBe("typed-account"); + screen.unmount(); + expect(screen.cookies.clearAll).not.toHaveBeenCalled(); + }); + + test("Android root response error prevents the default error view and does not loop recovery", () => { + const screen = mountBindScreen(); + const event = pageEvent(rootUrl, { + description: "net::ERR_HTTP_RESPONSE_CODE_FAILURE", + code: -1, + }); + screen.props.onError(event); + expect(event.preventDefault).toHaveBeenCalled(); + screen.render(); + screen.props.onHttpError(pageEvent(rootUrl, { statusCode: 404 })); + expect(screen.render().key).toBe(1); + expect(screen.router.back).toHaveBeenCalledTimes(1); + expect(screen.toast.mock.calls[0]?.[0].type).toBe("error"); + screen.unmount(); + expect(screen.cookies.clearAll).toHaveBeenCalledTimes(1); + expect(screen.cookies.clearAll).toHaveBeenCalledWith(true); + }); + + test.each([ + `${rootUrl}?ticket=test-ticket`, + `${rootUrl}information-center/center/index.html?appId=2`, + ])("does not recover HTTP 404 for %s", (url) => { + const screen = mountBindScreen(); + screen.props.onHttpError(pageEvent(url, { statusCode: 404 })); + expect(screen.render().key).toBe(0); + expect(screen.router.back).toHaveBeenCalledTimes(1); + screen.unmount(); + expect(screen.cookies.clearAll).toHaveBeenCalledTimes(1); + expect(screen.cookies.clearAll).toHaveBeenCalledWith(true); + }); + + test("diagnostics remain nonterminal and normal user exit clears the session", async () => { + const screen = mountBindScreen(); + await screen.send({ + type: "diagnostic", + endpoint: "studentInfo", + kind: "invalid-response", + reason: "student-response", + attempt: 1, + }); + expect(screen.report).toHaveBeenCalledTimes(1); + expect(screen.toast).not.toHaveBeenCalled(); + expect(screen.router.back).not.toHaveBeenCalled(); + expect(screen.router.replace).not.toHaveBeenCalled(); + screen.unmount(); + expect(screen.cookies.clearAll).toHaveBeenCalledTimes(1); + expect(screen.cookies.clearAll).toHaveBeenCalledWith(true); + }); + + test.each([false, true])( + "unmount ignores callbacks and late save completion (reject=%s)", + async (reject) => { + const write = deferred(); + const screen = mountBindScreen({ writePassword: () => write.promise }); + await screen.send(credentials); + const saving = screen.send(info); + screen.unmount(); + screen.props.onLoadEnd(); + screen.props.onHttpError(pageEvent(rootUrl, { statusCode: 404 })); + await screen.send({ type: "profileUnavailable" }); + await screen.send(info); + if (reject) write.reject(new Error("Save failed after leaving")); + else write.resolve(); + await saving; + expect(screen.secureWrite).toHaveBeenCalledTimes(1); + expect(screen.router.back).not.toHaveBeenCalled(); + expect(screen.router.replace).not.toHaveBeenCalled(); + expect(screen.toast).not.toHaveBeenCalled(); + expect(screen.views[0]?.injectJavaScript).not.toHaveBeenCalled(); + expect(screen.cookies.clearAll).toHaveBeenCalledTimes(1); + }, + ); +}); + +describe("precise recovery URL recognition", () => { + const { isRecoverableTalentRootError } = loadBindDeclarations<{ + isRecoverableTalentRootError: ( + failure: PageEvent["nativeEvent"], + ) => boolean; + }>(["isRecoverableTalentRootError"]); + + test.each([ + { url: rootUrl, statusCode: 404, recover: true }, + { + url: rootUrl, + description: "net::ERR_HTTP_RESPONSE_CODE_FAILURE", + recover: true, + }, + { + url: rootUrl, + statusCode: 500, + description: "net::ERR_HTTP_RESPONSE_CODE_FAILURE", + recover: false, + }, + { url: `${rootUrl}?ticket=test-ticket`, statusCode: 404, recover: false }, + { url: "http://talent.whut.edu.cn/", statusCode: 404, recover: false }, + { + url: "https://talent.whut.edu.cn.evil.example/", + statusCode: 404, + recover: false, + }, + { + url: "https://user:password@talent.whut.edu.cn/", + statusCode: 404, + recover: false, + }, + ])( + "recognizes only the allowed root failure: %j", + ({ recover, ...failure }) => { + expect(isRecoverableTalentRootError(failure)).toBe(recover); + }, + ); +}); diff --git a/scripts/tests/bind-report.test.ts b/scripts/tests/bind-report.test.ts new file mode 100644 index 0000000..2819eb6 --- /dev/null +++ b/scripts/tests/bind-report.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, mock, test } from "bun:test"; + +import { loadBindDeclarations, loadTsDeclarations } from "./bind-test-helpers"; + +type Reporter = (raw: unknown) => void; +type ReportError = (error: unknown, context?: Record) => void; +type SentryEvent = { + exception?: { values?: { type: string; value: string }[] }; +}; + +function reportRuntime({ development = false, telemetry = true } = {}) { + const local = mock( + (_error: unknown, _context?: Record) => {}, + ); + const capture = mock((_error: unknown, _context?: unknown) => {}); + const { isNetworkError } = loadTsDeclarations<{ + isNetworkError: (error: unknown) => boolean; + }>("lib/network-error.ts", ["isNetworkError"]); + const telemetryStore = { getState: () => ({ enabled: telemetry }) }; + const { reportError } = loadTsDeclarations<{ reportError: ReportError }>( + "lib/report.ts", + ["reportError"], + { + logger: { extend: () => ({ error: local }) }, + Sentry: { captureException: capture }, + isNetworkError, + useTelemetryStore: telemetryStore, + __DEV__: development, + }, + ); + const { createBindErrorReporter } = loadBindDeclarations<{ + createBindErrorReporter: (report: ReportError) => Reporter; + }>(["createBindErrorReporter"]); + return { + local, + capture, + isNetworkError, + reportError, + report: createBindErrorReporter(reportError), + createBindErrorReporter, + telemetryStore, + }; +} + +describe("binding diagnostics and report filtering", () => { + test("forwards only whitelisted diagnostic metadata, without response contents or credentials", () => { + const runtime = reportRuntime(); + runtime.report({ + endpoint: "studentInfo", + kind: "invalid-response", + reason: "student-fields", + responseType: "object", + dataType: "object", + expandFieldType: "array", + status: false, + statusCode: 200, + attempt: 8, + code: 50000, + username: "private-username", + password: "private-password", + cookie: "private-cookie", + url: "https://talent.whut.edu.cn/?ticket=private-ticket", + message: "private-response", + data: { name: "private-name" }, + domain: "private-domain", + unknown: "private-extra", + }); + expect(runtime.capture).toHaveBeenCalledTimes(1); + expect(runtime.local.mock.calls[0]?.[1]).toEqual({ + module: "bind", + endpoint: "studentInfo", + kind: "invalid-response", + reason: "student-fields", + responseType: "object", + dataType: "object", + expandFieldType: "array", + status: false, + statusCode: 200, + attempt: 8, + code: 50000, + }); + expect(JSON.stringify(runtime.capture.mock.calls)).not.toContain( + "private-", + ); + expect(runtime.capture.mock.calls[0]?.[1]).toEqual({ + tags: { module: "bind" }, + extra: runtime.local.mock.calls[0]?.[1], + }); + }); + + test("rejects unknown endpoints/kinds and unsafe metadata without consuming valid diagnostics", () => { + const runtime = reportRuntime(); + for (const value of [ + null, + "error", + {}, + { endpoint: "private-user", kind: "http" }, + { endpoint: "studentInfo", kind: "private-kind" }, + ]) { + runtime.report(value); + } + expect(runtime.local).not.toHaveBeenCalled(); + runtime.report({ + endpoint: "studentInfo", + kind: "invalid-response", + reason: "private-response", + responseType: "function", + dataType: "private-data", + expandFieldType: "symbol", + status: "true", + statusCode: "200", + attempt: Number.MAX_SAFE_INTEGER + 1, + code: 1.5, + }); + expect(runtime.local.mock.calls[0]?.[1]).toEqual({ + module: "bind", + endpoint: "studentInfo", + kind: "invalid-response", + }); + }); + + test("deduplicates each endpoint while giving network failures a separate key", () => { + const runtime = reportRuntime(); + runtime.report({ endpoint: "studentInfo", kind: "network" }); + runtime.report({ endpoint: "studentInfo", kind: "network" }); + runtime.report({ + endpoint: "studentInfo", + kind: "invalid-response", + attempt: 8, + }); + runtime.report({ endpoint: "studentInfo", kind: "http", statusCode: 503 }); + runtime.report({ endpoint: "loginUser", kind: "http", statusCode: 500 }); + expect(runtime.local).toHaveBeenCalledTimes(3); + expect(runtime.capture).toHaveBeenCalledTimes(2); + expect(runtime.local.mock.calls[0]?.[0]).toMatchObject({ + code: "NETWORK_FAILURE", + }); + expect(runtime.local.mock.calls[1]?.[1]).toMatchObject({ + endpoint: "studentInfo", + kind: "invalid-response", + attempt: 8, + }); + const nextScreen = runtime.createBindErrorReporter(runtime.reportError); + nextScreen({ endpoint: "studentInfo", kind: "invalid-response" }); + expect(runtime.capture).toHaveBeenCalledTimes(3); + }); + + test.each([ + { development: true, telemetry: true }, + { development: false, telemetry: false }, + ])( + "keeps local diagnostics but respects telemetry configuration: %j", + (options) => { + const runtime = reportRuntime(options); + runtime.report({ endpoint: "studentInfo", kind: "invalid-response" }); + expect(runtime.local).toHaveBeenCalledTimes(1); + expect(runtime.capture).not.toHaveBeenCalled(); + }, + ); + + test.each([ + { code: -2, domain: undefined, sentry: false }, + { code: -8, domain: undefined, sentry: false }, + { code: -11, domain: undefined, sentry: true }, + { code: -1009, domain: "NSURLErrorDomain", sentry: false }, + { code: -1200, domain: "NSURLErrorDomain", sentry: true }, + ])( + "classifies WebView load errors through the real network filter: %j", + ({ code, domain, sentry }) => { + const runtime = reportRuntime(); + runtime.report({ endpoint: "talentPage", kind: "load", code, domain }); + expect(runtime.local.mock.calls[0]?.[0]).toMatchObject({ + name: "WebViewLoadError", + code, + }); + if (domain) + expect(runtime.local.mock.calls[0]?.[0]).toHaveProperty( + "domain", + domain, + ); + expect(runtime.capture).toHaveBeenCalledTimes(sentry ? 1 : 0); + }, + ); + + test("HTTP failures stay reportable and unknown native domains are not forwarded", () => { + const runtime = reportRuntime(); + runtime.report({ + endpoint: "talentPage", + kind: "load", + code: -1200, + domain: "untrusted-domain", + }); + runtime.report({ endpoint: "studentInfo", kind: "http", statusCode: 503 }); + expect(runtime.capture).toHaveBeenCalledTimes(2); + expect(runtime.local.mock.calls[0]?.[0]).not.toHaveProperty("domain"); + expect(runtime.local.mock.calls[0]?.[1]).not.toHaveProperty("domain"); + }); + + test("root Sentry beforeSend keeps telemetry and network filtering as a second boundary", () => { + const runtime = reportRuntime(); + let telemetryEnabled = true; + let beforeSend!: ( + event: SentryEvent, + hint: { originalException?: unknown }, + ) => SentryEvent | null; + loadTsDeclarations("app/_layout.tsx", [], { + __DEV__: false, + SENTRY_DSN: "test-dsn", + Updates: { channel: "test" }, + t: (key: string) => key, + isNetworkError: runtime.isNetworkError, + useTelemetryStore: { getState: () => ({ enabled: telemetryEnabled }) }, + Sentry: { + init: (options: { beforeSend: typeof beforeSend }) => { + beforeSend = options.beforeSend; + }, + feedbackIntegration: () => ({}), + wrap: (value: unknown) => value, + }, + Observe: { configure: () => {} }, + ObserveRoot: { wrap: (value: unknown) => value }, + }); + const diagnostic: SentryEvent = { + exception: { + values: [ + { type: "Error", value: "Bind studentInfo: invalid-response" }, + ], + }, + }; + expect(beforeSend(diagnostic, {})).toBe(diagnostic); + expect( + beforeSend(diagnostic, { + originalException: { code: "NETWORK_FAILURE" }, + }), + ).toBeNull(); + expect( + beforeSend( + { + exception: { + values: [{ type: "TypeError", value: "Network request failed" }], + }, + }, + {}, + ), + ).toBeNull(); + telemetryEnabled = false; + expect(beforeSend(diagnostic, {})).toBeNull(); + }); +}); diff --git a/scripts/tests/bind-script.test.ts b/scripts/tests/bind-script.test.ts new file mode 100644 index 0000000..cc4966a --- /dev/null +++ b/scripts/tests/bind-script.test.ts @@ -0,0 +1,531 @@ +import { describe, expect, test } from "bun:test"; +import { createContext, runInContext } from "node:vm"; + +import { loadBindDeclarations } from "./bind-test-helpers"; + +const { INJECTED_JS } = loadBindDeclarations<{ INJECTED_JS: string }>([ + "INJECTED_JS", +]); + +type Endpoint = "loginUser" | "studentInfo" | "colleges" | "education"; +type Message = { type: string; [key: string]: unknown }; +interface Reply { + body?: unknown; + status?: number; + network?: boolean; + invalidJson?: boolean; +} +type ReplyPlan = Reply | ((attempt: number) => Reply | Promise); + +const identity = { + code: 0, + data: { sn: "TEST-STUDENT", name: "Test Student" }, +}; +const fullProfile = { + code: 0, + status: true, + data: { + expandField: { + 基本信息: [{ fieldCode: "card_no", fieldValue: "TEST-CARD" }], + 学籍信息: [ + { fieldCode: "education_code", fieldValue: "TEST-EDU" }, + { fieldCode: "college_code", fieldValue: "TEST-COLLEGE" }, + ], + }, + }, +}; +const timeoutProfile = { + code: 50000, + status: false, + data: null, + message: "登录超时", +}; +const emptyInfo = { + type: "info", + name: "Test Student", + studentId: "TEST-STUDENT", + cardId: "", + college: "", + eduLevel: "", +}; +const fullInfo = { + ...emptyInfo, + cardId: "TEST-CARD", + college: "Test College", + eduLevel: "Test Education", +}; + +function endpointFor(url: string): Endpoint { + if (url.includes("getLoginUserNew")) return "loginUser"; + if (url.includes("findStudentInfoWechat")) return "studentInfo"; + if (url.includes("getColleges")) return "colleges"; + if (url.includes("getDictionByName")) return "education"; + throw new Error(`Unexpected test request: ${url}`); +} + +function createPage( + plans: Partial> = {}, + url = "https://talent.whut.edu.cn/information-center/center/index.html?appId=2", +) { + const defaults: Record = { + loginUser: { body: identity }, + studentInfo: { body: fullProfile }, + colleges: { + body: { code: 0, data: [{ sn: "TEST-COLLEGE", name: "Test College" }] }, + }, + education: { + body: { code: 0, data: [{ code: "TEST-EDU", name: "Test Education" }] }, + }, + }; + const messages: Message[] = []; + const requests: { endpoint: Endpoint; url: string; options: unknown }[] = []; + const timers: { callback: () => void; delay: number }[] = []; + const redirects: string[] = []; + const pageWindow = { + ReactNativeWebView: { + postMessage: (value: string) => messages.push(JSON.parse(value)), + }, + }; + const location = Object.assign(new URL(url), { + replace: (value: string) => redirects.push(value), + }); + const context = createContext({ + window: pageWindow, + location, + document: {}, + setTimeout: (callback: () => void, delay: number) => { + timers.push({ callback, delay }); + return timers.length; + }, + fetch: async (requestUrl: string, options: unknown) => { + const endpoint = endpointFor(requestUrl); + requests.push({ endpoint, url: requestUrl, options }); + const attempt = requests.filter( + (item) => item.endpoint === endpoint, + ).length; + const plan = plans[endpoint] ?? defaults[endpoint]; + const reply = await (typeof plan === "function" ? plan(attempt) : plan); + if (reply.network) throw new Error("Synthetic network failure"); + const status = reply.status ?? 200; + return { + ok: status >= 200 && status < 300, + status, + json: async () => { + if (reply.invalidJson) + throw new SyntaxError("Synthetic invalid JSON"); + return reply.body; + }, + }; + }, + }); + const inject = () => runInContext(INJECTED_JS, context); + // A real event-loop turn drains the complete fetch/Promise.all/finally chain. + // Fake retry timers are advanced only after that chain has settled. + const settle = () => new Promise((resolve) => setImmediate(resolve)); + const takeTimer = () => { + const timer = timers.shift(); + if (!timer) throw new Error("Expected a pending retry timer"); + expect(timer.delay).toBe(1000); + return timer.callback; + }; + const advance = async () => { + takeTimer()(); + await settle(); + }; + const finish = async () => { + await settle(); + for (let turn = 0; timers.length; turn++) { + if (turn >= 10) throw new Error("Binding scheduled unbounded retries"); + await advance(); + } + }; + const terminals = () => + messages.filter((message) => + ["info", "profileUnavailable", "bindingFailed"].includes(message.type), + ); + const diagnostics = () => + messages.filter((message) => message.type === "diagnostic"); + const count = (endpoint: Endpoint) => + requests.filter((request) => request.endpoint === endpoint).length; + inject(); + return { + messages, + requests, + timers, + redirects, + inject, + settle, + takeTimer, + advance, + finish, + terminals, + diagnostics, + count, + }; +} + +function expectDiagnostic( + page: ReturnType, + endpoint: Endpoint, + kind: string, + reason?: string, +) { + expect(page.diagnostics()).toContainEqual( + expect.objectContaining({ endpoint, kind, ...(reason ? { reason } : {}) }), + ); +} + +describe("production binding injection: successful and optional profiles", () => { + test("keeps complete profile fields and expands the real template correctly", async () => { + const page = createPage(); + await page.finish(); + expect(page.terminals()).toEqual([fullInfo]); + expect(page.diagnostics()).toEqual([]); + expect(page.count("loginUser")).toBe(1); + expect(page.requests.find((r) => r.endpoint === "education")?.url).toBe( + `/information-center/xd/commons/getDictionByName/${encodeURIComponent("学生类型")}`, + ); + for (const request of page.requests) { + expect(request.options).toEqual({ + credentials: "include", + headers: { accept: "application/json" }, + }); + } + }); + + test.each([ + ["null", { expandField: null }], + ["missing", {}], + ["empty object", { expandField: {} }], + ])( + "successful %s expansion finishes on the first attempt", + async (_, data) => { + const page = createPage({ + studentInfo: { body: { code: 0, status: true, data } }, + }); + await page.finish(); + expect(page.terminals()).toEqual([emptyInfo]); + expect(page.count("loginUser")).toBe(1); + expect(page.diagnostics()).toEqual([]); + }, + ); + + test.each([2, 8])( + "keeps recovered data on attempt %i", + async (recoveryAttempt) => { + const page = createPage({ + studentInfo: (attempt) => + attempt < recoveryAttempt ? { status: 503 } : { body: fullProfile }, + }); + await page.finish(); + expect(page.terminals()).toEqual([fullInfo]); + expect(page.count("loginUser")).toBe(recoveryAttempt); + expectDiagnostic(page, "studentInfo", "http"); + }, + ); + + test.each([ + ["network", { network: true }], + ["http", { status: 503 }], + ["invalid-json", { invalidJson: true }], + ["invalid-response", { body: { code: 0, data: "bad dictionary" } }], + ] satisfies [string, Reply][])( + "optional dictionary %s errors preserve binding", + async (kind, reply) => { + const page = createPage({ colleges: reply, education: reply }); + await page.finish(); + expect(page.terminals()).toEqual([{ ...emptyInfo, cardId: "TEST-CARD" }]); + expect(page.count("loginUser")).toBe(1); + expectDiagnostic(page, "colleges", kind); + expectDiagnostic(page, "education", kind); + }, + ); +}); + +describe("production binding injection: student profile fallback", () => { + test.each([ + ["network", { network: true }], + ["http", { status: 503 }], + ["invalid-json", { invalidJson: true }], + ] satisfies [string, Reply][])( + "persistent student %s errors retain current identity", + async (kind, reply) => { + const page = createPage({ studentInfo: reply }); + await page.finish(); + expect(page.terminals()).toEqual([emptyInfo]); + expect(page.count("loginUser")).toBe(8); + expect(page.count("studentInfo")).toBe(8); + expectDiagnostic(page, "studentInfo", kind); + expect( + page.diagnostics().some((m) => m.reason === "student-response"), + ).toBe(false); + if (kind === "http") { + expect(page.diagnostics()[0].statusCode).toBe(503); + } + }, + ); + + test("persistent session timeout is diagnosed only at the final fallback", async () => { + const page = createPage({ studentInfo: { body: timeoutProfile } }); + await page.settle(); + for (let attempt = 1; attempt < 8; attempt++) { + expect(page.terminals()).toEqual([]); + expect(page.diagnostics()).toEqual([]); + await page.advance(); + } + expect(page.terminals()).toEqual([emptyInfo]); + expect(page.diagnostics()).toEqual([ + expect.objectContaining({ reason: "session-timeout", attempt: 8 }), + ]); + expect(page.messages.at(-1)).toEqual(emptyInfo); + expect(page.timers).toHaveLength(0); + }); + + test("temporary session timeout recovers without a timeout diagnostic", async () => { + const page = createPage({ + studentInfo: (attempt) => ({ + body: attempt < 3 ? timeoutProfile : fullProfile, + }), + }); + await page.finish(); + expect(page.terminals()).toEqual([fullInfo]); + expect(page.count("studentInfo")).toBe(3); + expect(page.diagnostics()).toEqual([]); + }); + + test.each([ + ["missing status", { code: 0, data: {} }], + [ + "false status with empty expansion", + { code: 0, status: false, data: { expandField: null } }, + ], + [ + "nonzero code with empty expansion", + { code: 123, status: true, data: { expandField: null } }, + ], + ["missing code", { status: true, data: {} }], + ["null data", { code: 0, status: true, data: null }], + ["array data", { code: 0, status: true, data: [] }], + ["null response", null], + ])( + "invalid student response (%s) retries before using empty fields", + async (_, body) => { + const page = createPage({ studentInfo: { body } }); + await page.finish(); + expect(page.count("studentInfo")).toBe(8); + expect(page.terminals()).toEqual([emptyInfo]); + expectDiagnostic( + page, + "studentInfo", + "invalid-response", + "student-response", + ); + }, + ); + + test.each([["invalid"], [[]], [42], [false]])( + "rejects invalid expansion %j before falling back", + async (expandField) => { + const page = createPage({ + studentInfo: { body: { code: 0, status: true, data: { expandField } } }, + }); + await page.finish(); + expect(page.count("studentInfo")).toBe(8); + expect(page.terminals()).toEqual([emptyInfo]); + expectDiagnostic(page, "studentInfo", "invalid-response", "expand-field"); + }, + ); + + test.each([ + { 基本信息: "invalid" }, + { 学籍信息: {} }, + { 基本信息: [null] }, + { 学籍信息: ["invalid"] }, + ])("rejects malformed field collections %j", async (expandField) => { + const page = createPage({ + studentInfo: { body: { code: 0, status: true, data: { expandField } } }, + }); + await page.finish(); + expect(page.count("studentInfo")).toBe(8); + expect(page.terminals()).toEqual([emptyInfo]); + expectDiagnostic(page, "studentInfo", "invalid-response", "student-fields"); + }); + + test("a parsing exception discards previously assigned fields", async () => { + // This is a possible JSON value; String(value) throws after cardId was set. + const expandField = { + 基本信息: fullProfile.data.expandField.基本信息, + 学籍信息: [ + { + fieldCode: "education_code", + fieldValue: { toString: null, valueOf: null }, + }, + ], + }; + const page = createPage({ + studentInfo: { body: { code: 0, status: true, data: { expandField } } }, + }); + await page.finish(); + expect(page.terminals()).toEqual([emptyInfo]); + expect(page.count("studentInfo")).toBe(8); + expectDiagnostic( + page, + "studentInfo", + "invalid-response", + "student-parsing", + ); + }); + + test("fallback uses the identity returned in the final attempt", async () => { + const page = createPage({ + loginUser: (attempt) => ({ + body: { + code: 0, + data: { sn: `TEST-${attempt}`, name: `Student ${attempt}` }, + }, + }), + studentInfo: { network: true }, + }); + await page.finish(); + expect(page.terminals()).toEqual([ + { ...emptyInfo, studentId: "TEST-8", name: "Student 8" }, + ]); + }); +}); + +describe("production binding injection: identity failure and explicit unavailable", () => { + test.each([ + ["network", { network: true }], + ["http", { status: 503 }], + ["invalid-json", { invalidJson: true }], + ["invalid-response", { body: { code: 0, data: {} } }], + ["invalid-response", { body: { code: 123, data: identity.data } }], + ["invalid-response", { body: { code: 0, data: null } }], + ] satisfies [string, Reply][])( + "persistent identity %s failures allow setup without info", + async (kind, reply) => { + const page = createPage({ loginUser: reply }); + await page.finish(); + expect(page.terminals()).toEqual([ + { type: "profileUnavailable", identityUnavailable: true }, + ]); + expect(page.count("loginUser")).toBe(8); + expect(page.count("studentInfo")).toBe(0); + expectDiagnostic(page, "loginUser", kind); + }, + ); + + test("an earlier valid identity cannot authorize fallback after final identity failure", async () => { + const page = createPage({ + loginUser: (attempt) => + attempt < 8 ? { body: identity } : { network: true }, + studentInfo: { network: true }, + }); + await page.finish(); + expect(page.terminals()).toEqual([ + { type: "profileUnavailable", identityUnavailable: true }, + ]); + expect(page.count("loginUser")).toBe(8); + expect(page.count("studentInfo")).toBe(7); + expectDiagnostic(page, "loginUser", "network"); + }); + + test.each(["loginUser", "studentInfo"] as const)( + "%s code 50005 retains its immediate setup path", + async (endpoint) => { + const page = createPage({ [endpoint]: { body: { code: 50005 } } }); + await page.finish(); + expect(page.terminals()).toEqual([{ type: "profileUnavailable" }]); + expect(page.count("loginUser")).toBe(1); + expect(page.diagnostics()).toEqual([]); + }, + ); +}); + +describe("production binding injection: lifecycle guards", () => { + test("repeated injection cannot duplicate requests or completion", async () => { + const page = createPage(); + for (let index = 0; index < 5; index++) page.inject(); + await page.finish(); + page.inject(); + await page.finish(); + expect(page.terminals()).toEqual([fullInfo]); + for (const endpoint of [ + "loginUser", + "studentInfo", + "colleges", + "education", + ] as const) { + expect(page.count(endpoint)).toBe(1); + } + }); + + test("a pending student request keeps the full attempt locked and late retries are inert", async () => { + let releaseStudent!: (reply: Reply) => void; + const studentReply = new Promise((resolve) => { + releaseStudent = resolve; + }); + const page = createPage({ + studentInfo: (attempt) => + attempt === 1 ? { status: 503 } : studentReply, + }); + await page.settle(); + const retry = page.takeTimer(); + retry(); + await page.settle(); + expect(page.count("studentInfo")).toBe(2); + retry(); + retry(); + page.inject(); + await page.settle(); + expect(page.count("loginUser")).toBe(2); + expect(page.terminals()).toEqual([]); + releaseStudent({ body: fullProfile }); + await page.finish(); + expect(page.terminals()).toEqual([fullInfo]); + retry(); + page.inject(); + await page.finish(); + expect(page.count("loginUser")).toBe(2); + expect(page.terminals()).toEqual([fullInfo]); + expect(page.timers).toHaveLength(0); + }); + + test.each(["loginUser", "studentInfo"] as const)( + "%s retry exhaustion produces one terminal and no further requests", + async (endpoint) => { + const page = createPage({ [endpoint]: { network: true } }); + await page.settle(); + const lateRetry = page.takeTimer(); + lateRetry(); + await page.finish(); + expect(page.terminals()).toHaveLength(1); + expect(page.count("loginUser")).toBe(8); + lateRetry(); + page.inject(); + await page.finish(); + expect(page.terminals()).toHaveLength(1); + expect(page.count("loginUser")).toBe(8); + expect(page.timers).toHaveLength(0); + }, + ); + + test("the talent root redirects through the interpolated application path only once", async () => { + const page = createPage({}, "https://talent.whut.edu.cn/"); + page.inject(); + await page.finish(); + expect(page.redirects).toEqual([ + "/information-center/center/index.html?appId=2", + ]); + expect(page.requests).toEqual([]); + expect(page.messages).toEqual([]); + }); + + test("unrelated origins do not run the binding script", async () => { + const page = createPage({}, "https://unrelated.example/"); + await page.finish(); + expect(page.requests).toEqual([]); + expect(page.messages).toEqual([]); + expect(page.timers).toEqual([]); + }); +}); diff --git a/scripts/tests/bind-test-helpers.ts b/scripts/tests/bind-test-helpers.ts new file mode 100644 index 0000000..78a6f93 --- /dev/null +++ b/scripts/tests/bind-test-helpers.ts @@ -0,0 +1,51 @@ +import { readFileSync } from "node:fs"; +import { runInNewContext } from "node:vm"; +import ts from "typescript"; + +/** Evaluate production declarations without importing the native app runtime. */ +export function loadTsDeclarations( + relativePath: string, + names: string[], + globals: Record = {}, +): T { + if (names.some((name) => !/^[A-Za-z_$][\w$]*$/.test(name))) { + throw new Error("Expected declaration names, not expressions"); + } + const file = new URL(`../../${relativePath}`, import.meta.url); + const source = ts.createSourceFile( + file.pathname, + readFileSync(file, "utf8"), + ts.ScriptTarget.Latest, + true, + relativePath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); + const declarations = ts.factory.updateSourceFile( + source, + source.statements.filter((statement) => !ts.isImportDeclaration(statement)), + ); + // Transpile the complete AST, including template substitutions and escapes. + // Taking raw template text would execute different JavaScript in the WebView. + const compiled = ts.transpileModule( + ts.createPrinter().printFile(declarations), + { + fileName: file.pathname, + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.CommonJS, + jsx: ts.JsxEmit.React, + }, + }, + ).outputText; + return runInNewContext( + `${compiled}\n({${names.map((name) => `${name}: typeof ${name} !== 'undefined' ? ${name} : exports.${name}`).join(",")}});`, + { exports: {}, URL, encodeURIComponent, ...globals }, + { filename: file.pathname }, + ) as T; +} + +export function loadBindDeclarations( + names: string[], + globals: Record = {}, +): T { + return loadTsDeclarations("app/browser/bind.tsx", names, globals); +} From 9a6002b04e07980871d85a0daf078d744d95ed8c Mon Sep 17 00:00:00 2001 From: zhxycn Date: Fri, 30 Oct 2026 17:23:32 +0800 Subject: [PATCH 2/2] rm --- app/browser/bind.tsx | 31 +- lib/i18n/locales/en.json | 10 +- lib/i18n/locales/zh.json | 10 +- scripts/tests/bind-native.test.ts | 485 -------------------------- scripts/tests/bind-report.test.ts | 249 -------------- scripts/tests/bind-script.test.ts | 531 ----------------------------- scripts/tests/bind-test-helpers.ts | 51 --- 7 files changed, 20 insertions(+), 1347 deletions(-) delete mode 100644 scripts/tests/bind-native.test.ts delete mode 100644 scripts/tests/bind-report.test.ts delete mode 100644 scripts/tests/bind-script.test.ts delete mode 100644 scripts/tests/bind-test-helpers.ts diff --git a/app/browser/bind.tsx b/app/browser/bind.tsx index 4f38cb1..1af2f76 100644 --- a/app/browser/bind.tsx +++ b/app/browser/bind.tsx @@ -295,7 +295,7 @@ const INJECTED_JS = `(function(){ diagnostic('studentInfo','invalid-response',undefined,reason,stu); } if(giveUp){ - // 只使用本轮已验证的身份,解析失败时不保留半份附加资料。 + // 资料异常时只保留已验证的身份 finish('info',{name:name,studentId:sn,cardId:'',college:'',eduLevel:''}); return; } @@ -396,13 +396,13 @@ export default function BindScreen() { }; }, []); - const handleFailure = (message = t("user.bindFailedSub")) => { + const handleFailure = () => { if (flowFinished.current) return; flowFinished.current = true; Toast.show({ type: "error", text1: t("user.bindFailed"), - text2: message, + text2: t("user.bindFailedSub"), position: "bottom", }); if (router.canGoBack()) router.back(); @@ -487,16 +487,8 @@ export default function BindScreen() { useOnboardingStore.getState().reset(); Toast.show({ type: "info", - text1: t( - msg.identityUnavailable === true - ? "user.bindIdentityUnavailable" - : "user.bindProfileUnavailable", - ), - text2: t( - msg.identityUnavailable === true - ? "user.bindIdentityUnavailableSub" - : "user.bindProfileUnavailableSub", - ), + text1: t("user.bindProfileUnavailable"), + text2: t("user.bindProfileUnavailableSub"), position: "bottom", }); router.replace({ @@ -508,10 +500,12 @@ export default function BindScreen() { reportError(new Error("Bind credentials unavailable"), { module: "bind", }); - handleFailure(t("user.bindCredentialsUnavailable")); + handleFailure(); return; } flowFinished.current = true; + // 保存完成前保留会话,避免页面卸载时提前清除 Cookie + preserveSession.current = true; const { username, password } = pendingCredentials.current; try { @@ -527,13 +521,16 @@ export default function BindScreen() { ); } catch (error) { reportError(error, { module: "bind" }); - if (!screenActive.current) return; + if (!screenActive.current) { + CookieManager.clearAll(true).catch(() => {}); + return; + } + preserveSession.current = false; flowFinished.current = false; - handleFailure(t("user.bindSaveFailed")); + handleFailure(); return; } if (!screenActive.current) return; - preserveSession.current = true; Toast.show({ type: "success", diff --git a/lib/i18n/locales/en.json b/lib/i18n/locales/en.json index d725f69..e3232ba 100644 --- a/lib/i18n/locales/en.json +++ b/lib/i18n/locales/en.json @@ -260,13 +260,9 @@ "bindFailed": "Link failed", "bindPasswordError": "Incorrect password", "bindPasswordErrorSub": "Please link your WHUT account again", - "bindFailedSub": "iWUT is temporarily unavailable, please try again later", - "bindIdentityUnavailable": "Account verification incomplete", - "bindIdentityUnavailableSub": "You can continue setup and try linking your account later", - "bindCredentialsUnavailable": "Login credentials are unavailable. Please sign in again to link your account", - "bindSaveFailed": "Unable to save your account. Please try again later", - "bindProfileUnavailable": "Account verified", - "bindProfileUnavailableSub": "Your profile is not available yet, but you can continue and import your timetable", + "bindFailedSub": "Unable to link your account. Please try again later", + "bindProfileUnavailable": "There is an issue with your user information", + "bindProfileUnavailableSub": "You can finish setting up the app and try linking your account again later", "menuTools": "Tools", "menuSettings": "Settings", "menuOther": "Other", diff --git a/lib/i18n/locales/zh.json b/lib/i18n/locales/zh.json index 5c920e9..2b75c6b 100644 --- a/lib/i18n/locales/zh.json +++ b/lib/i18n/locales/zh.json @@ -260,13 +260,9 @@ "bindFailed": "绑定失败", "bindPasswordError": "密码错误", "bindPasswordErrorSub": "请重新绑定智慧理工大账号", - "bindFailedSub": "智慧理工大暂时无法访问,请稍后重试", - "bindIdentityUnavailable": "暂未完成账号验证", - "bindIdentityUnavailableSub": "可先继续设置,稍后再尝试绑定账号", - "bindCredentialsUnavailable": "未获取到登录凭据,请重新登录后绑定", - "bindSaveFailed": "无法保存账号信息,请稍后重试", - "bindProfileUnavailable": "账号验证成功", - "bindProfileUnavailableSub": "暂未获取到个人资料,可先继续导入课表", + "bindFailedSub": "无法绑定账号,请稍后重试", + "bindProfileUnavailable": "用户信息异常", + "bindProfileUnavailableSub": "您可以继续完成 App 设置,稍后再尝试绑定账号", "menuTools": "工具", "menuSettings": "设置", "menuOther": "其他", diff --git a/scripts/tests/bind-native.test.ts b/scripts/tests/bind-native.test.ts deleted file mode 100644 index 54cb11b..0000000 --- a/scripts/tests/bind-native.test.ts +++ /dev/null @@ -1,485 +0,0 @@ -import { describe, expect, mock, test } from "bun:test"; -import { persist } from "zustand/middleware"; -import { createStore, type StoreApi } from "zustand/vanilla"; - -import { loadBindDeclarations, loadTsDeclarations } from "./bind-test-helpers"; - -interface BindState { - isBound: boolean; - studentId: string; - studentName: string; - cardId: string; - college: string; - eduLevel: string; - bind: (...args: string[]) => Promise; - getCredentials: () => Promise<{ username: string; password: string } | null>; -} - -type PageEvent = { - nativeEvent: { - url: string; - statusCode?: number; - code?: number; - description?: string; - domain?: string; - }; - preventDefault: () => void; -}; - -type WebViewProps = { - key: number; - source: { uri: string }; - ref: ( - view: { - injectJavaScript: (script: string) => void; - clearCache: (all: boolean) => void; - } | null, - ) => void; - onLoadEnd: () => void; - onMessage: (event: { nativeEvent: { data: string } }) => Promise; - onError: (event: PageEvent) => void; - onHttpError: (event: PageEvent) => void; -}; - -type ElementNode = { - type: unknown; - props: Record; - children: ElementNode[]; -}; - -function deferred() { - let resolve!: () => void; - let reject!: (error: Error) => void; - const promise = new Promise((done, fail) => { - resolve = done; - reject = fail; - }); - return { promise, resolve, reject }; -} - -function makeStore( - writePassword: ( - key: string, - password: string, - ) => Promise = async () => {}, -) { - const secureWrite = mock(writePassword); - const persistWrite = mock(() => {}); - const cookies = { clearAll: mock(async () => {}) }; - const { useUserBindStore: store } = loadTsDeclarations<{ - useUserBindStore: StoreApi; - }>("store/user-bind.ts", ["useUserBindStore"], { - create: () => createStore, - persist, - zustandStorage: { - getItem: () => null, - setItem: persistWrite, - removeItem: () => {}, - }, - SecureStore: { - setItemAsync: secureWrite, - getItemAsync: async () => "test-password", - deleteItemAsync: async () => {}, - }, - CookieManager: cookies, - }); - return { store, secureWrite, persistWrite, cookies }; -} - -/** Render the real screen with isolated hook/UI adapters; no app modules are mocked globally. */ -function mountBindScreen( - options: { - writePassword?: (key: string, password: string) => Promise; - canGoBack?: boolean; - } = {}, -) { - const user = makeStore(options.writePassword); - const hooks: unknown[] = []; - let hook = 0; - const cleanups: (() => void)[] = []; - const toast = mock((_value: Record) => {}); - const report = mock( - (_error: unknown, _context?: Record) => {}, - ); - const reset = mock(() => {}); - const router = { - canGoBack: () => options.canGoBack !== false, - back: mock(() => {}), - replace: mock((_route: unknown) => {}), - }; - const views: { - injectJavaScript: ReturnType void>>; - clearCache: ReturnType void>>; - }[] = []; - const { BindScreen } = loadBindDeclarations<{ - BindScreen: () => ElementNode; - }>(["BindScreen"], { - React: { - createElement: ( - type: unknown, - props: Record | null, - ...children: ElementNode[] - ) => ({ type, props: props ?? {}, children }), - }, - useRef: (initial: T) => { - const index = hook++; - hooks[index] ??= { current: initial }; - return hooks[index]; - }, - useState: (initial: T) => { - const index = hook++; - hooks[index] ??= { value: initial }; - const slot = hooks[index] as { value: T }; - return [ - slot.value, - (value: T) => { - slot.value = value; - }, - ]; - }, - useCallback: (callback: T) => { - hook++; - return callback; - }, - useEffect: (effect: () => void | (() => void)) => { - const index = hook++; - if (!hooks[index]) { - hooks[index] = true; - const cleanup = effect(); - if (cleanup) cleanups.push(cleanup); - } - }, - useMarkRouteInteractive: () => {}, - useT: () => (key: string) => key, - useUserBindStore: user.store, - useOnboardingStore: { getState: () => ({ reset }) }, - CookieManager: user.cookies, - logger: { extend: () => ({ info: () => {} }) }, - reportError: report, - router, - Toast: { show: toast }, - StackScreenContent: "container", - View: "view", - Stack: { Screen: "screen" }, - WebView: "webview", - IS_DEV: false, - }); - const findWebView = (node: ElementNode): WebViewProps | undefined => { - if (node.type === "webview") return node.props as unknown as WebViewProps; - return node.children.map(findWebView).find(Boolean); - }; - let props: WebViewProps | undefined; - const render = () => { - hook = 0; - const next = findWebView(BindScreen()); - if (!next) throw new Error("BindScreen did not render its WebView"); - if (!props || next.key !== props.key) { - props?.ref(null); - const view = { - injectJavaScript: mock((_script: string) => {}), - clearCache: mock((_all: boolean) => {}), - }; - views.push(view); - next.ref(view); - } - props = next; - return next; - }; - render(); - return { - ...user, - toast, - report, - reset, - router, - views, - render, - get props() { - return props!; - }, - send: (message: Record, target = props!) => - target.onMessage({ nativeEvent: { data: JSON.stringify(message) } }), - unmount: () => { - props?.ref(null); - cleanups.forEach((cleanup) => cleanup()); - }, - }; -} - -const credentials = { - type: "credentials", - username: "typed-account", - password: "test-password", -}; -const info = { - type: "info", - studentId: "server-student-id", - name: "Test user", - cardId: "", - college: "", - eduLevel: "", -}; -const rootUrl = "https://talent.whut.edu.cn/"; -function pageEvent( - url: string, - extra: Partial = {}, -): PageEvent { - return { nativeEvent: { url, ...extra }, preventDefault: mock(() => {}) }; -} - -describe("binding native screen", () => { - test.each([false, true])( - "profileUnavailable preserves session without binding (identityUnavailable=%s)", - async (identityUnavailable) => { - const screen = mountBindScreen(); - await screen.send(credentials); - await screen.send({ type: "profileUnavailable", identityUnavailable }); - await screen.send(info); - await screen.send({ type: "bindingFailed" }); - expect(screen.secureWrite).not.toHaveBeenCalled(); - expect(screen.store.getState().isBound).toBe(false); - expect(screen.reset).toHaveBeenCalledTimes(1); - expect(screen.router.replace).toHaveBeenCalledTimes(1); - expect(screen.router.replace).toHaveBeenCalledWith({ - pathname: "/onboarding", - params: { step: "setup" }, - }); - expect(screen.router.back).not.toHaveBeenCalled(); - expect(screen.toast).toHaveBeenCalledTimes(1); - expect(screen.toast.mock.calls[0]?.[0].text1).toBe( - identityUnavailable - ? "user.bindIdentityUnavailable" - : "user.bindProfileUnavailable", - ); - screen.unmount(); - expect(screen.cookies.clearAll).not.toHaveBeenCalled(); - }, - ); - - test("binding waits for SecureStore, preserves identity mapping, and ignores duplicate terminal messages", async () => { - const write = deferred(); - const screen = mountBindScreen({ writePassword: () => write.promise }); - await screen.send(credentials); - const completion = screen.send(info); - expect(screen.secureWrite).toHaveBeenCalledTimes(1); - expect(screen.secureWrite).toHaveBeenCalledWith( - "zhlgd_password", - "test-password", - ); - expect(screen.store.getState().isBound).toBe(false); - expect(screen.persistWrite).not.toHaveBeenCalled(); - expect(screen.router.back).not.toHaveBeenCalled(); - await screen.send(info); - await screen.send({ type: "profileUnavailable" }); - await screen.send({ type: "bindingFailed" }); - write.resolve(); - await completion; - expect(screen.secureWrite).toHaveBeenCalledTimes(1); - expect(screen.store.getState()).toMatchObject({ - isBound: true, - studentId: "typed-account", - studentName: "Test user", - cardId: "", - college: "", - eduLevel: "", - }); - expect(screen.router.back).toHaveBeenCalledTimes(1); - expect(screen.toast.mock.calls[0]?.[0].type).toBe("success"); - expect(JSON.stringify(screen.persistWrite.mock.calls)).not.toContain( - "test-password", - ); - screen.unmount(); - expect(screen.cookies.clearAll).not.toHaveBeenCalled(); - }); - - test("missing captured credentials ends with feedback rather than leaving info pending", async () => { - const screen = mountBindScreen(); - await screen.send(info); - expect(screen.secureWrite).not.toHaveBeenCalled(); - expect(screen.store.getState().isBound).toBe(false); - expect(screen.toast.mock.calls[0]?.[0]).toMatchObject({ - type: "error", - text2: "user.bindCredentialsUnavailable", - }); - expect(screen.router.back).toHaveBeenCalledTimes(1); - await screen.send(credentials); - await screen.send(info); - expect(screen.secureWrite).not.toHaveBeenCalled(); - screen.unmount(); - expect(screen.cookies.clearAll).toHaveBeenCalledTimes(1); - expect(screen.cookies.clearAll).toHaveBeenCalledWith(true); - }); - - test("SecureStore rejection reports and exits without persisting or preserving the session", async () => { - const failure = new Error("SecureStore unavailable"); - const screen = mountBindScreen({ - writePassword: async () => { - throw failure; - }, - }); - await screen.send(credentials); - await screen.send(info); - expect(screen.store.getState().isBound).toBe(false); - expect(screen.persistWrite).not.toHaveBeenCalled(); - expect(screen.report).toHaveBeenCalledWith( - failure, - expect.objectContaining({ module: "bind" }), - ); - expect(screen.toast.mock.calls[0]?.[0]).toMatchObject({ - type: "error", - text2: "user.bindSaveFailed", - }); - expect(screen.router.back).toHaveBeenCalledTimes(1); - await screen.send(info); - expect(screen.secureWrite).toHaveBeenCalledTimes(1); - screen.unmount(); - expect(screen.cookies.clearAll).toHaveBeenCalledTimes(1); - expect(screen.cookies.clearAll).toHaveBeenCalledWith(true); - }); - - test("bare-root 404 recovers once, preserves credentials, and rejects old WebView callbacks", async () => { - const screen = mountBindScreen(); - const old = screen.props; - await screen.send(credentials); - old.onHttpError(pageEvent(rootUrl, { statusCode: 404 })); - const recovered = screen.render(); - expect(recovered.key).toBe(1); - expect(recovered.source.uri).toBe( - `${rootUrl}information-center/center/index.html?appId=2`, - ); - expect(screen.cookies.clearAll).not.toHaveBeenCalled(); - expect(screen.views[0]?.clearCache).toHaveBeenCalledTimes(1); - expect(screen.views[0]?.clearCache).toHaveBeenCalledWith(true); - old.onLoadEnd(); - await screen.send({ ...credentials, username: "stale-account" }, old); - await screen.send(info, old); - await screen.send({ type: "profileUnavailable" }, old); - old.onHttpError(pageEvent(rootUrl, { statusCode: 500 })); - const staleError = pageEvent(rootUrl, { code: -8 }); - old.onError(staleError); - expect(staleError.preventDefault).toHaveBeenCalled(); - expect(screen.views[1]?.injectJavaScript).not.toHaveBeenCalled(); - expect(screen.report).not.toHaveBeenCalled(); - expect(screen.toast).not.toHaveBeenCalled(); - expect(screen.secureWrite).not.toHaveBeenCalled(); - recovered.onLoadEnd(); - expect(screen.views[1]?.injectJavaScript).toHaveBeenCalledTimes(1); - await screen.send(info); - expect(screen.store.getState().studentId).toBe("typed-account"); - screen.unmount(); - expect(screen.cookies.clearAll).not.toHaveBeenCalled(); - }); - - test("Android root response error prevents the default error view and does not loop recovery", () => { - const screen = mountBindScreen(); - const event = pageEvent(rootUrl, { - description: "net::ERR_HTTP_RESPONSE_CODE_FAILURE", - code: -1, - }); - screen.props.onError(event); - expect(event.preventDefault).toHaveBeenCalled(); - screen.render(); - screen.props.onHttpError(pageEvent(rootUrl, { statusCode: 404 })); - expect(screen.render().key).toBe(1); - expect(screen.router.back).toHaveBeenCalledTimes(1); - expect(screen.toast.mock.calls[0]?.[0].type).toBe("error"); - screen.unmount(); - expect(screen.cookies.clearAll).toHaveBeenCalledTimes(1); - expect(screen.cookies.clearAll).toHaveBeenCalledWith(true); - }); - - test.each([ - `${rootUrl}?ticket=test-ticket`, - `${rootUrl}information-center/center/index.html?appId=2`, - ])("does not recover HTTP 404 for %s", (url) => { - const screen = mountBindScreen(); - screen.props.onHttpError(pageEvent(url, { statusCode: 404 })); - expect(screen.render().key).toBe(0); - expect(screen.router.back).toHaveBeenCalledTimes(1); - screen.unmount(); - expect(screen.cookies.clearAll).toHaveBeenCalledTimes(1); - expect(screen.cookies.clearAll).toHaveBeenCalledWith(true); - }); - - test("diagnostics remain nonterminal and normal user exit clears the session", async () => { - const screen = mountBindScreen(); - await screen.send({ - type: "diagnostic", - endpoint: "studentInfo", - kind: "invalid-response", - reason: "student-response", - attempt: 1, - }); - expect(screen.report).toHaveBeenCalledTimes(1); - expect(screen.toast).not.toHaveBeenCalled(); - expect(screen.router.back).not.toHaveBeenCalled(); - expect(screen.router.replace).not.toHaveBeenCalled(); - screen.unmount(); - expect(screen.cookies.clearAll).toHaveBeenCalledTimes(1); - expect(screen.cookies.clearAll).toHaveBeenCalledWith(true); - }); - - test.each([false, true])( - "unmount ignores callbacks and late save completion (reject=%s)", - async (reject) => { - const write = deferred(); - const screen = mountBindScreen({ writePassword: () => write.promise }); - await screen.send(credentials); - const saving = screen.send(info); - screen.unmount(); - screen.props.onLoadEnd(); - screen.props.onHttpError(pageEvent(rootUrl, { statusCode: 404 })); - await screen.send({ type: "profileUnavailable" }); - await screen.send(info); - if (reject) write.reject(new Error("Save failed after leaving")); - else write.resolve(); - await saving; - expect(screen.secureWrite).toHaveBeenCalledTimes(1); - expect(screen.router.back).not.toHaveBeenCalled(); - expect(screen.router.replace).not.toHaveBeenCalled(); - expect(screen.toast).not.toHaveBeenCalled(); - expect(screen.views[0]?.injectJavaScript).not.toHaveBeenCalled(); - expect(screen.cookies.clearAll).toHaveBeenCalledTimes(1); - }, - ); -}); - -describe("precise recovery URL recognition", () => { - const { isRecoverableTalentRootError } = loadBindDeclarations<{ - isRecoverableTalentRootError: ( - failure: PageEvent["nativeEvent"], - ) => boolean; - }>(["isRecoverableTalentRootError"]); - - test.each([ - { url: rootUrl, statusCode: 404, recover: true }, - { - url: rootUrl, - description: "net::ERR_HTTP_RESPONSE_CODE_FAILURE", - recover: true, - }, - { - url: rootUrl, - statusCode: 500, - description: "net::ERR_HTTP_RESPONSE_CODE_FAILURE", - recover: false, - }, - { url: `${rootUrl}?ticket=test-ticket`, statusCode: 404, recover: false }, - { url: "http://talent.whut.edu.cn/", statusCode: 404, recover: false }, - { - url: "https://talent.whut.edu.cn.evil.example/", - statusCode: 404, - recover: false, - }, - { - url: "https://user:password@talent.whut.edu.cn/", - statusCode: 404, - recover: false, - }, - ])( - "recognizes only the allowed root failure: %j", - ({ recover, ...failure }) => { - expect(isRecoverableTalentRootError(failure)).toBe(recover); - }, - ); -}); diff --git a/scripts/tests/bind-report.test.ts b/scripts/tests/bind-report.test.ts deleted file mode 100644 index 2819eb6..0000000 --- a/scripts/tests/bind-report.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { describe, expect, mock, test } from "bun:test"; - -import { loadBindDeclarations, loadTsDeclarations } from "./bind-test-helpers"; - -type Reporter = (raw: unknown) => void; -type ReportError = (error: unknown, context?: Record) => void; -type SentryEvent = { - exception?: { values?: { type: string; value: string }[] }; -}; - -function reportRuntime({ development = false, telemetry = true } = {}) { - const local = mock( - (_error: unknown, _context?: Record) => {}, - ); - const capture = mock((_error: unknown, _context?: unknown) => {}); - const { isNetworkError } = loadTsDeclarations<{ - isNetworkError: (error: unknown) => boolean; - }>("lib/network-error.ts", ["isNetworkError"]); - const telemetryStore = { getState: () => ({ enabled: telemetry }) }; - const { reportError } = loadTsDeclarations<{ reportError: ReportError }>( - "lib/report.ts", - ["reportError"], - { - logger: { extend: () => ({ error: local }) }, - Sentry: { captureException: capture }, - isNetworkError, - useTelemetryStore: telemetryStore, - __DEV__: development, - }, - ); - const { createBindErrorReporter } = loadBindDeclarations<{ - createBindErrorReporter: (report: ReportError) => Reporter; - }>(["createBindErrorReporter"]); - return { - local, - capture, - isNetworkError, - reportError, - report: createBindErrorReporter(reportError), - createBindErrorReporter, - telemetryStore, - }; -} - -describe("binding diagnostics and report filtering", () => { - test("forwards only whitelisted diagnostic metadata, without response contents or credentials", () => { - const runtime = reportRuntime(); - runtime.report({ - endpoint: "studentInfo", - kind: "invalid-response", - reason: "student-fields", - responseType: "object", - dataType: "object", - expandFieldType: "array", - status: false, - statusCode: 200, - attempt: 8, - code: 50000, - username: "private-username", - password: "private-password", - cookie: "private-cookie", - url: "https://talent.whut.edu.cn/?ticket=private-ticket", - message: "private-response", - data: { name: "private-name" }, - domain: "private-domain", - unknown: "private-extra", - }); - expect(runtime.capture).toHaveBeenCalledTimes(1); - expect(runtime.local.mock.calls[0]?.[1]).toEqual({ - module: "bind", - endpoint: "studentInfo", - kind: "invalid-response", - reason: "student-fields", - responseType: "object", - dataType: "object", - expandFieldType: "array", - status: false, - statusCode: 200, - attempt: 8, - code: 50000, - }); - expect(JSON.stringify(runtime.capture.mock.calls)).not.toContain( - "private-", - ); - expect(runtime.capture.mock.calls[0]?.[1]).toEqual({ - tags: { module: "bind" }, - extra: runtime.local.mock.calls[0]?.[1], - }); - }); - - test("rejects unknown endpoints/kinds and unsafe metadata without consuming valid diagnostics", () => { - const runtime = reportRuntime(); - for (const value of [ - null, - "error", - {}, - { endpoint: "private-user", kind: "http" }, - { endpoint: "studentInfo", kind: "private-kind" }, - ]) { - runtime.report(value); - } - expect(runtime.local).not.toHaveBeenCalled(); - runtime.report({ - endpoint: "studentInfo", - kind: "invalid-response", - reason: "private-response", - responseType: "function", - dataType: "private-data", - expandFieldType: "symbol", - status: "true", - statusCode: "200", - attempt: Number.MAX_SAFE_INTEGER + 1, - code: 1.5, - }); - expect(runtime.local.mock.calls[0]?.[1]).toEqual({ - module: "bind", - endpoint: "studentInfo", - kind: "invalid-response", - }); - }); - - test("deduplicates each endpoint while giving network failures a separate key", () => { - const runtime = reportRuntime(); - runtime.report({ endpoint: "studentInfo", kind: "network" }); - runtime.report({ endpoint: "studentInfo", kind: "network" }); - runtime.report({ - endpoint: "studentInfo", - kind: "invalid-response", - attempt: 8, - }); - runtime.report({ endpoint: "studentInfo", kind: "http", statusCode: 503 }); - runtime.report({ endpoint: "loginUser", kind: "http", statusCode: 500 }); - expect(runtime.local).toHaveBeenCalledTimes(3); - expect(runtime.capture).toHaveBeenCalledTimes(2); - expect(runtime.local.mock.calls[0]?.[0]).toMatchObject({ - code: "NETWORK_FAILURE", - }); - expect(runtime.local.mock.calls[1]?.[1]).toMatchObject({ - endpoint: "studentInfo", - kind: "invalid-response", - attempt: 8, - }); - const nextScreen = runtime.createBindErrorReporter(runtime.reportError); - nextScreen({ endpoint: "studentInfo", kind: "invalid-response" }); - expect(runtime.capture).toHaveBeenCalledTimes(3); - }); - - test.each([ - { development: true, telemetry: true }, - { development: false, telemetry: false }, - ])( - "keeps local diagnostics but respects telemetry configuration: %j", - (options) => { - const runtime = reportRuntime(options); - runtime.report({ endpoint: "studentInfo", kind: "invalid-response" }); - expect(runtime.local).toHaveBeenCalledTimes(1); - expect(runtime.capture).not.toHaveBeenCalled(); - }, - ); - - test.each([ - { code: -2, domain: undefined, sentry: false }, - { code: -8, domain: undefined, sentry: false }, - { code: -11, domain: undefined, sentry: true }, - { code: -1009, domain: "NSURLErrorDomain", sentry: false }, - { code: -1200, domain: "NSURLErrorDomain", sentry: true }, - ])( - "classifies WebView load errors through the real network filter: %j", - ({ code, domain, sentry }) => { - const runtime = reportRuntime(); - runtime.report({ endpoint: "talentPage", kind: "load", code, domain }); - expect(runtime.local.mock.calls[0]?.[0]).toMatchObject({ - name: "WebViewLoadError", - code, - }); - if (domain) - expect(runtime.local.mock.calls[0]?.[0]).toHaveProperty( - "domain", - domain, - ); - expect(runtime.capture).toHaveBeenCalledTimes(sentry ? 1 : 0); - }, - ); - - test("HTTP failures stay reportable and unknown native domains are not forwarded", () => { - const runtime = reportRuntime(); - runtime.report({ - endpoint: "talentPage", - kind: "load", - code: -1200, - domain: "untrusted-domain", - }); - runtime.report({ endpoint: "studentInfo", kind: "http", statusCode: 503 }); - expect(runtime.capture).toHaveBeenCalledTimes(2); - expect(runtime.local.mock.calls[0]?.[0]).not.toHaveProperty("domain"); - expect(runtime.local.mock.calls[0]?.[1]).not.toHaveProperty("domain"); - }); - - test("root Sentry beforeSend keeps telemetry and network filtering as a second boundary", () => { - const runtime = reportRuntime(); - let telemetryEnabled = true; - let beforeSend!: ( - event: SentryEvent, - hint: { originalException?: unknown }, - ) => SentryEvent | null; - loadTsDeclarations("app/_layout.tsx", [], { - __DEV__: false, - SENTRY_DSN: "test-dsn", - Updates: { channel: "test" }, - t: (key: string) => key, - isNetworkError: runtime.isNetworkError, - useTelemetryStore: { getState: () => ({ enabled: telemetryEnabled }) }, - Sentry: { - init: (options: { beforeSend: typeof beforeSend }) => { - beforeSend = options.beforeSend; - }, - feedbackIntegration: () => ({}), - wrap: (value: unknown) => value, - }, - Observe: { configure: () => {} }, - ObserveRoot: { wrap: (value: unknown) => value }, - }); - const diagnostic: SentryEvent = { - exception: { - values: [ - { type: "Error", value: "Bind studentInfo: invalid-response" }, - ], - }, - }; - expect(beforeSend(diagnostic, {})).toBe(diagnostic); - expect( - beforeSend(diagnostic, { - originalException: { code: "NETWORK_FAILURE" }, - }), - ).toBeNull(); - expect( - beforeSend( - { - exception: { - values: [{ type: "TypeError", value: "Network request failed" }], - }, - }, - {}, - ), - ).toBeNull(); - telemetryEnabled = false; - expect(beforeSend(diagnostic, {})).toBeNull(); - }); -}); diff --git a/scripts/tests/bind-script.test.ts b/scripts/tests/bind-script.test.ts deleted file mode 100644 index cc4966a..0000000 --- a/scripts/tests/bind-script.test.ts +++ /dev/null @@ -1,531 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { createContext, runInContext } from "node:vm"; - -import { loadBindDeclarations } from "./bind-test-helpers"; - -const { INJECTED_JS } = loadBindDeclarations<{ INJECTED_JS: string }>([ - "INJECTED_JS", -]); - -type Endpoint = "loginUser" | "studentInfo" | "colleges" | "education"; -type Message = { type: string; [key: string]: unknown }; -interface Reply { - body?: unknown; - status?: number; - network?: boolean; - invalidJson?: boolean; -} -type ReplyPlan = Reply | ((attempt: number) => Reply | Promise); - -const identity = { - code: 0, - data: { sn: "TEST-STUDENT", name: "Test Student" }, -}; -const fullProfile = { - code: 0, - status: true, - data: { - expandField: { - 基本信息: [{ fieldCode: "card_no", fieldValue: "TEST-CARD" }], - 学籍信息: [ - { fieldCode: "education_code", fieldValue: "TEST-EDU" }, - { fieldCode: "college_code", fieldValue: "TEST-COLLEGE" }, - ], - }, - }, -}; -const timeoutProfile = { - code: 50000, - status: false, - data: null, - message: "登录超时", -}; -const emptyInfo = { - type: "info", - name: "Test Student", - studentId: "TEST-STUDENT", - cardId: "", - college: "", - eduLevel: "", -}; -const fullInfo = { - ...emptyInfo, - cardId: "TEST-CARD", - college: "Test College", - eduLevel: "Test Education", -}; - -function endpointFor(url: string): Endpoint { - if (url.includes("getLoginUserNew")) return "loginUser"; - if (url.includes("findStudentInfoWechat")) return "studentInfo"; - if (url.includes("getColleges")) return "colleges"; - if (url.includes("getDictionByName")) return "education"; - throw new Error(`Unexpected test request: ${url}`); -} - -function createPage( - plans: Partial> = {}, - url = "https://talent.whut.edu.cn/information-center/center/index.html?appId=2", -) { - const defaults: Record = { - loginUser: { body: identity }, - studentInfo: { body: fullProfile }, - colleges: { - body: { code: 0, data: [{ sn: "TEST-COLLEGE", name: "Test College" }] }, - }, - education: { - body: { code: 0, data: [{ code: "TEST-EDU", name: "Test Education" }] }, - }, - }; - const messages: Message[] = []; - const requests: { endpoint: Endpoint; url: string; options: unknown }[] = []; - const timers: { callback: () => void; delay: number }[] = []; - const redirects: string[] = []; - const pageWindow = { - ReactNativeWebView: { - postMessage: (value: string) => messages.push(JSON.parse(value)), - }, - }; - const location = Object.assign(new URL(url), { - replace: (value: string) => redirects.push(value), - }); - const context = createContext({ - window: pageWindow, - location, - document: {}, - setTimeout: (callback: () => void, delay: number) => { - timers.push({ callback, delay }); - return timers.length; - }, - fetch: async (requestUrl: string, options: unknown) => { - const endpoint = endpointFor(requestUrl); - requests.push({ endpoint, url: requestUrl, options }); - const attempt = requests.filter( - (item) => item.endpoint === endpoint, - ).length; - const plan = plans[endpoint] ?? defaults[endpoint]; - const reply = await (typeof plan === "function" ? plan(attempt) : plan); - if (reply.network) throw new Error("Synthetic network failure"); - const status = reply.status ?? 200; - return { - ok: status >= 200 && status < 300, - status, - json: async () => { - if (reply.invalidJson) - throw new SyntaxError("Synthetic invalid JSON"); - return reply.body; - }, - }; - }, - }); - const inject = () => runInContext(INJECTED_JS, context); - // A real event-loop turn drains the complete fetch/Promise.all/finally chain. - // Fake retry timers are advanced only after that chain has settled. - const settle = () => new Promise((resolve) => setImmediate(resolve)); - const takeTimer = () => { - const timer = timers.shift(); - if (!timer) throw new Error("Expected a pending retry timer"); - expect(timer.delay).toBe(1000); - return timer.callback; - }; - const advance = async () => { - takeTimer()(); - await settle(); - }; - const finish = async () => { - await settle(); - for (let turn = 0; timers.length; turn++) { - if (turn >= 10) throw new Error("Binding scheduled unbounded retries"); - await advance(); - } - }; - const terminals = () => - messages.filter((message) => - ["info", "profileUnavailable", "bindingFailed"].includes(message.type), - ); - const diagnostics = () => - messages.filter((message) => message.type === "diagnostic"); - const count = (endpoint: Endpoint) => - requests.filter((request) => request.endpoint === endpoint).length; - inject(); - return { - messages, - requests, - timers, - redirects, - inject, - settle, - takeTimer, - advance, - finish, - terminals, - diagnostics, - count, - }; -} - -function expectDiagnostic( - page: ReturnType, - endpoint: Endpoint, - kind: string, - reason?: string, -) { - expect(page.diagnostics()).toContainEqual( - expect.objectContaining({ endpoint, kind, ...(reason ? { reason } : {}) }), - ); -} - -describe("production binding injection: successful and optional profiles", () => { - test("keeps complete profile fields and expands the real template correctly", async () => { - const page = createPage(); - await page.finish(); - expect(page.terminals()).toEqual([fullInfo]); - expect(page.diagnostics()).toEqual([]); - expect(page.count("loginUser")).toBe(1); - expect(page.requests.find((r) => r.endpoint === "education")?.url).toBe( - `/information-center/xd/commons/getDictionByName/${encodeURIComponent("学生类型")}`, - ); - for (const request of page.requests) { - expect(request.options).toEqual({ - credentials: "include", - headers: { accept: "application/json" }, - }); - } - }); - - test.each([ - ["null", { expandField: null }], - ["missing", {}], - ["empty object", { expandField: {} }], - ])( - "successful %s expansion finishes on the first attempt", - async (_, data) => { - const page = createPage({ - studentInfo: { body: { code: 0, status: true, data } }, - }); - await page.finish(); - expect(page.terminals()).toEqual([emptyInfo]); - expect(page.count("loginUser")).toBe(1); - expect(page.diagnostics()).toEqual([]); - }, - ); - - test.each([2, 8])( - "keeps recovered data on attempt %i", - async (recoveryAttempt) => { - const page = createPage({ - studentInfo: (attempt) => - attempt < recoveryAttempt ? { status: 503 } : { body: fullProfile }, - }); - await page.finish(); - expect(page.terminals()).toEqual([fullInfo]); - expect(page.count("loginUser")).toBe(recoveryAttempt); - expectDiagnostic(page, "studentInfo", "http"); - }, - ); - - test.each([ - ["network", { network: true }], - ["http", { status: 503 }], - ["invalid-json", { invalidJson: true }], - ["invalid-response", { body: { code: 0, data: "bad dictionary" } }], - ] satisfies [string, Reply][])( - "optional dictionary %s errors preserve binding", - async (kind, reply) => { - const page = createPage({ colleges: reply, education: reply }); - await page.finish(); - expect(page.terminals()).toEqual([{ ...emptyInfo, cardId: "TEST-CARD" }]); - expect(page.count("loginUser")).toBe(1); - expectDiagnostic(page, "colleges", kind); - expectDiagnostic(page, "education", kind); - }, - ); -}); - -describe("production binding injection: student profile fallback", () => { - test.each([ - ["network", { network: true }], - ["http", { status: 503 }], - ["invalid-json", { invalidJson: true }], - ] satisfies [string, Reply][])( - "persistent student %s errors retain current identity", - async (kind, reply) => { - const page = createPage({ studentInfo: reply }); - await page.finish(); - expect(page.terminals()).toEqual([emptyInfo]); - expect(page.count("loginUser")).toBe(8); - expect(page.count("studentInfo")).toBe(8); - expectDiagnostic(page, "studentInfo", kind); - expect( - page.diagnostics().some((m) => m.reason === "student-response"), - ).toBe(false); - if (kind === "http") { - expect(page.diagnostics()[0].statusCode).toBe(503); - } - }, - ); - - test("persistent session timeout is diagnosed only at the final fallback", async () => { - const page = createPage({ studentInfo: { body: timeoutProfile } }); - await page.settle(); - for (let attempt = 1; attempt < 8; attempt++) { - expect(page.terminals()).toEqual([]); - expect(page.diagnostics()).toEqual([]); - await page.advance(); - } - expect(page.terminals()).toEqual([emptyInfo]); - expect(page.diagnostics()).toEqual([ - expect.objectContaining({ reason: "session-timeout", attempt: 8 }), - ]); - expect(page.messages.at(-1)).toEqual(emptyInfo); - expect(page.timers).toHaveLength(0); - }); - - test("temporary session timeout recovers without a timeout diagnostic", async () => { - const page = createPage({ - studentInfo: (attempt) => ({ - body: attempt < 3 ? timeoutProfile : fullProfile, - }), - }); - await page.finish(); - expect(page.terminals()).toEqual([fullInfo]); - expect(page.count("studentInfo")).toBe(3); - expect(page.diagnostics()).toEqual([]); - }); - - test.each([ - ["missing status", { code: 0, data: {} }], - [ - "false status with empty expansion", - { code: 0, status: false, data: { expandField: null } }, - ], - [ - "nonzero code with empty expansion", - { code: 123, status: true, data: { expandField: null } }, - ], - ["missing code", { status: true, data: {} }], - ["null data", { code: 0, status: true, data: null }], - ["array data", { code: 0, status: true, data: [] }], - ["null response", null], - ])( - "invalid student response (%s) retries before using empty fields", - async (_, body) => { - const page = createPage({ studentInfo: { body } }); - await page.finish(); - expect(page.count("studentInfo")).toBe(8); - expect(page.terminals()).toEqual([emptyInfo]); - expectDiagnostic( - page, - "studentInfo", - "invalid-response", - "student-response", - ); - }, - ); - - test.each([["invalid"], [[]], [42], [false]])( - "rejects invalid expansion %j before falling back", - async (expandField) => { - const page = createPage({ - studentInfo: { body: { code: 0, status: true, data: { expandField } } }, - }); - await page.finish(); - expect(page.count("studentInfo")).toBe(8); - expect(page.terminals()).toEqual([emptyInfo]); - expectDiagnostic(page, "studentInfo", "invalid-response", "expand-field"); - }, - ); - - test.each([ - { 基本信息: "invalid" }, - { 学籍信息: {} }, - { 基本信息: [null] }, - { 学籍信息: ["invalid"] }, - ])("rejects malformed field collections %j", async (expandField) => { - const page = createPage({ - studentInfo: { body: { code: 0, status: true, data: { expandField } } }, - }); - await page.finish(); - expect(page.count("studentInfo")).toBe(8); - expect(page.terminals()).toEqual([emptyInfo]); - expectDiagnostic(page, "studentInfo", "invalid-response", "student-fields"); - }); - - test("a parsing exception discards previously assigned fields", async () => { - // This is a possible JSON value; String(value) throws after cardId was set. - const expandField = { - 基本信息: fullProfile.data.expandField.基本信息, - 学籍信息: [ - { - fieldCode: "education_code", - fieldValue: { toString: null, valueOf: null }, - }, - ], - }; - const page = createPage({ - studentInfo: { body: { code: 0, status: true, data: { expandField } } }, - }); - await page.finish(); - expect(page.terminals()).toEqual([emptyInfo]); - expect(page.count("studentInfo")).toBe(8); - expectDiagnostic( - page, - "studentInfo", - "invalid-response", - "student-parsing", - ); - }); - - test("fallback uses the identity returned in the final attempt", async () => { - const page = createPage({ - loginUser: (attempt) => ({ - body: { - code: 0, - data: { sn: `TEST-${attempt}`, name: `Student ${attempt}` }, - }, - }), - studentInfo: { network: true }, - }); - await page.finish(); - expect(page.terminals()).toEqual([ - { ...emptyInfo, studentId: "TEST-8", name: "Student 8" }, - ]); - }); -}); - -describe("production binding injection: identity failure and explicit unavailable", () => { - test.each([ - ["network", { network: true }], - ["http", { status: 503 }], - ["invalid-json", { invalidJson: true }], - ["invalid-response", { body: { code: 0, data: {} } }], - ["invalid-response", { body: { code: 123, data: identity.data } }], - ["invalid-response", { body: { code: 0, data: null } }], - ] satisfies [string, Reply][])( - "persistent identity %s failures allow setup without info", - async (kind, reply) => { - const page = createPage({ loginUser: reply }); - await page.finish(); - expect(page.terminals()).toEqual([ - { type: "profileUnavailable", identityUnavailable: true }, - ]); - expect(page.count("loginUser")).toBe(8); - expect(page.count("studentInfo")).toBe(0); - expectDiagnostic(page, "loginUser", kind); - }, - ); - - test("an earlier valid identity cannot authorize fallback after final identity failure", async () => { - const page = createPage({ - loginUser: (attempt) => - attempt < 8 ? { body: identity } : { network: true }, - studentInfo: { network: true }, - }); - await page.finish(); - expect(page.terminals()).toEqual([ - { type: "profileUnavailable", identityUnavailable: true }, - ]); - expect(page.count("loginUser")).toBe(8); - expect(page.count("studentInfo")).toBe(7); - expectDiagnostic(page, "loginUser", "network"); - }); - - test.each(["loginUser", "studentInfo"] as const)( - "%s code 50005 retains its immediate setup path", - async (endpoint) => { - const page = createPage({ [endpoint]: { body: { code: 50005 } } }); - await page.finish(); - expect(page.terminals()).toEqual([{ type: "profileUnavailable" }]); - expect(page.count("loginUser")).toBe(1); - expect(page.diagnostics()).toEqual([]); - }, - ); -}); - -describe("production binding injection: lifecycle guards", () => { - test("repeated injection cannot duplicate requests or completion", async () => { - const page = createPage(); - for (let index = 0; index < 5; index++) page.inject(); - await page.finish(); - page.inject(); - await page.finish(); - expect(page.terminals()).toEqual([fullInfo]); - for (const endpoint of [ - "loginUser", - "studentInfo", - "colleges", - "education", - ] as const) { - expect(page.count(endpoint)).toBe(1); - } - }); - - test("a pending student request keeps the full attempt locked and late retries are inert", async () => { - let releaseStudent!: (reply: Reply) => void; - const studentReply = new Promise((resolve) => { - releaseStudent = resolve; - }); - const page = createPage({ - studentInfo: (attempt) => - attempt === 1 ? { status: 503 } : studentReply, - }); - await page.settle(); - const retry = page.takeTimer(); - retry(); - await page.settle(); - expect(page.count("studentInfo")).toBe(2); - retry(); - retry(); - page.inject(); - await page.settle(); - expect(page.count("loginUser")).toBe(2); - expect(page.terminals()).toEqual([]); - releaseStudent({ body: fullProfile }); - await page.finish(); - expect(page.terminals()).toEqual([fullInfo]); - retry(); - page.inject(); - await page.finish(); - expect(page.count("loginUser")).toBe(2); - expect(page.terminals()).toEqual([fullInfo]); - expect(page.timers).toHaveLength(0); - }); - - test.each(["loginUser", "studentInfo"] as const)( - "%s retry exhaustion produces one terminal and no further requests", - async (endpoint) => { - const page = createPage({ [endpoint]: { network: true } }); - await page.settle(); - const lateRetry = page.takeTimer(); - lateRetry(); - await page.finish(); - expect(page.terminals()).toHaveLength(1); - expect(page.count("loginUser")).toBe(8); - lateRetry(); - page.inject(); - await page.finish(); - expect(page.terminals()).toHaveLength(1); - expect(page.count("loginUser")).toBe(8); - expect(page.timers).toHaveLength(0); - }, - ); - - test("the talent root redirects through the interpolated application path only once", async () => { - const page = createPage({}, "https://talent.whut.edu.cn/"); - page.inject(); - await page.finish(); - expect(page.redirects).toEqual([ - "/information-center/center/index.html?appId=2", - ]); - expect(page.requests).toEqual([]); - expect(page.messages).toEqual([]); - }); - - test("unrelated origins do not run the binding script", async () => { - const page = createPage({}, "https://unrelated.example/"); - await page.finish(); - expect(page.requests).toEqual([]); - expect(page.messages).toEqual([]); - expect(page.timers).toEqual([]); - }); -}); diff --git a/scripts/tests/bind-test-helpers.ts b/scripts/tests/bind-test-helpers.ts deleted file mode 100644 index 78a6f93..0000000 --- a/scripts/tests/bind-test-helpers.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { readFileSync } from "node:fs"; -import { runInNewContext } from "node:vm"; -import ts from "typescript"; - -/** Evaluate production declarations without importing the native app runtime. */ -export function loadTsDeclarations( - relativePath: string, - names: string[], - globals: Record = {}, -): T { - if (names.some((name) => !/^[A-Za-z_$][\w$]*$/.test(name))) { - throw new Error("Expected declaration names, not expressions"); - } - const file = new URL(`../../${relativePath}`, import.meta.url); - const source = ts.createSourceFile( - file.pathname, - readFileSync(file, "utf8"), - ts.ScriptTarget.Latest, - true, - relativePath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS, - ); - const declarations = ts.factory.updateSourceFile( - source, - source.statements.filter((statement) => !ts.isImportDeclaration(statement)), - ); - // Transpile the complete AST, including template substitutions and escapes. - // Taking raw template text would execute different JavaScript in the WebView. - const compiled = ts.transpileModule( - ts.createPrinter().printFile(declarations), - { - fileName: file.pathname, - compilerOptions: { - target: ts.ScriptTarget.ES2022, - module: ts.ModuleKind.CommonJS, - jsx: ts.JsxEmit.React, - }, - }, - ).outputText; - return runInNewContext( - `${compiled}\n({${names.map((name) => `${name}: typeof ${name} !== 'undefined' ? ${name} : exports.${name}`).join(",")}});`, - { exports: {}, URL, encodeURIComponent, ...globals }, - { filename: file.pathname }, - ) as T; -} - -export function loadBindDeclarations( - names: string[], - globals: Record = {}, -): T { - return loadTsDeclarations("app/browser/bind.tsx", names, globals); -}