From e22eceb59536b161108c6f2660cc39b9d8011ca3 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:20:51 -0800 Subject: [PATCH 1/5] Add a dev-only tgui test harness behind UI_HARNESS Builds compiled with -DUI_HARNESS let local tools click, type, press keys, scroll and read a client's tgui windows through world/Topic from 127.0.0.1. The page fires real DOM events so interfaces' own handlers run, and replies in chunks. Normal builds compile none of the server side, and the page side stays idle unless a harness build enables it. --- code/_compile_options.dm | 5 + tgstation.dme | 1 + tgui/packages/tgui/harness.ts | 388 ++++++++++++++++++++++ tgui/packages/tgui/index.tsx | 3 + voidcrew/modules/ui_harness/ui_harness.dm | 172 ++++++++++ 5 files changed, 569 insertions(+) create mode 100644 tgui/packages/tgui/harness.ts create mode 100644 voidcrew/modules/ui_harness/ui_harness.dm diff --git a/code/_compile_options.dm b/code/_compile_options.dm index 507ff81e786..4f6ec28270b 100644 --- a/code/_compile_options.dm +++ b/code/_compile_options.dm @@ -88,6 +88,11 @@ // If this is uncommented, we do a single run though of the game setup and tear down process with unit tests in between //#define UNIT_TESTS +// VOIDCREW EDIT ADDITION START - dev-only tgui test harness (voidcrew/modules/ui_harness). Lets local tools click a +// client's tgui windows through world/Topic from 127.0.0.1. Never define this on a live server. +//#define UI_HARNESS +// VOIDCREW EDIT ADDITION END + // If this is uncommented, will attempt to load and initialize prof.dll/libprof.so by default. // Even if it's not defined, you can pass "tracy" via -params in order to try to load it. // We do not ship byond-tracy. Build it yourself here: https://github.com/mafemergency/byond-tracy, diff --git a/tgstation.dme b/tgstation.dme index f46d8206dc0..03530399eea 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7721,6 +7721,7 @@ #include "voidcrew\modules\transporter\transporter_designs.dm" #include "voidcrew\modules\transporter\transporter_pad.dm" #include "voidcrew\modules\transporter\transporter_transponder.dm" +#include "voidcrew\modules\ui_harness\ui_harness.dm" #if defined(UNIT_TESTS) || defined(SPACEMAN_DMM) #endif #include "voidcrew\modules\vehicles\atv.dm" diff --git a/tgui/packages/tgui/harness.ts b/tgui/packages/tgui/harness.ts new file mode 100644 index 00000000000..b6c9f3f0a7b --- /dev/null +++ b/tgui/packages/tgui/harness.ts @@ -0,0 +1,388 @@ +/** + * UI test harness, page side (dev builds only). + * + * Servers built with -DUI_HARNESS (voidcrew/modules/ui_harness) can send this window + * "harness/run" messages so local tools can click, type and read the page without + * the real mouse or keyboard. It fires ordinary DOM events, so the interface's own + * handlers run, and answers with a "harness/result" message. + * + * It does nothing until the server sends "harness/enable", which only harness + * builds ever do. + */ + +type Store = { getState: () => any }; +type Args = Record; + +const MAX_TEXT = 4000; +const MAX_SETTLE_MS = 5000; + +// Topic links get cut off past ~2 KB, so replies go back in pieces like tgui's own oversized payloads. +const CHUNK_BUDGET = 900; + +let enabled = false; + +const sendChunked = (id: string, json: string) => { + const chunks: string[] = []; + let current = ''; + let cost = 0; + for (const char of json) { + const charCost = encodeURIComponent(JSON.stringify(char)).length - 6; + if (cost + charCost > CHUNK_BUDGET && current) { + chunks.push(current); + current = ''; + cost = 0; + } + current += char; + cost += charCost; + } + chunks.push(current); + chunks.forEach((chunk, part) => + Byond.sendMessage('harness/result', { + id, + part: part + 1, + parts: chunks.length, + chunk, + }), + ); +}; + +const KEY_CODES: Record = { + Backspace: 8, + Tab: 9, + Enter: 13, + Shift: 16, + Control: 17, + Alt: 18, + Escape: 27, + ' ': 32, + PageUp: 33, + PageDown: 34, + End: 35, + Home: 36, + ArrowLeft: 37, + ArrowUp: 38, + ArrowRight: 39, + ArrowDown: 40, + Delete: 46, +}; + +const squash = (text: string | null | undefined) => + (text || '').replace(/\s+/g, ' ').trim(); + +const isVisible = (el: Element) => { + const rect = el.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) { + return false; + } + const style = window.getComputedStyle(el); + return style.visibility !== 'hidden' && style.display !== 'none'; +}; + +const describe = (el: Element | null) => { + if (!el) { + return null; + } + const rect = el.getBoundingClientRect(); + return { + tag: el.tagName.toLowerCase(), + id: el.id || undefined, + className: String(el.className || '').slice(0, 120) || undefined, + text: squash((el as HTMLElement).innerText).slice(0, 120), + value: (el as HTMLInputElement).value, + rect: [rect.left, rect.top, rect.width, rect.height].map(Math.round), + }; +}; + +/** Visible elements whose text matches, innermost only, in document order. */ +const findByText = (text: string, exact: boolean) => { + const want = squash(text).toLowerCase(); + const matches = (el: Element) => { + const have = squash((el as HTMLElement).innerText).toLowerCase(); + return exact ? have === want : have.includes(want); + }; + const hits = Array.from(document.body.querySelectorAll('*')).filter( + (el) => isVisible(el) && matches(el), + ); + return hits.filter((el) => !hits.some((o) => o !== el && el.contains(o))); +}; + +/** Resolves the element a command acts on, from selector, text or point. */ +const findTarget = (args: Args): Element | null => { + const nth = Number(args.nth || 0); + if (args.x !== undefined && args.y !== undefined) { + return document.elementFromPoint(Number(args.x), Number(args.y)); + } + let list: Element[] = []; + if (args.selector) { + list = Array.from(document.querySelectorAll(args.selector)).filter( + isVisible, + ); + } else if (args.text !== undefined) { + list = findByText(args.text, args.exact !== '0'); + } else if (args.placeholder) { + list = Array.from( + document.querySelectorAll( + `[placeholder*="${CSS.escape(args.placeholder)}"]`, + ), + ).filter(isVisible); + } else { + return null; + } + return list[nth] || null; +}; + +const centerOf = (el: Element) => { + const rect = el.getBoundingClientRect(); + return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; +}; + +const mouse = ( + target: Element, + type: string, + x: number, + y: number, + buttons: number, +) => { + const init = { + bubbles: true, + cancelable: true, + composed: true, + view: window, + clientX: x, + clientY: y, + button: 0, + buttons, + }; + const event = type.startsWith('pointer') + ? new PointerEvent(type, { + ...init, + pointerId: 1, + pointerType: 'mouse', + isPrimary: true, + }) + : new MouseEvent(type, init); + target.dispatchEvent(event); +}; + +/** Clicks the topmost element at the target's centre, as a real click would. */ +const click = (args: Args) => { + const found = findTarget(args); + if (!found) { + throw new Error('nothing matched'); + } + found.scrollIntoView?.({ block: 'nearest' }); + const point = + args.x !== undefined + ? { x: Number(args.x), y: Number(args.y) } + : centerOf(found); + const hit = document.elementFromPoint(point.x, point.y) || found; + mouse(hit, 'pointerover', point.x, point.y, 0); + mouse(hit, 'mouseover', point.x, point.y, 0); + mouse(hit, 'pointerdown', point.x, point.y, 1); + mouse(hit, 'mousedown', point.x, point.y, 1); + if (hit instanceof HTMLElement) { + hit.focus({ preventScroll: true }); + } + mouse(hit, 'pointerup', point.x, point.y, 0); + mouse(hit, 'mouseup', point.x, point.y, 0); + mouse(hit, 'click', point.x, point.y, 0); + if (args.double === '1') { + mouse(hit, 'dblclick', point.x, point.y, 0); + } + return { matched: describe(found), hit: describe(hit) }; +}; + +const keyCodeOf = (key: string) => + KEY_CODES[key] ?? (key.length === 1 ? key.toUpperCase().charCodeAt(0) : 0); + +const pressKey = (target: Element, key: string) => { + const keyCode = keyCodeOf(key); + const init = { + key, + code: key.length === 1 ? `Key${key.toUpperCase()}` : key, + keyCode, + which: keyCode, + bubbles: true, + cancelable: true, + composed: true, + view: window, + }; + target.dispatchEvent(new KeyboardEvent('keydown', init)); + if (key.length === 1) { + target.dispatchEvent(new KeyboardEvent('keypress', init)); + } + target.dispatchEvent(new KeyboardEvent('keyup', init)); +}; + +const key = (args: Args) => { + if (!args.key) { + throw new Error('key= is required'); + } + const target = + (args.selector || args.text !== undefined || args.placeholder + ? findTarget(args) + : document.activeElement) || document.body; + pressKey(target, args.key); + return { hit: describe(target) }; +}; + +/** Sets an input's value the way typing does, so React's onChange/onInput run. */ +const type = (args: Args) => { + const target = ( + args.selector || args.text !== undefined || args.placeholder + ? findTarget(args) + : document.activeElement + ) as HTMLInputElement | HTMLTextAreaElement | null; + if ( + !( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement + ) + ) { + throw new Error(`not a text field: ${JSON.stringify(describe(target))}`); + } + target.focus({ preventScroll: true }); + const text = args.value ?? ''; + const next = args.append === '1' ? target.value + text : text; + const setter = Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(target), + 'value', + )?.set; + setter?.call(target, next); + target.dispatchEvent( + new InputEvent('input', { + bubbles: true, + data: text, + inputType: 'insertText', + }), + ); + target.dispatchEvent(new Event('change', { bubbles: true })); + if (args.enter === '1') { + pressKey(target, 'Enter'); + } + if (args.blur === '1') { + target.blur(); + } + // A background window gets no real focus events, so inputs that commit on blur would never commit. + if ((args.enter === '1' || args.blur === '1') && !document.hasFocus()) { + target.dispatchEvent(new FocusEvent('blur')); + target.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + } + return { hit: describe(target) }; +}; + +const canScroll = (el: Element) => + el.scrollHeight > el.clientHeight + 1 && + /auto|scroll/.test(window.getComputedStyle(el).overflowY); + +/** Scrolls the nearest scrollable box around the target, or the biggest one on the page. */ +const scroll = (args: Args) => { + let el: Element | null = findTarget(args); + while (el && !canScroll(el)) { + el = el.parentElement; + } + if (!el) { + const boxes = Array.from(document.body.querySelectorAll('*')).filter( + (box) => isVisible(box) && canScroll(box), + ); + const area = (box: Element) => box.clientWidth * box.clientHeight; + el = boxes.sort((a, b) => area(b) - area(a))[0] || null; + } + if (!el) { + throw new Error('nothing scrollable'); + } + if (args.to === 'top') { + el.scrollTop = 0; + } else if (args.to === 'bottom') { + el.scrollTop = el.scrollHeight; + } else { + el.scrollTop += Number(args.by || 300); + } + el.dispatchEvent(new Event('scroll', { bubbles: true })); + return { hit: describe(el), scrollTop: el.scrollTop }; +}; + +const summary = (store: Store, args: Args, full: boolean) => { + const config = store.getState()?.backend?.config || {}; + const limit = Math.min(Number(args.limit || 1500), MAX_TEXT); + const out: Record = { + windowId: Byond.windowId, + interface: config.interface?.name, + title: config.title, + activeTabs: Array.from(document.querySelectorAll('.Tab--selected')) + .filter(isVisible) + .map((el) => squash((el as HTMLElement).innerText)), + focused: describe(document.activeElement), + text: squash(document.body.innerText).slice(0, limit), + }; + if (full) { + out.url = window.location.href; + out.size = [window.innerWidth, window.innerHeight]; + out.buttons = Array.from(document.querySelectorAll('.Button')) + .filter(isVisible) + .map((el) => squash((el as HTMLElement).innerText)) + .filter((text) => text) + .slice(0, 80); + out.inputs = Array.from(document.querySelectorAll('input, textarea')) + .filter(isVisible) + .map(describe) + .slice(0, 20); + } + return out; +}; + +const ACTIONS: Record unknown> = { + click, + key, + type, + scroll, + query: () => ({}), +}; + +const run = (store: Store, payload: any) => { + const id = payload?.id; + const op = String(payload?.op || ''); + const args: Args = payload?.args || {}; + const reply = (body: Record) => + sendChunked(id, JSON.stringify({ id, op, ...body })); + const action = ACTIONS[op]; + if (!action) { + reply({ ok: false, error: `unknown op ${op}` }); + return; + } + let result: unknown; + try { + result = action(args); + } catch (err) { + reply({ + ok: false, + error: String((err as Error)?.message || err), + page: summary(store, args, true), + }); + return; + } + // Give the interface time to re-render (and the server time to answer an act) before reading it back. + const settle = Math.min( + Number(args.settle ?? (op === 'query' ? 0 : 400)), + MAX_SETTLE_MS, + ); + setTimeout(() => { + try { + reply({ ok: true, result, page: summary(store, args, op === 'query') }); + } catch (err) { + reply({ ok: false, error: String((err as Error)?.message || err) }); + } + }, settle); +}; + +export const setupUiHarness = (store: Store) => { + Byond.subscribe((type, payload) => { + if (type === 'harness/enable') { + enabled = true; + return; + } + if (type === 'harness/run' && enabled) { + run(store, payload); + } + }); +}; diff --git a/tgui/packages/tgui/index.tsx b/tgui/packages/tgui/index.tsx index 7e98ffa7a55..cdb5893b5f9 100644 --- a/tgui/packages/tgui/index.tsx +++ b/tgui/packages/tgui/index.tsx @@ -14,6 +14,7 @@ import { setupHotReloading } from 'tgui-dev-server/link/client'; import { App } from './App'; import { setGlobalStore } from './backend'; +import { setupUiHarness } from './harness'; // VOIDCREW EDIT ADDITION - ui_harness dev test harness import { captureExternalLinks } from './links'; import { render } from './renderer'; import { configureStore } from './store'; @@ -45,6 +46,8 @@ function setupApp() { // Dispatch incoming messages as store actions Byond.subscribe((type, payload) => store.dispatch({ type, payload })); + // VOIDCREW EDIT ADDITION - lets -DUI_HARNESS dev builds drive this window (voidcrew/modules/ui_harness) + setupUiHarness(store); // Enable hot module reloading if (import.meta.webpackHot) { diff --git a/voidcrew/modules/ui_harness/ui_harness.dm b/voidcrew/modules/ui_harness/ui_harness.dm new file mode 100644 index 00000000000..3729ad73c78 --- /dev/null +++ b/voidcrew/modules/ui_harness/ui_harness.dm @@ -0,0 +1,172 @@ +/** + * UI test harness (dev builds only). + * + * Lets a local tool drive a connected client's tgui windows (click, type, press keys, read the page) + * without touching the real mouse or keyboard. The tool talks to world/Topic over TCP from 127.0.0.1; + * this file forwards each command to the page as a tgui message, and the page module + * (tgui/packages/tgui/harness.ts) fires real DOM events and replies with "harness/result". + * + * None of this exists unless the world is compiled with -DUI_HARNESS. Never build a live server with it. + */ +#ifdef UI_HARNESS + +#warn compiling with UI_HARNESS: local tools can drive clients' tgui windows. Never run this build on a live server. + +/// Longest a single harness command waits for the page to answer, in deciseconds. +#define UI_HARNESS_MAX_WAIT (15 SECONDS) +/// Default wait for the page to answer, in deciseconds. +#define UI_HARNESS_DEFAULT_WAIT (8 SECONDS) + +/// Most pieces one page reply may come in (about 1 KB each). +#define UI_HARNESS_MAX_PARTS 40 + +/// Command id -> REF() of the window it was sent to, or the page's reply list once it arrives. +GLOBAL_LIST_EMPTY(ui_harness_pending) +/// Command id -> the reply pieces received so far. +GLOBAL_LIST_EMPTY(ui_harness_chunks) +GLOBAL_VAR_INIT(ui_harness_next_id, 0) + +/world/Topic(T, addr, master, key) + var/list/input = params2list(T) + if(!("ui_harness" in input)) + return ..() + if(!ui_harness_is_local(addr)) + log_topic("ui_harness refused from [addr]") + return json_encode(list("error" = "local connections only")) + return json_encode(ui_harness_command(input)) + +/// TRUE if [addr] is the loopback address (BYOND may append a port). +/proc/ui_harness_is_local(addr) + if(!istext(addr)) + return FALSE + var/list/parts = splittext(addr, ":") + return parts[1] == "127.0.0.1" + +/// Runs one harness command and returns a list to hand back as JSON. +/proc/ui_harness_command(list/input) + var/op = input["ui_harness"] + if(op == "list") + return list("ok" = TRUE, "clients" = ui_harness_list_clients()) + + var/client/target_client = ui_harness_find_client(input["ckey"]) + if(!target_client) + return list("error" = "no such client (pass ckey= when several are connected)") + + switch(op) + if("open") + return ui_harness_open(target_client, input["what"]) + if("click", "type", "key", "query", "scroll") + var/datum/tgui_window/window = ui_harness_find_window(target_client, input["window"], input["interface"]) + if(!window) + return list("error" = "no such tgui window (pass window= or interface=)", "windows" = ui_harness_list_windows(target_client)) + var/wait = clamp(text2num(input["timeout"]) * 10 || UI_HARNESS_DEFAULT_WAIT, 1 SECONDS, UI_HARNESS_MAX_WAIT) + var/list/args_out = input.Copy() + args_out -= list("ui_harness", "ckey", "window", "interface", "timeout") + return ui_harness_send(window, op, args_out, wait) + return list("error" = "unknown command [op]") + +/proc/ui_harness_list_clients() + var/list/out = list() + for(var/client/client as anything in GLOB.clients) + out += list(list( + "ckey" = client.ckey, + "mob" = "[client.mob?.type]", + "windows" = ui_harness_list_windows(client), + )) + return out + +/proc/ui_harness_list_windows(client/client) + var/list/out = list() + for(var/window_id in client.tgui_windows) + var/datum/tgui_window/window = client.tgui_windows[window_id] + out += list(list( + "id" = window.id, + "status" = window.status, + "visible" = window.visible, + "is_browser" = window.is_browser, + "interface" = window.locked_by?.interface, + "title" = window.locked_by?.title, + )) + return out + +/proc/ui_harness_find_client(wanted_ckey) + if(wanted_ckey) + return GLOB.directory[ckey(wanted_ckey)] + if(length(GLOB.clients) == 1) + return GLOB.clients[1] + return null + +/proc/ui_harness_find_window(client/client, window_id, interface_name) + if(window_id) + return client.tgui_windows[window_id] + if(!interface_name) + return null + for(var/id in client.tgui_windows) + var/datum/tgui_window/window = client.tgui_windows[id] + if(window.status == TGUI_WINDOW_READY && window.locked_by?.interface == interface_name) + return window + return null + +/// Opens one of a short list of UIs a lobby client can't otherwise reach without a mouse. +/proc/ui_harness_open(client/client, what) + switch(what) + if("preferences") + client.prefs.ui_interact(client.mob) + return list("ok" = TRUE, "opened" = what) + return list("error" = "unknown UI [what] (known: preferences)") + +/// Sends [op] to [window]'s page and waits up to [wait] for its reply. +/proc/ui_harness_send(datum/tgui_window/window, op, list/op_args, wait) + var/id = "[++GLOB.ui_harness_next_id]" + GLOB.ui_harness_pending[id] = REF(window) + window.send_message("harness/enable") + window.send_message("harness/run", list("id" = id, "op" = op, "args" = op_args)) + var/give_up = REALTIMEOFDAY + wait + while(REALTIMEOFDAY < give_up && !islist(GLOB.ui_harness_pending[id])) + sleep(1) + var/reply = GLOB.ui_harness_pending[id] + GLOB.ui_harness_pending -= id + GLOB.ui_harness_chunks -= id + if(!islist(reply)) + return list("error" = "no reply from the page within [wait / 10] s", "window" = window.id) + var/list/result = reply + result["window"] = window.id + return result + +/datum/tgui_window/on_message(type, payload, href_list) + if(type != "harness/result") + return ..() + // Only replies to a command this world sent to this exact window are kept. + if(!islist(payload)) + return + var/id = "[payload["id"]]" + if(GLOB.ui_harness_pending[id] != REF(src)) + return + // The page sends its reply as JSON in numbered pieces, since topic links have a length limit. + var/parts = text2num(payload["parts"]) + var/part = text2num(payload["part"]) + if(!isnum(parts) || parts < 1 || parts > UI_HARNESS_MAX_PARTS || !isnum(part) || part < 1 || part > parts) + return + var/list/chunks = GLOB.ui_harness_chunks[id] + if(!chunks) + chunks = new /list(parts) + GLOB.ui_harness_chunks[id] = chunks + if(length(chunks) != parts) + return + chunks[part] = "[payload["chunk"]]" + for(var/chunk in chunks) + if(isnull(chunk)) + return + GLOB.ui_harness_chunks -= id + var/json = jointext(chunks, "") + if(!rustg_json_is_valid(json)) + GLOB.ui_harness_pending[id] = list("error" = "the page's reply was not valid JSON") + return + var/list/reply = json_decode(json) + GLOB.ui_harness_pending[id] = islist(reply) ? reply : list("error" = "the page's reply was not an object") + +#undef UI_HARNESS_MAX_WAIT +#undef UI_HARNESS_MAX_PARTS +#undef UI_HARNESS_DEFAULT_WAIT + +#endif From 40b23dbd82bf03079948c929198cb19855eee092 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:56:53 -0800 Subject: [PATCH 2/5] Add a layout command to the UI harness that finds overflowing, cut and overlapping text --- tgui/packages/tgui/harness.ts | 437 +++++++++++++++++++++- voidcrew/modules/ui_harness/ui_harness.dm | 2 +- 2 files changed, 437 insertions(+), 2 deletions(-) diff --git a/tgui/packages/tgui/harness.ts b/tgui/packages/tgui/harness.ts index b6c9f3f0a7b..e4c1e0275d8 100644 --- a/tgui/packages/tgui/harness.ts +++ b/tgui/packages/tgui/harness.ts @@ -302,6 +302,440 @@ const scroll = (args: Args) => { return { hit: describe(el), scrollTop: el.scrollTop }; }; +// --------------------------------------------------------------------------------------------- +// layout: finds text that runs out of its box, crosses a frame line, is cut off, or overlaps other +// text. It works on glyph boxes (a Range over each text node, whitespace trimmed), so absolutely +// placed text grids (the lobby terminal) and ordinary flow layouts are checked the same way. +// --------------------------------------------------------------------------------------------- + +type Box = { l: number; t: number; r: number; b: number }; + +const MAX_FINDINGS = 60; + +const boxOf = (r: DOMRect): Box => ({ + l: r.left, + t: r.top, + r: r.right, + b: r.bottom, +}); + +const rectOf = (b: Box) => + [b.l, b.t, b.r - b.l, b.b - b.t].map((n) => Math.round(n)); + +const clipTo = (a: Box, c: Box): Box | null => { + const out = { + l: Math.max(a.l, c.l), + t: Math.max(a.t, c.t), + r: Math.min(a.r, c.r), + b: Math.min(a.b, c.b), + }; + return out.r > out.l && out.b > out.t ? out : null; +}; + +/** How far [a] pokes out of [c] on its worst side, in px (0 if inside). */ +const pokesOut = (a: Box, c: Box) => + Math.max(c.l - a.l, a.r - c.r, c.t - a.t, a.b - c.b, 0); + +/** The smaller of the two overlap extents (<= 0 when they don't overlap). */ +const overlapBy = (a: Box, b: Box) => + Math.min( + Math.min(a.r, b.r) - Math.max(a.l, b.l), + Math.min(a.b, b.b) - Math.max(a.t, b.t), + ); + +const alphaOf = (color: string) => { + if (!color || color === 'transparent') { + return 0; + } + const m = color.match(/rgba?\(([^)]+)\)/); + if (!m) { + return 1; + } + const parts = m[1].split(/[,\s/]+/).filter((p) => p); + return parts.length > 3 ? Number(parts[3]) : 1; +}; + +const pathOf = (el: Element | null) => { + const parts: string[] = []; + let e = el; + while (e && e !== document.body && parts.length < 5) { + let part = e.tagName.toLowerCase(); + if (e.id) { + parts.unshift(`${part}#${e.id}`); + break; + } + const cls = Array.from(e.classList).slice(0, 2); + if (cls.length) { + part += `.${cls.join('.')}`; + } + const parent: Element | null = e.parentElement; + if (parent) { + part += `:nth-child(${Array.from(parent.children).indexOf(e) + 1})`; + } + parts.unshift(part); + e = parent; + } + return parts.join('>'); +}; + +/** ignore= is a JSON list of selectors, or selectors joined with "||". */ +const parseIgnore = (raw: string | undefined): string[] => { + if (!raw) { + return []; + } + if (raw.trim().startsWith('[')) { + const list = JSON.parse(raw); + return Array.isArray(list) ? list.map(String) : []; + } + return raw + .split('||') + .map((s) => s.trim()) + .filter((s) => s); +}; + +type TextItem = { + el: Element; + text: string; + /** Visible part of each line's glyph box. */ + boxes: Box[]; +}; + +type Finding = { + kind: string; + text: string; + rect: number[]; + path: string; + by: number; + other?: { text?: string; rect: number[]; path: string }; +}; + +const SIDES = ['top', 'right', 'bottom', 'left']; + +const layout = (args: Args) => { + const tol = Math.max(0, Number(args.tolerance ?? 1)); + // Share of the font size a line's ink fills; the rest of a Range box is leading. + const ink = Math.min(Math.max(Number(args.ink ?? 0.9), 0.3), 1.5); + const max = Math.min(Number(args.max || 40), MAX_FINDINGS); + const ignore = parseIgnore(args.ignore); + const root = + (args.root ? document.querySelector(args.root) : null) || document.body; + const viewport: Box = { + l: 0, + t: 0, + r: window.innerWidth, + b: window.innerHeight, + }; + + const styles = new Map(); + const styleOf = (el: Element) => { + let s = styles.get(el); + if (!s) { + s = window.getComputedStyle(el); + styles.set(el, s); + } + return s; + }; + const hiddenCache = new Map(); + const isHidden = (el: Element | null): boolean => { + if (!el || el === document.documentElement) { + return false; + } + const known = hiddenCache.get(el); + if (known !== undefined) { + return known; + } + const s = styleOf(el); + const out = + s.display === 'none' || + s.visibility === 'hidden' || + Number(s.opacity) < 0.05 || + isHidden(el.parentElement); + hiddenCache.set(el, out); + return out; + }; + const ignored = (el: Element) => + ignore.some((sel) => { + try { + return !!el.closest(sel); + } catch { + return false; + } + }); + const clips = (s: CSSStyleDeclaration) => + s.overflowX !== 'visible' || s.overflowY !== 'visible'; + const scrolls = (s: CSSStyleDeclaration) => + /auto|scroll/.test(s.overflowX) || /auto|scroll/.test(s.overflowY); + const paddingBox = (el: Element): Box => { + const r = el.getBoundingClientRect(); + const l = r.left + el.clientLeft; + const t = r.top + el.clientTop; + return { l, t, r: l + el.clientWidth, b: t + el.clientHeight }; + }; + const isBlock = (s: CSSStyleDeclaration) => + s.display !== 'inline' && s.display !== 'contents'; + const hasBackground = (el: Element) => + alphaOf(styleOf(el).backgroundColor) >= 0.5; + const borderWidth = (s: CSSStyleDeclaration, side: string) => + !/none|hidden/.test(s.getPropertyValue(`border-${side}-style`)) && + alphaOf(s.getPropertyValue(`border-${side}-color`)) > 0.1 + ? parseFloat(s.getPropertyValue(`border-${side}-width`)) || 0 + : 0; + const hasBorder = (s: CSSStyleDeclaration) => + SIDES.some((side) => borderWidth(s, side) >= 1); + + const findings: Finding[] = []; + let total = 0; + const seen = new Set(); + const add = (key: string, f: Finding) => { + if (seen.has(key)) { + return; + } + seen.add(key); + total++; + if (findings.length < max) { + findings.push(f); + } + }; + const short = (text: string) => + text.length > 60 ? `${text.slice(0, 59)}~` : text; + + // 1. Every visible text node, as one glyph box per line, checked against the boxes holding it. + const items: TextItem[] = []; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + const range = document.createRange(); + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + const raw = node.nodeValue || ''; + const start = raw.search(/\S/); + if (start < 0) { + continue; + } + const el = node.parentElement; + if ( + !el || + /^(SCRIPT|STYLE|NOSCRIPT|TEXTAREA|OPTION|TITLE)$/.test(el.tagName) || + isHidden(el) || + ignored(el) + ) { + continue; + } + range.setStart(node, start); + range.setEnd(node, raw.trimEnd().length); + const fontSize = parseFloat(styleOf(el).fontSize) || 12; + const text = squash(raw); + const path = pathOf(el); + const boxes: Box[] = []; + for (const r of Array.from(range.getClientRects())) { + if (r.width < 0.5 || r.height < 0.5) { + continue; + } + const inset = Math.max(0, (r.height - fontSize * ink) / 2); + let line: Box | null = { + l: r.left, + t: r.top + inset, + r: r.right, + b: r.bottom - inset, + }; + let ownBox = false; + for (let a: Element | null = el; a && line; a = a.parentElement) { + if (a === document.documentElement || a === document.body) { + break; + } + const s = styleOf(a); + if (clips(s)) { + const pad = paddingBox(a); + const by = pokesOut(line, pad); + const visible = clipTo(line, pad); + // Cut with an ellipsis is a bounded cut; scrolled out of view is not a cut. + if ( + by > tol && + visible && + !scrolls(s) && + s.textOverflow !== 'ellipsis' + ) { + add(`clip:${path}:${Math.round(line.t)}`, { + kind: 'clipped', + text: short(text), + rect: rectOf(line), + path, + by: Math.round(by), + other: { rect: rectOf(pad), path: pathOf(a) }, + }); + } + line = visible; + ownBox = true; + continue; + } + if (!isBlock(s)) { + continue; + } + // The first block box is the text's own. Past it, only boxes you can see (a border or a + // background) count: an invisible layout box overflowing harms nothing by itself. + const own = !ownBox; + ownBox = true; + if (!(own || hasBorder(s) || hasBackground(a))) { + continue; + } + const box = boxOf(a.getBoundingClientRect()); + const by = pokesOut(line, box); + if (by > tol) { + add(`past:${path}:${pathOf(a)}`, { + kind: 'past_box', + text: short(text), + rect: rectOf(line), + path, + by: Math.round(by), + other: { rect: rectOf(box), path: pathOf(a) }, + }); + } + } + if (!line) { + continue; + } + const by = pokesOut(line, viewport); + const visible = clipTo(line, viewport); + if (by > tol && visible) { + add(`off:${path}:${Math.round(line.t)}`, { + kind: 'offscreen', + text: short(text), + rect: rectOf(line), + path, + by: Math.round(by), + }); + } + if (visible) { + boxes.push(visible); + } + } + if (boxes.length) { + items.push({ el, text, boxes }); + } + } + + // 2. Frame lines: every visible border side, as a strip. Text may not cross one it isn't inside. + type Strip = { el: Element; box: Box; across: boolean }; + const strips: Strip[] = []; + for (const el of Array.from(root.querySelectorAll('*'))) { + if (isHidden(el) || ignored(el)) { + continue; + } + const s = styleOf(el); + if (!hasBorder(s)) { + continue; + } + const r = el.getBoundingClientRect(); + if (r.width <= 0 || r.height <= 0) { + continue; + } + const [top, right, bottom, left] = SIDES.map((side) => + borderWidth(s, side), + ); + const push = (box: Box, across: boolean) => { + if (box.r - box.l >= 1 && box.b - box.t >= 1) { + strips.push({ el, box, across }); + } + }; + push({ l: r.left, t: r.top, r: r.right, b: r.top + top }, true); + push({ l: r.left, t: r.bottom - bottom, r: r.right, b: r.bottom }, true); + push({ l: r.left, t: r.top, r: r.left + left, b: r.bottom }, false); + push({ l: r.right - right, t: r.top, r: r.right, b: r.bottom }, false); + } + for (const item of items) { + // A label that paints its own background over a horizontal line is a legend (a frame title). + const legend = hasBackground(item.el); + const path = pathOf(item.el); + for (const strip of strips) { + if (strip.el.contains(item.el) || (legend && strip.across)) { + continue; + } + for (const line of item.boxes) { + const by = overlapBy(line, strip.box); + // A border can be thinner than the tolerance, so crossing most of its width is enough. + const thin = strip.across + ? strip.box.b - strip.box.t + : strip.box.r - strip.box.l; + if (by > Math.min(tol, thin - 0.5)) { + add(`line:${path}:${pathOf(strip.el)}`, { + kind: 'crosses_line', + text: short(item.text), + rect: rectOf(line), + path, + by: Math.round(by * 10) / 10, + other: { rect: rectOf(strip.box), path: pathOf(strip.el) }, + }); + break; + } + } + } + } + + // 3. Text over other text. + type Line = { item: TextItem; box: Box }; + const lines: Line[] = []; + for (const item of items) { + for (const box of item.boxes) { + lines.push({ item, box }); + } + } + lines.sort((a, b) => a.box.l - b.box.l); + for (let i = 0; i < lines.length; i++) { + const a = lines[i]; + for ( + let j = i + 1; + j < lines.length && lines[j].box.l < a.box.r - tol; + j++ + ) { + const b = lines[j]; + if ( + a.item === b.item || + a.item.el.contains(b.item.el) || + b.item.el.contains(a.item.el) + ) { + continue; + } + const by = overlapBy(a.box, b.box); + if (by <= tol) { + continue; + } + // The same text drawn twice in one place is a deliberate layer (a glow or a ghost copy). + if ( + a.item.text === b.item.text && + Math.abs(a.box.l - b.box.l) <= 2 && + Math.abs(a.box.t - b.box.t) <= 2 + ) { + continue; + } + const [first, second] = + a.box.t < b.box.t || (a.box.t === b.box.t && a.box.l <= b.box.l) + ? [a, b] + : [b, a]; + const p1 = pathOf(first.item.el); + const p2 = pathOf(second.item.el); + add(`over:${p1}:${p2}`, { + kind: 'overlap', + text: short(first.item.text), + rect: rectOf(first.box), + path: p1, + by: Math.round(by), + other: { + text: short(second.item.text), + rect: rectOf(second.box), + path: p2, + }, + }); + } + } + + return { + clean: total === 0, + total, + findings, + checked: { texts: items.length, lines: strips.length }, + tolerance: tol, + size: [window.innerWidth, window.innerHeight], + screen: [window.screenX, window.screenY, window.devicePixelRatio], + }; +}; + const summary = (store: Store, args: Args, full: boolean) => { const config = store.getState()?.backend?.config || {}; const limit = Math.min(Number(args.limit || 1500), MAX_TEXT); @@ -336,6 +770,7 @@ const ACTIONS: Record unknown> = { key, type, scroll, + layout, query: () => ({}), }; @@ -363,7 +798,7 @@ const run = (store: Store, payload: any) => { } // Give the interface time to re-render (and the server time to answer an act) before reading it back. const settle = Math.min( - Number(args.settle ?? (op === 'query' ? 0 : 400)), + Number(args.settle ?? (op === 'query' || op === 'layout' ? 0 : 400)), MAX_SETTLE_MS, ); setTimeout(() => { diff --git a/voidcrew/modules/ui_harness/ui_harness.dm b/voidcrew/modules/ui_harness/ui_harness.dm index 3729ad73c78..b1bbbfc980e 100644 --- a/voidcrew/modules/ui_harness/ui_harness.dm +++ b/voidcrew/modules/ui_harness/ui_harness.dm @@ -55,7 +55,7 @@ GLOBAL_VAR_INIT(ui_harness_next_id, 0) switch(op) if("open") return ui_harness_open(target_client, input["what"]) - if("click", "type", "key", "query", "scroll") + if("click", "type", "key", "query", "scroll", "layout") var/datum/tgui_window/window = ui_harness_find_window(target_client, input["window"], input["interface"]) if(!window) return list("error" = "no such tgui window (pass window= or interface=)", "windows" = ui_harness_list_windows(target_client)) From 06712dd98288e1ccc0a8c8f2ff38733004397e13 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 19:11:03 -0800 Subject: [PATCH 3/5] Skip image and thick borders in the layout check --- tgui/packages/tgui/harness.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tgui/packages/tgui/harness.ts b/tgui/packages/tgui/harness.ts index e4c1e0275d8..ff5b2bc3ead 100644 --- a/tgui/packages/tgui/harness.ts +++ b/tgui/packages/tgui/harness.ts @@ -416,6 +416,8 @@ const layout = (args: Args) => { // Share of the font size a line's ink fills; the rest of a Range box is leading. const ink = Math.min(Math.max(Number(args.ink ?? 0.9), 0.3), 1.5); const max = Math.min(Number(args.max || 40), MAX_FINDINGS); + // Borders thicker than this are a surround (a bezel, a padded panel edge), not a line. + const maxLine = Number(args.max_line ?? 12); const ignore = parseIgnore(args.ignore); const root = (args.root ? document.querySelector(args.root) : null) || document.body; @@ -619,16 +621,18 @@ const layout = (args: Args) => { continue; } const s = styleOf(el); - if (!hasBorder(s)) { + // Image borders are artwork (the lobby's bezel), drawn under the screen that sits in them. + if (!hasBorder(s) || s.getPropertyValue('border-image-source') !== 'none') { continue; } const r = el.getBoundingClientRect(); if (r.width <= 0 || r.height <= 0) { continue; } - const [top, right, bottom, left] = SIDES.map((side) => - borderWidth(s, side), - ); + const [top, right, bottom, left] = SIDES.map((side) => { + const width = borderWidth(s, side); + return width > maxLine ? 0 : width; + }); const push = (box: Box, across: boolean) => { if (box.r - box.l >= 1 && box.b - box.t >= 1) { strips.push({ el, box, across }); From 03deab5c1b5f9e61e1398b83648afb1a59783869 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 19:30:13 -0800 Subject: [PATCH 4/5] Skip text and lines hidden under opaque panels in the layout check --- tgui/packages/tgui/harness.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/tgui/packages/tgui/harness.ts b/tgui/packages/tgui/harness.ts index ff5b2bc3ead..9d4f302bb6b 100644 --- a/tgui/packages/tgui/harness.ts +++ b/tgui/packages/tgui/harness.ts @@ -485,6 +485,25 @@ const layout = (args: Args) => { const hasBorder = (s: CSSStyleDeclaration) => SIDES.some((side) => borderWidth(s, side) >= 1); + /** True when something opaque is drawn over [el] at [point]: the first opaque layer above it isn't [el]'s own. */ + const coveredAt = (el: Element, x: number, y: number) => { + for (const hit of document.elementsFromPoint(x, y)) { + if (hit === el || hit.contains(el) || el.contains(hit)) { + return false; + } + if (alphaOf(styleOf(hit).backgroundColor) >= 0.85) { + return true; + } + } + return false; + }; + /** A box is covered (a popup over it, say) when its middle row is covered at both ends and the centre. */ + const covered = (el: Element, box: Box) => { + const y = (box.t + box.b) / 2; + const xs = [box.l + 1, (box.l + box.r) / 2, box.r - 1]; + return xs.every((x) => coveredAt(el, x, y)); + }; + const findings: Finding[] = []; let total = 0; const seen = new Set(); @@ -537,6 +556,10 @@ const layout = (args: Args) => { r: r.right, b: r.bottom - inset, }; + // Text under a popup or another opaque panel can't be seen, so it can't collide either. + if (covered(el, line)) { + continue; + } let ownBox = false; for (let a: Element | null = el; a && line; a = a.parentElement) { if (a === document.documentElement || a === document.body) { @@ -634,7 +657,7 @@ const layout = (args: Args) => { return width > maxLine ? 0 : width; }); const push = (box: Box, across: boolean) => { - if (box.r - box.l >= 1 && box.b - box.t >= 1) { + if (box.r - box.l >= 1 && box.b - box.t >= 1 && !covered(el, box)) { strips.push({ el, box, across }); } }; From b5d71a27a108c5b1ee1e7d50e48ba34727c00314 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 21:06:20 -0800 Subject: [PATCH 5/5] Move the UI harness page module into voidcrew_tgui --- tgui/packages/tgui/index.tsx | 2 +- tgui/packages/{tgui => voidcrew_tgui/utils}/harness.ts | 0 voidcrew/modules/ui_harness/ui_harness.dm | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename tgui/packages/{tgui => voidcrew_tgui/utils}/harness.ts (100%) diff --git a/tgui/packages/tgui/index.tsx b/tgui/packages/tgui/index.tsx index cdb5893b5f9..8382c181f72 100644 --- a/tgui/packages/tgui/index.tsx +++ b/tgui/packages/tgui/index.tsx @@ -14,7 +14,7 @@ import { setupHotReloading } from 'tgui-dev-server/link/client'; import { App } from './App'; import { setGlobalStore } from './backend'; -import { setupUiHarness } from './harness'; // VOIDCREW EDIT ADDITION - ui_harness dev test harness +import { setupUiHarness } from 'voidcrew_tgui/utils/harness'; // VOIDCREW EDIT ADDITION - ui_harness dev test harness import { captureExternalLinks } from './links'; import { render } from './renderer'; import { configureStore } from './store'; diff --git a/tgui/packages/tgui/harness.ts b/tgui/packages/voidcrew_tgui/utils/harness.ts similarity index 100% rename from tgui/packages/tgui/harness.ts rename to tgui/packages/voidcrew_tgui/utils/harness.ts diff --git a/voidcrew/modules/ui_harness/ui_harness.dm b/voidcrew/modules/ui_harness/ui_harness.dm index b1bbbfc980e..6b29885c17f 100644 --- a/voidcrew/modules/ui_harness/ui_harness.dm +++ b/voidcrew/modules/ui_harness/ui_harness.dm @@ -4,7 +4,7 @@ * Lets a local tool drive a connected client's tgui windows (click, type, press keys, read the page) * without touching the real mouse or keyboard. The tool talks to world/Topic over TCP from 127.0.0.1; * this file forwards each command to the page as a tgui message, and the page module - * (tgui/packages/tgui/harness.ts) fires real DOM events and replies with "harness/result". + * (tgui/packages/voidcrew_tgui/utils/harness.ts) fires real DOM events and replies with "harness/result". * * None of this exists unless the world is compiled with -DUI_HARNESS. Never build a live server with it. */