#!/usr/bin/env node import { createHash } from 'node:crypto'; import { mkdirSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; const DEFAULT_PORT = process.env.LWLT_CDP_PORT || '9223'; function parseArgs(argv) { const args = { _: [] }; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (!arg.startsWith('--')) { args._.push(arg); continue; } const key = arg.slice(2); const next = argv[i + 1]; if (!next || next.startsWith('--')) args[key] = true; else { args[key] = next; i += 1; } } return args; } function usage() { return [ 'Usage:', ' node tools/browser_order_add_template_selftest.mjs --open-form --intercept-submit --out reports/template-selftest.json', '', 'Options:', ` --port Chrome DevTools port. Default: ${DEFAULT_PORT}`, ' --open-form Navigate the LTJT main iframe to a fresh orders_add.asp form first.', ' --product-index Existing product option index to use. Default: 0.', ' --departure-date Test departure date. Default: 2026-8-1.', ' --intercept-submit Also call SubmitInfoForm() with DoInfoJH AJAX intercepted.', ' --out Write redacted self-test report.', ' --approved-preflight-out Write a browser_preflight_passed-compatible redacted report.', ' --approved-intercept-out Write a submit_intercept_captured-compatible redacted report.', ' --help Show this help.', '', 'This tool uses existing LTJT options in browser memory only. It writes no real option values to disk and never lets DoInfoJH reach the network.' ].join('\n'); } function writeJson(path, data) { mkdirSync(dirname(resolve(path)), { recursive: true }); writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`); } async function getJson(url) { const res = await fetch(url); if (!res.ok) throw new Error(`${res.status} ${res.statusText}: ${url}`); return res.json(); } async function getTarget(port) { const targets = await getJson(`http://127.0.0.1:${port}/json`); const target = targets.find((item) => item.type === 'page' && item.url.includes('ltjt.yunzhi.run')); if (!target) throw new Error(`No ltjt.yunzhi.run page target on port ${port}`); return target; } class CDP { constructor(wsUrl) { this.wsUrl = wsUrl; this.id = 0; this.pending = new Map(); } connect() { return new Promise((resolve, reject) => { this.ws = new WebSocket(this.wsUrl); this.ws.onopen = resolve; this.ws.onerror = (event) => reject(new Error(event.message || event.type || 'WebSocket error')); this.ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (!msg.id || !this.pending.has(msg.id)) return; const { resolve: ok, reject: fail, timer } = this.pending.get(msg.id); clearTimeout(timer); this.pending.delete(msg.id); msg.error ? fail(new Error(msg.error.message)) : ok(msg.result); }; }); } send(method, params = {}, timeoutMs = 30000) { const id = ++this.id; this.ws.send(JSON.stringify({ id, method, params })); return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`Timeout: ${method}`)); }, timeoutMs); this.pending.set(id, { resolve, reject, timer }); }); } close() { this.ws?.close(); } } async function evaluate(cdp, expression, timeoutMs = 30000) { const result = await cdp.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }, timeoutMs); if (result.exceptionDetails) { const detail = result.exceptionDetails.exception?.description || result.exceptionDetails.exception?.value || result.exceptionDetails.text || 'Runtime.evaluate failed'; throw new Error(detail); } return result.result.value; } function buildCompatiblePreflightReport(output) { const submitPayloadSha256 = output.submit_intercept?.intercepted_submits?.[0]?.payload_sha256 || ''; return { generated_at: new Date().toISOString(), status: output.status === 'template_selftest_passed' ? 'browser_preflight_passed' : 'browser_preflight_blocked', source: 'browser_order_add_template_selftest', page: output.page, lookup_checks: output.exact_checks, product_side_effect: output.product_side_effect, consistency_checks: Object.entries(output.core_after_product || {}).map(([name, value]) => ({ name, passed: Boolean(value.nonempty), expected_redacted: true, actual_redacted: true, actual_length: value.value_length || 0 })), final_form: { ...output.final_form, submit_payload_sha256: submitPayloadSha256 || undefined, submit_function_mutates_payload: Boolean(submitPayloadSha256 && submitPayloadSha256 !== output.final_form?.serialized_form_sha256) }, blockers: output.blockers || [], warnings: output.warnings || [], submit_safety: { live_submit_attempted: false, live_submit_supported_by_this_tool: false, note: 'Derived from redacted template self-test. The live DoInfoJH request was not sent.' }, test_markers: output.test_markers }; } function buildCompatibleInterceptReport(output) { const submitIntercept = output.submit_intercept || {}; return { generated_at: new Date().toISOString(), status: submitIntercept.status || 'submit_intercept_blocked', source: 'browser_order_add_template_selftest', page: output.page, intercepted_submit_count: submitIntercept.intercepted_submit_count || 0, intercepted_submits: submitIntercept.intercepted_submits || [], passthrough_ajax_count: 0, alerts: submitIntercept.alerts || [], blockers: submitIntercept.status === 'submit_intercept_captured' ? [] : ['template self-test submit intercept did not pass'], submit_safety: { live_submit_attempted: false, DoInfoJH_network_prevented: submitIntercept.DoInfoJH_network_prevented === true, note: 'Derived from redacted template self-test. jQuery.ajax intercepted DoInfoJH before network.' }, test_markers: output.test_markers }; } function selfTestExpression({ openForm, productIndex, departureDate, interceptSubmit }) { return `(async () => { const openForm = ${JSON.stringify(Boolean(openForm))}; const productIndex = ${JSON.stringify(Number(productIndex || 0))}; const departureDate = ${JSON.stringify(departureDate || '2026-8-1')}; const interceptSubmit = ${JSON.stringify(Boolean(interceptSubmit))}; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); async function sha256(text) { const buffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)); return Array.from(new Uint8Array(buffer)).map((byte) => byte.toString(16).padStart(2, '0')).join(''); } const entryUrl = 'https://ltjt.yunzhi.run/System/Business/orders_add.asp?fabudanwei=' + encodeURIComponent('老挝联泰') + '©=0&ddid=0&_=' + Date.now(); const entryStamp = new URL(entryUrl).searchParams.get('_'); function findOrderWindow() { const queue = [window]; while (queue.length) { const win = queue.shift(); let doc; try { doc = win.document; } catch (err) { continue; } if (doc.querySelector('#ListForm') && doc.location.href.includes('/orders_add.asp')) return win; Array.from(win.frames).forEach((frame) => queue.push(frame)); } return null; } function topState() { return { url: location.href, title: document.title }; } if (/login/i.test(location.href) || /云智办公i-5.1/.test(document.title || '')) { return { ok: false, status: 'login_required', blockers: ['login_required'], top: topState() }; } if (openForm) { const frame = document.getElementById('Iframe_Home') || document.querySelector('iframe[name="MainIframe"], iframe'); if (!frame) return { ok: false, status: 'main_iframe_not_found', blockers: ['main_iframe_not_found'], top: topState() }; frame.src = entryUrl; } const started = Date.now(); let orderWin = findOrderWindow(); while ((!orderWin || !orderWin.document.querySelector('#ListForm') || orderWin.document.querySelector('#ListForm').elements.length < 800 || (openForm && !orderWin.document.location.href.includes('_=' + entryStamp))) && Date.now() - started < 25000) { await sleep(500); orderWin = findOrderWindow(); } if (!orderWin) return { ok: false, status: 'orders_add_form_not_found', blockers: ['orders_add_form_not_found'], top: topState() }; const doc = orderWin.document; const form = doc.querySelector('#ListForm'); const blockers = []; const warnings = []; const setResults = []; const ajaxRecords = []; const fabudanwei = form.elements.fabudanwei?.value || '老挝联泰'; const testMarker = 'AI-DRYRUN-NO-SUBMIT'; const testSuffix = testMarker + '-' + Date.now(); function splitRows(text) { const str = String(text || ''); if (!str) return []; if (str.includes('◇')) return str.split('◇').map((row) => row.trim()).filter(Boolean); if (/\\r?\\n/.test(str)) return str.split(/\\r?\\n/).map((row) => row.trim()).filter(Boolean); return [str.trim()].filter(Boolean); } function splitColumns(row) { return String(row || '').split('◆').map((part) => part.trim()); } function summarizeText(text) { const rows = splitRows(text); const histogram = {}; rows.slice(0, 200).forEach((row) => { const count = splitColumns(row).length; histogram[count] = (histogram[count] || 0) + 1; }); return { byte_length: new Blob([String(text || '')]).size, row_count: rows.length, column_count_histogram_first_200_rows: histogram, value_redacted: true }; } async function fetchText(path) { const url = new URL(path, doc.location.href).href; const res = await orderWin.fetch(url, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' } }); const text = await res.text(); return { ok: res.ok, status: res.status, text }; } function exactMatches(text, expected, column) { const expectedText = String(expected || '').trim(); if (!expectedText) return []; return splitRows(text) .map((row, rowIndex) => ({ rowIndex, columns: splitColumns(row) })) .filter((row) => String(row.columns[column] || '').trim() === expectedText); } function getValue(name) { const el = form.elements[name]; if (!el) return ''; if (el.length && !el.tagName) return Array.from(el).find((item) => item.checked)?.value || el[0]?.value || ''; return el.value ?? ''; } function setValue(name, value, source) { const elements = Array.from(form.elements).filter((el) => el.name === name); if (!elements.length) { blockers.push('Cannot set missing form field ' + name + ' from ' + source); return; } const textValue = String(value ?? ''); const type = String(elements[0].type || '').toLowerCase(); if (type === 'radio') { const matched = elements.find((el) => String(el.value) === textValue) || elements[0]; elements.forEach((el) => { el.checked = el === matched; }); } else if (type === 'checkbox') { elements[0].checked = Boolean(value); if (textValue) elements[0].value = textValue; } else { elements[0].value = textValue; } setResults.push({ name, source, type, value_length: textValue.length, value_redacted: true }); } function summarizeValues(values) { const normalized = values.map((value) => String(value ?? '')); return { value_count: normalized.length, nonempty_count: normalized.filter((value) => value !== '').length, total_value_length: normalized.reduce((sum, value) => sum + value.length, 0), value_redacted: true }; } function snapshotForm() { const snapshot = {}; Array.from(form.elements).forEach((el, index) => { const name = el.name || el.id || '__element_' + index; const type = String(el.type || '').toLowerCase(); const value = (type === 'checkbox' || type === 'radio') ? (el.checked ? el.value : '') : (el.value ?? ''); if (!snapshot[name]) snapshot[name] = []; snapshot[name].push(String(value)); }); return snapshot; } function compareSnapshots(before, after) { const names = Array.from(new Set([...Object.keys(before), ...Object.keys(after)])).sort(); return names .filter((name) => JSON.stringify(before[name] || []) !== JSON.stringify(after[name] || [])) .map((name) => ({ name, before: summarizeValues(before[name] || []), after: summarizeValues(after[name] || []) })); } function groupChangedFields(changes) { const groups = {}; for (const change of changes) { let group = 'other'; if (/^(Text|shuoming|oldxingcheng|xingcheng)/.test(change.name)) group = 'itinerary_text'; else if (/^(zao|zhong|wan|zhusu|jingdian)/.test(change.name)) group = 'resources'; else if (/^(ys_|youyingshou|baojia|yingfu)/.test(change.name)) group = 'pricing_receivable'; else if (/^(TianShu|chanpinming|tuanxuhao|zhuanxianming|zutuanshe|bizhong|lianxiren)/.test(change.name)) group = 'core'; groups[group] = (groups[group] || 0) + 1; } return groups; } const productList = await fetchText('../dat/AjaxPublicFun.asp?Act=ProGetProductname&fls=1&fabudanwei=' + encodeURIComponent(fabudanwei)); const routeList = await fetchText('../dat/AjaxPublicFun.asp?Act=GetInformation&fl=3&fls=1&fabudanwei=' + encodeURIComponent(fabudanwei)); const customerList = await fetchText('../dat/AjaxPublicFun.asp?Act=ProTravel&fabudanwei=' + encodeURIComponent(fabudanwei)); const staffList = await fetchText('../dat/AjaxPublicFun.asp?Act=ProDanwei_Yuangong&fabudanwei=' + encodeURIComponent(fabudanwei)); const productRows = splitRows(productList.text).map((row, rowIndex) => ({ rowIndex, columns: splitColumns(row) })).filter((row) => String(row.columns[1] || '').trim()); const product = productRows[productIndex] || productRows[0]; if (!product) blockers.push('No existing product option was available'); const $ = orderWin.jQuery || orderWin.$; const originalAjax = $?.ajax; if (!$ || typeof originalAjax !== 'function') blockers.push('jQuery.ajax is not available in order form'); if (typeof orderWin.Find_product !== 'function') blockers.push('Find_product() is not available'); if (!blockers.length) { $.ajax = function ajaxWrapper(options, ...rest) { const config = typeof options === 'string' ? { url: options } : { ...(options || {}) }; const isProductEffect = String(config.url || '').includes('Act=GetProduct'); if (!isProductEffect) return originalAjax.call(this, options, ...rest); const record = { url_path_redacted: '../dat/AjaxPublicFun.asp?Act=GetProduct&cpm=[redacted]', method: config.type || config.method || 'GET', dataType: config.dataType || '', completed: false, ok: false, text_status: '', response_byte_length: 0, contains_login_timeout_text: false }; ajaxRecords.push(record); const originalSuccess = config.success; const originalError = config.error; const originalComplete = config.complete; config.success = function successWrapper(data, textStatus) { const text = String(data || ''); record.ok = true; record.text_status = textStatus || ''; record.response_byte_length = new Blob([text]).size; record.contains_login_timeout_text = /登陆|登录|login/i.test(text); return originalSuccess ? originalSuccess.apply(this, arguments) : undefined; }; config.error = function errorWrapper(jqXHR, textStatus, errorThrown) { record.ok = false; record.text_status = textStatus || ''; record.error_text_redacted = errorThrown ? String(errorThrown).slice(0, 80) : ''; return originalError ? originalError.apply(this, arguments) : undefined; }; config.complete = function completeWrapper() { record.completed = true; return originalComplete ? originalComplete.apply(this, arguments) : undefined; }; return originalAjax.call(this, config, ...rest); }; } const beforeProduct = snapshotForm(); if (!blockers.length) { setValue('chanpinming', product.columns[1], 'existing_product_option'); try { orderWin.Find_product(); } catch (err) { blockers.push('Find_product() threw: ' + (err.message || String(err)).slice(0, 160)); } } const effectStarted = Date.now(); while (Date.now() - effectStarted < 15000) { if (ajaxRecords.some((record) => record.completed)) break; await sleep(250); } await sleep(1000); if ($ && originalAjax) $.ajax = originalAjax; const afterProduct = snapshotForm(); const productChanges = compareSnapshots(beforeProduct, afterProduct); const productAjax = ajaxRecords.find((record) => record.url_path_redacted.includes('GetProduct')) || null; if (!productAjax) blockers.push('GetProduct ajax request was not observed'); else if (!productAjax.ok) blockers.push('GetProduct ajax request did not complete successfully'); else if (productAjax.contains_login_timeout_text) blockers.push('GetProduct response looked like a login-timeout script'); if (!productChanges.length) blockers.push('Find_product/GetProduct completed without changing any serialized form fields'); const core = { product: getValue('chanpinming'), trip: getValue('TianShu'), route: getValue('zhuanxianming'), routePrefix: getValue('tuanxuhao1'), customer: getValue('zutuanshe'), customerId: getValue('zutuansheid'), salesUser: getValue('xiaoshouren') }; const routeMatches = exactMatches(routeList.text, core.route, 1); const customerMatches = exactMatches(customerList.text, core.customer, 2); const productMatches = exactMatches(productList.text, core.product, 1); const tripMatches = exactMatches('1D◇2D1N◇3D2N◇4D3N◇5D4N◇6D5N◇7D6N◇8D7N◇9D8N◇10D9N', core.trip, 0); const staffRows = splitRows(staffList.text).map((row, rowIndex) => ({ rowIndex, columns: splitColumns(row) })).filter((row) => String(row.columns[0] || '').trim()); let selectedStaff = ''; const productSalesMatches = exactMatches(staffList.text, core.salesUser, 0); if (productSalesMatches.length === 1) selectedStaff = productSalesMatches[0].columns[0]; else if (staffRows[0]) selectedStaff = staffRows[0].columns[0]; const staffMatches = exactMatches(staffList.text, selectedStaff, 0); const exactChecks = [ { name: 'product', exact_match_count: productMatches.length }, { name: 'trip_days', exact_match_count: tripMatches.length }, { name: 'route', exact_match_count: routeMatches.length }, { name: 'customer', exact_match_count: customerMatches.length }, { name: 'staff_for_op_and_sales', exact_match_count: staffMatches.length } ]; exactChecks.forEach((check) => { if (check.exact_match_count !== 1) blockers.push(check.name + ': expected exactly one self-test LTJT match, found ' + check.exact_match_count); }); ['product', 'trip', 'route', 'routePrefix', 'customer', 'customerId'].forEach((key) => { if (!String(core[key] || '').trim()) blockers.push('Product template did not populate required core field ' + key); }); if (!blockers.length) { const currency = getValue('bizhong') || 'USD'; setValue('chufa_ri', departureDate, 'selftest_test_departure_date'); setValue('tuanxuhao2', testSuffix, 'selftest_obvious_test_suffix'); setValue('gendanren', selectedStaff, 'selftest_existing_staff_option'); setValue('xiaoshouren', selectedStaff, 'selftest_existing_staff_option'); setValue('darenshu', '1', 'selftest_passenger_counts'); setValue('xiaorenshu', '0', 'selftest_passenger_counts'); setValue('ertrenshu', '0', 'selftest_passenger_counts'); setValue('yingrenshu', '0', 'selftest_passenger_counts'); setValue('quanrenshu', '0', 'selftest_passenger_counts'); setValue('frenshu0', '1', 'selftest_room_counts'); setValue('frenshu1', '0', 'selftest_room_counts'); setValue('frenshu2', '0', 'selftest_room_counts'); setValue('frenshu3', '0', 'selftest_room_counts'); setValue('frenshu4', '0', 'selftest_room_counts'); setValue('frenshu5', '0', 'selftest_room_counts'); setValue('frenshu6', '1', 'selftest_room_counts'); setValue('danzhuangtai', '预订', 'selftest_status'); setValue('yaobeian', '要备案', 'selftest_checkbox'); setValue('xiadanbeizhu', testMarker + ' 自动化自测草稿,不提交,不代表真实订单。', 'selftest_obvious_test_note'); const receivableTargets = ['ys_danwei','ys_danweiid','ys_shuoming','ys_zhanwei','ys_xiangmu','ys_fangshi','ys_bizhong','ys_shuliang','ys_danjia','ys_jine','ys_yishoufu','ys_beizhu','ys_id','ys_shoufulei','ys_caozuoren','ys_shenheren']; for (let index = 0; index < 10; index += 1) { receivableTargets.forEach((prefix) => setValue(prefix + index, '', 'selftest_clear_receivables')); } setValue('ys_danwei0', core.customer, 'selftest_receivable_customer'); setValue('ys_danweiid0', core.customerId, 'selftest_receivable_customer_id'); setValue('ys_xiangmu0', testMarker + ' 测试团费', 'selftest_receivable_item'); setValue('ys_shuoming0', '人', 'selftest_receivable_unit'); setValue('ys_bizhong0', currency, 'selftest_receivable_currency'); setValue('ys_shuliang0', '1', 'selftest_receivable_quantity'); setValue('ys_danjia0', '1', 'selftest_receivable_unit_price'); setValue('ys_jine0', '1', 'selftest_receivable_amount'); setValue('ys_yishoufu0', '0', 'selftest_receivable_paid'); setValue('ys_beizhu0', testMarker, 'selftest_receivable_remark'); setValue('ys_shoufulei0', '0', 'selftest_receivable_payment_type'); setValue('ys_caozuoren0', testMarker, 'selftest_receivable_operator_marker'); } const requiredTargets = ['chufa_ri','zhuanxianming','TianShu','zutuanshe','chanpinming','gendanren','tuanxuhao1','tuanxuhao2','darenshu','xiaorenshu','ertrenshu','yingrenshu','quanrenshu','xiaoshouren']; const requiredMissing = requiredTargets.filter((name) => String(getValue(name) || '').trim() === ''); if (requiredMissing.length) blockers.push('Required fields still blank: ' + requiredMissing.join(', ')); const serializedForm = orderWin.jQuery ? orderWin.jQuery(form).serialize() : Array.from(new FormData(form).entries()).map(([key, value]) => encodeURIComponent(key) + '=' + encodeURIComponent(value)).join('&'); const serializedParams = new URLSearchParams(serializedForm); let submitIntercept = null; if (interceptSubmit && !blockers.length) { const originalAjaxForSubmit = $.ajax; const originalAlert = orderWin.alert; const originalDialogAlert = $.dialog?.alert; const intercepted = []; const alerts = []; orderWin.alert = function interceptedAlert(message) { alerts.push({ type: 'alert', message_length: String(message || '').length, message_redacted: true }); return undefined; }; if ($.dialog && typeof $.dialog.alert === 'function') { $.dialog.alert = function interceptedDialogAlert(message) { alerts.push({ type: 'dialog.alert', message_length: String(message || '').length, message_redacted: true }); return undefined; }; } $.ajax = function ajaxIntercept(options, ...rest) { const config = typeof options === 'string' ? { url: options } : { ...(options || {}) }; const url = String(config.url || ''); const data = String(config.data || ''); const isSubmit = /\\/System\\/DAT\\/orders\\.asp/i.test(url) && data.startsWith('Act=DoInfoJH&'); if (!isSubmit) return originalAjaxForSubmit.call(this, options, ...rest); const params = new URLSearchParams(data.slice('Act=DoInfoJH&'.length)); intercepted.push({ url_redacted: '/System/DAT/orders.asp', method: config.type || config.method || 'GET', dataType: config.dataType || '', prevented_from_network: true, payload: data, payload_summary: { starts_with_DoInfoJH: true, byte_length: new Blob([data]).size, field_count: Array.from(params.keys()).length, field_names: Array.from(new Set(Array.from(params.keys()))).sort(), value_redacted: true } }); return { readyState: 4, status: 0, statusText: 'intercepted_by_template_selftest', abort() {} }; }; let thrown = ''; try { orderWin.SubmitInfoForm(); } catch (err) { thrown = err.message || String(err); } await sleep(1000); $.ajax = originalAjaxForSubmit; orderWin.alert = originalAlert; if ($.dialog && typeof originalDialogAlert === 'function') $.dialog.alert = originalDialogAlert; for (const item of intercepted) { item.payload_sha256 = await sha256(item.payload || ''); item.payload_redacted = true; delete item.payload; } submitIntercept = { status: intercepted.length === 1 && !alerts.length && !thrown ? 'submit_intercept_captured' : 'submit_intercept_blocked', intercepted_submit_count: intercepted.length, intercepted_submits: intercepted, alerts, thrown_redacted: Boolean(thrown), thrown_length: String(thrown || '').length, DoInfoJH_network_prevented: true }; if (submitIntercept.status !== 'submit_intercept_captured') blockers.push('submit intercept did not capture exactly one clean DoInfoJH branch'); } return { ok: true, status: blockers.length ? 'template_selftest_blocked' : 'template_selftest_passed', page: { url: doc.location.href, title: doc.title, form_element_count: form.elements.length }, selected_product: { value_redacted: true, row_index: product?.rowIndex ?? null, column_count: product?.columns?.length || 0 }, lookup_summaries: { product: summarizeText(productList.text), route: summarizeText(routeList.text), customer: summarizeText(customerList.text), staff: summarizeText(staffList.text) }, exact_checks: exactChecks || [], product_side_effect: { ajax_records: ajaxRecords, changed_field_count: productChanges.length, changed_field_groups: groupChangedFields(productChanges), changed_field_names: productChanges.map((change) => change.name) }, core_after_product: Object.fromEntries(Object.entries(core).map(([key, value]) => [key, { nonempty: Boolean(String(value || '').trim()), value_length: String(value || '').length, value_redacted: true }])), test_markers: { suffix_marker: testMarker, note_marker: testMarker, values_are_test_only: true }, set_summary: { set_field_count: setResults.length, set_field_names: Array.from(new Set(setResults.map((item) => item.name))).sort(), set_results: setResults }, final_form: { serialized_field_count: Array.from(serializedParams.keys()).length, serialized_form_sha256: await sha256('Act=DoInfoJH&' + serializedForm), serialized_form_redacted: true, required_missing: requiredMissing }, submit_intercept: submitIntercept, blockers, warnings, submit_safety: { live_submit_attempted: false, DoInfoJH_network_prevented: Boolean(submitIntercept?.DoInfoJH_network_prevented), note: 'This self-test uses existing LTJT option values only in browser memory. It never lets DoInfoJH reach the network.' } }; })()`; } async function main() { const args = parseArgs(process.argv.slice(2)); if (args.help) { console.log(usage()); return; } const target = await getTarget(args.port || DEFAULT_PORT); const cdp = new CDP(target.webSocketDebuggerUrl); await cdp.connect(); try { await cdp.send('Runtime.enable'); const output = await evaluate(cdp, selfTestExpression({ openForm: args['open-form'], productIndex: args['product-index'] || 0, departureDate: args['departure-date'] || '2026-8-1', interceptSubmit: args['intercept-submit'] }), 120000); output.generated_at = new Date().toISOString(); if (args.out) writeJson(args.out, output); if (args['approved-preflight-out']) writeJson(args['approved-preflight-out'], buildCompatiblePreflightReport(output)); if (args['approved-intercept-out']) writeJson(args['approved-intercept-out'], buildCompatibleInterceptReport(output)); console.log(JSON.stringify({ status: output.status, blockers: output.blockers?.length || 0, product_changed_fields: output.product_side_effect?.changed_field_count || 0, product_changed_groups: output.product_side_effect?.changed_field_groups || {}, exact_checks: output.exact_checks || [], serialized_field_count: output.final_form?.serialized_field_count || 0, submit_intercept_status: output.submit_intercept?.status || '', intercepted_submit_count: output.submit_intercept?.intercepted_submit_count || 0, approved_preflight_out: args['approved-preflight-out'] || '', approved_intercept_out: args['approved-intercept-out'] || '', out: args.out || '' }, null, 2)); if (output.blockers?.length) process.exit(2); } finally { cdp.close(); } } main().catch((error) => { console.error(error.stack || error.message); process.exit(1); });