Files
LWLT-AI/tools/browser_order_add_template_selftest.mjs
2026-07-13 19:57:46 +08:00

669 lines
30 KiB
JavaScript

#!/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 <number> Chrome DevTools port. Default: ${DEFAULT_PORT}`,
' --open-form Navigate the LTJT main iframe to a fresh orders_add.asp form first.',
' --product-index <n> Existing product option index to use. Default: 0.',
' --departure-date <date> Test departure date. Default: 2026-8-1.',
' --intercept-submit Also call SubmitInfoForm() with DoInfoJH AJAX intercepted.',
' --out <path> Write redacted self-test report.',
' --approved-preflight-out <path> Write a browser_preflight_passed-compatible redacted report.',
' --approved-intercept-out <path> 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('老挝联泰') + '&copy=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);
});