From 353f076ec3172497045e77e818097e7c893f0e42 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Fri, 4 Sep 2026 16:15:13 -0400 Subject: [PATCH] feat(tui): scroll inspect, mask secrets, fix wheel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ink has no scroll offset, so a viewport can only draw a slice of a flat list and a tree cannot be sliced. Inspect built its context as a nested tree, so everything past the fold was unreachable and the screen's own footer was what got pushed off to make room. All four views — summary, expanded shapes, rendered SQL, render error — are now one element per visual line behind `ScrollPane`. One row must stay one row or the window arithmetic is wrong by the difference. `wrap="truncate"` bounds a cell's width but not its height, so `oneLine` flattens line breaks where text enters a one-row cell: nothing here controls what it displays, and a secret can be a PEM key, an environment variable can hold anything, and a helper's error message can be a multi-line diagnostic. The screen opens one focus scope with `skip` rather than one per phase. React runs a child's effects before its parent's, so a screen-level push lands above its own `SearchableList` and takes the keys the picker was mounted to receive. `$.secrets` reported a key count, which cannot answer what the screen is opened to ask: a stale password and a fresh one are both `Object (7 keys)`. It shows a partial reveal instead, narrowing as the value shortens, with the length beside it as a number rather than encoded in the mask width. Banding counts code points, so a non-BMP character is never sliced through a surrogate pair. `$.env` gets the same treatment — it is the whole of `process.env` and nothing here can tell which keys are credentials. The mouse wheel now scrolls every viewport. It reached only `SelectList` and `ResultTable`, the two `useRowMouse` callers, so the explore detail view, the full-text overlay and the row viewer ignored it. That is worse than unhandled: the TUI runs in the alternate screen, which has no scrollback, and mouse tracking takes the notches a terminal would otherwise translate into arrow keys — turning the mouse on is what removed the only wheel behaviour those panes had. `useWheelScroll` is the wheel-only half of `useRowMouse`, which needs row refs a viewport has none of. Also fixes an error branch that could never render: a helper that failed to load set the error phase with a file selected, which required `!selectedFile`, so the likeliest failure showed as "Unknown phase". Claude-Session: https://claude.ai/code/session_01JGZ8ocdNed7TFFEbccQsW2 --- .../inspect-scroll-and-masked-secrets.md | 34 ++ src/core/shared/index.ts | 3 + src/core/shared/mask.ts | 88 +++ .../components/terminal/RowViewOverlay.tsx | 10 +- src/tui/components/terminal/ScrollPane.tsx | 93 +++ src/tui/components/terminal/index.ts | 3 + src/tui/mouse.tsx | 66 +++ .../db/explore/ExploreDetailScreen.tsx | 8 + .../screens/db/explore/FullTextOverlay.tsx | 8 +- src/tui/screens/run/RunInspectScreen.tsx | 556 +++++++++++------- tests/cli/components/scroll-pane.test.tsx | 261 ++++++++ tests/cli/screens/run/inspect-lines.test.tsx | 238 ++++++++ tests/core/shared/mask.test.ts | 119 ++++ 13 files changed, 1287 insertions(+), 200 deletions(-) create mode 100644 .changeset/inspect-scroll-and-masked-secrets.md create mode 100644 src/core/shared/mask.ts create mode 100644 src/tui/components/terminal/ScrollPane.tsx create mode 100644 tests/cli/components/scroll-pane.test.tsx create mode 100644 tests/cli/screens/run/inspect-lines.test.tsx create mode 100644 tests/core/shared/mask.test.ts diff --git a/.changeset/inspect-scroll-and-masked-secrets.md b/.changeset/inspect-scroll-and-masked-secrets.md new file mode 100644 index 00000000..557d5ee1 --- /dev/null +++ b/.changeset/inspect-scroll-and-masked-secrets.md @@ -0,0 +1,34 @@ +--- +"@noormdev/cli": minor +--- + +Make `run inspect` scrollable, and show secrets partially rather than as a count. + +The inspect screen rendered its context as a nested tree that grew with the +project, and Ink has no scroll offset — so on any real template the bottom of +the view sat below the fold with no key that could reach it, and the screen's +own footer was what got pushed off to make room. Every view it offers (summary, +expanded, rendered SQL, and render errors) is now a flat list of one element per +visual line behind a viewport, scrolled with the same `↑↓` / `^U` / `^D` keys the +explore and SQL screens already use. + +`$.secrets` and `$.globalSecrets` reported a key count, which cannot answer the +question the screen is opened to answer: a stale password and a fresh one are +both `Object (7 keys)`. Both tiers now show a partial reveal that narrows as the +value gets shorter — a four-character value shows nothing, a long one shows two +characters and a four-character suffix — with the length beside it as a number, +so a value that is set but empty is distinguishable from one that is set wrong. +`$.env` is listed and masked on the same terms, because it is the whole of +`process.env` and nothing in the screen can tell which of its keys are +credentials. + +The mouse wheel now scrolls every viewport, which it never did. Only `SelectList` +and `ResultTable` consumed wheel notches, so the explore detail view, the +full-text overlay and the row viewer ignored them — and because the TUI runs in +the alternate screen, which has no scrollback, and mouse tracking intercepts the +notches a terminal would otherwise translate into arrow keys, turning the mouse +on had actually removed the only wheel behaviour those panes had. + +Also fixes an error path that could never render: a template whose helper failed +to load set the screen's error phase with a file selected, which no branch +matched, so the most likely failure showed as "Unknown phase". diff --git a/src/core/shared/index.ts b/src/core/shared/index.ts index fafd72b1..85ae15c4 100644 --- a/src/core/shared/index.ts +++ b/src/core/shared/index.ts @@ -14,6 +14,9 @@ export { OperationAbortedError, throwIfAborted, raceAbort } from './abort.js'; // Files export { filterFilesByPaths, findUnmatchedIncludePatterns, findUnmatchedExcludePatterns } from './files.js'; +// Secret display +export { maskSecret } from './mask.js'; + // Dialect quoting export { createDialectQuoting, type DialectQuoting } from './dialect-quoting.js'; diff --git a/src/core/shared/mask.ts b/src/core/shared/mask.ts new file mode 100644 index 00000000..9eafe5cb --- /dev/null +++ b/src/core/shared/mask.ts @@ -0,0 +1,88 @@ +/** + * Partial masking for a secret that is being shown to the person who owns it. + * + * The inspect screen exists to answer "did this template get the values I + * think it got", and a row of `Object (7 keys)` cannot answer it. Neither can + * a full reveal, which turns a screen someone leaves open during a screen + * share into a credential leak. What answers it is enough of the value to + * recognise which secret it is and to catch the two mistakes that actually + * happen — a stale value, and a key resolved from the wrong tier. + * + * How much is safe to show depends on how much there is. A four-character + * value has no middle to hide, so revealing its ends reveals the value; a + * forty-character token gives away nothing in six characters. The bands below + * reveal less as the value gets shorter, and stop revealing at all once a + * value is short enough that any window is most of it. + * + * The mask core is a fixed width on purpose. Sizing it to the value would + * publish the exact length of every secret on screen, which is a real + * narrowing hint against a value someone is trying to guess. Callers that + * want the length — inspect does, because "set but empty" and "set to the + * 8-character staging password" are different bugs — ask for it separately + * and render it as a number, where it reads as the diagnostic it is rather + * than as part of the value. + * + * @example + * maskSecret('hunter2'); // 'h*****2' + * maskSecret('postgres://user:pw@host/db'); // 'po*****t/db' + */ + +/** + * What stands in for the hidden middle, at every length that has one. + * + * Fixed rather than proportional so the rendering never encodes how long the + * value is. See the module note. + */ +const MASK_CORE = '*****'; + +/** + * Longest value that is shown as nothing but mask. + * + * At four characters a first-and-last window is half the value, which is not + * a mask. + */ +const OPAQUE_MAX = 4; + +/** Longest value that reveals only one character at each end. */ +const NARROW_MAX = 8; + +/** Longest value that reveals a suffix but no prefix. */ +const SUFFIX_ONLY_MAX = 12; + +/** What an empty value renders as, so it is not mistaken for an unset one. */ +const EMPTY_LABEL = '(empty)'; + +/** + * A secret rendered for display, revealing less the shorter it is. + * + * @example + * maskSecret(''); // '(empty)' + * maskSecret('abcd'); // '*****' + * maskSecret('abcdefgh'); // 'a*****h' + * maskSecret('abcdefghijkl'); // '*****ijkl' + * maskSecret('abcdefghijklm'); // 'ab*****jklm' + */ +export function maskSecret(value: string): string { + + // Code points, not `.length`. A `String.prototype.slice` offset counts + // UTF-16 code units, so a value ending in an emoji or any other non-BMP + // character gets sliced through the middle of a surrogate pair and the + // reveal renders as a replacement glyph — the one part of the value a + // reader is meant to recognise, corrupted. + const characters = [...value]; + const { length } = characters; + + const head = (count: number) => characters.slice(0, count).join(''); + const tail = (count: number) => characters.slice(-count).join(''); + + if (length === 0) return EMPTY_LABEL; + + if (length <= OPAQUE_MAX) return MASK_CORE; + + if (length <= NARROW_MAX) return `${head(1)}${MASK_CORE}${tail(1)}`; + + if (length <= SUFFIX_ONLY_MAX) return `${MASK_CORE}${tail(4)}`; + + return `${head(2)}${MASK_CORE}${tail(4)}`; + +} diff --git a/src/tui/components/terminal/RowViewOverlay.tsx b/src/tui/components/terminal/RowViewOverlay.tsx index ece7bb95..fa881cc5 100644 --- a/src/tui/components/terminal/RowViewOverlay.tsx +++ b/src/tui/components/terminal/RowViewOverlay.tsx @@ -41,6 +41,7 @@ import type { ReactElement } from 'react'; import type { RowFormat } from './rowDocument.js'; import { useFocusScope } from '../../focus.js'; +import { useWheelScroll } from '../../mouse.js'; import { preferredRowFormat, rememberRowFormat, renderRowDocument } from './rowDocument.js'; import { rowBudget, rowWindow, scrollTarget, wrapText } from './viewport.js'; @@ -118,6 +119,13 @@ export function RowViewOverlay({ const view = rowWindow(lines.length, offset, budget); const maxOffset = lines.length - view.count; + const scrollTo = (next: number) => setOffset(Math.min(Math.max(next, 0), maxOffset)); + + // Scrolls the document, not the row cursor: ←/→ change rows, and a wheel + // that jumped between rows would lose the reader's place in a long one. + // Inert without a MouseProvider above it or with the setting off. + useWheelScroll({ isActive: isFocused, onWheel: (delta) => scrollTo(view.start + delta) }); + const move = (next: number) => { if (next < 0 || next > rows.length - 1 || next === index) return; @@ -169,7 +177,7 @@ export function RowViewOverlay({ const target = scrollTarget(input, key, view, maxOffset); - if (target !== null) setOffset(Math.min(Math.max(target, 0), maxOffset)); + if (target !== null) scrollTo(target); }); diff --git a/src/tui/components/terminal/ScrollPane.tsx b/src/tui/components/terminal/ScrollPane.tsx new file mode 100644 index 00000000..b6c99da9 --- /dev/null +++ b/src/tui/components/terminal/ScrollPane.tsx @@ -0,0 +1,93 @@ +/** + * ScrollPane - a vertical viewport over a flat list of pre-laid-out lines. + * + * Ink has no scroll offset. A screen that renders more rows than the terminal + * holds does not clip them, it pushes its own footer off the bottom, so the + * content past the fold is not merely unscrolled — it is unreachable. Every + * screen that can overflow therefore has to flatten itself to one element per + * visual line and draw a slice of that list, which is what this does. + * + * It is the plain form of the pattern: offset state, the shared window + * arithmetic, the shared scroll keys, and the two "more above / more below" + * indicators. `ExploreDetailScreen`'s `ScrollView` is the same viewport with a + * full-text overlay and a row peek switched in over the top of it, and it stays + * where it is — its overlays are built from explore's own layout module, so + * pulling it down here would drag a screen's vocabulary into a shared + * component for no gain. Anything that needs a viewport and not those overlays + * uses this. + * + * Takes `height` and `isFocused` as props rather than measuring or scoping for + * itself, so the screen stays the single place that accounts for chrome, and so + * a test can pin a viewport without a terminal to measure. + * + * @example + * + */ +import { useState } from 'react'; +import { Box, Text, useInput } from 'ink'; + +import type { ReactElement } from 'react'; + +import { useWheelScroll } from '../../mouse.js'; +import { rowWindow, scrollTarget } from './viewport.js'; + +/** + * Props for the scroll pane. + */ +export interface ScrollPaneProps { + + /** + * One element per visual line, each carrying its own `key`. + * + * One *visual* line: an element that wraps to two rows makes the window + * arithmetic wrong by one, so callers wrap their own text (`wrapText`) or + * truncate it (`wrap="truncate"`) before handing it over. + */ + lines: ReactElement[]; + + /** Rows the viewport may draw, indicators included. */ + height: number; + + /** Focus comes from the screen; this component opens no scope of its own. */ + isFocused: boolean; + +} + +/** + * ScrollPane component. + */ +export function ScrollPane({ lines, height, isFocused }: ScrollPaneProps): ReactElement { + + const [offset, setOffset] = useState(0); + + const view = rowWindow(lines.length, offset, height); + const maxOffset = lines.length - view.count; + + const scrollTo = (next: number) => setOffset(Math.min(Math.max(next, 0), maxOffset)); + + // Inert without a MouseProvider above it or with the setting off. + useWheelScroll({ isActive: isFocused, onWheel: (delta) => scrollTo(view.start + delta) }); + + useInput((input, key) => { + + if (!isFocused) return; + + // Rebases on `view.start` rather than on `offset`: the window clamps + // what it draws, so a stale offset left by a resize or by shorter + // content cannot send the next keypress somewhere the viewport never + // was. + const target = scrollTarget(input, key, view, maxOffset); + + if (target !== null) scrollTo(target); + + }); + + return ( + + {view.above > 0 && ↑ {view.above} more} + {lines.slice(view.start, view.start + view.count)} + {view.below > 0 && ↓ {view.below} more} + + ); + +} diff --git a/src/tui/components/terminal/index.ts b/src/tui/components/terminal/index.ts index 05cd92fe..16c33a06 100644 --- a/src/tui/components/terminal/index.ts +++ b/src/tui/components/terminal/index.ts @@ -17,6 +17,9 @@ export type { ResultBrowserProps } from './ResultBrowser.js'; export { RowViewOverlay } from './RowViewOverlay.js'; export type { RowViewOverlayProps } from './RowViewOverlay.js'; +export { ScrollPane } from './ScrollPane.js'; +export type { ScrollPaneProps } from './ScrollPane.js'; + export { fitGridColumns, fitPeekColumns, PEEK_COLUMN_CAP } from './columnFit.js'; export type { GridColumnFit, PeekColumnFit } from './columnFit.js'; diff --git a/src/tui/mouse.tsx b/src/tui/mouse.tsx index f88e7b12..94282eb9 100644 --- a/src/tui/mouse.tsx +++ b/src/tui/mouse.tsx @@ -435,6 +435,72 @@ export function useMouseTransport(): MouseTransport { } +/** + * Options for useWheelScroll. + */ +export interface WheelScrollOptions { + + /** + * Whether the component owning the viewport currently has input. + * + * The same guard its `useInput` handler uses: a wheel notch acts on + * whatever already has focus, so a pane sitting behind an overlay must not + * move under it. + */ + isActive: boolean; + + /** A wheel notch: -1 for up, 1 for down. */ + onWheel: (delta: -1 | 1) => void; + +} + +/** + * Wheel notches for a viewport that scrolls but has no rows to click. + * + * `useRowMouse` already carries wheel handling, but it is built around + * hit-testing a list of registered row refs, and a viewport has none — it draws + * a slice of a flat line list where nothing is selectable. Subscribing for the + * wheel alone is the whole of what those panes need. + * + * Without this the wheel is not merely unhandled, it is broken: the TUI runs in + * the alternate screen, which has no scrollback of its own, and mouse tracking + * takes the notches that a terminal would otherwise translate into arrow keys. + * So turning the mouse on removes the only wheel behaviour a viewport had. + * + * @example + * useWheelScroll({ isActive: isFocused, onWheel: (delta) => scrollTo(view.start + delta) }); + */ +export function useWheelScroll({ isActive, onWheel }: WheelScrollOptions): void { + + const { enabled, subscribe } = useMouseTransport(); + + // Registered once, reading the latest props through this ref. Listing them + // as dependencies would resubscribe on every render, since every caller + // passes an inline arrow. + const latest = useRef({ isActive, onWheel }); + + latest.current = { isActive, onWheel }; + + useEffect(() => { + + if (!enabled) return; + + return subscribe((event) => { + + const current = latest.current; + + if (!current.isActive || event.kind !== 'press') return; + + if (event.button === 'wheel-up') current.onWheel(-1); + + else if (event.button === 'wheel-down') current.onWheel(1); + + }); + + }, [enabled, subscribe]); + +} + /** * Options for useRowMouse. */ diff --git a/src/tui/screens/db/explore/ExploreDetailScreen.tsx b/src/tui/screens/db/explore/ExploreDetailScreen.tsx index a387b49d..a7ef498f 100644 --- a/src/tui/screens/db/explore/ExploreDetailScreen.tsx +++ b/src/tui/screens/db/explore/ExploreDetailScreen.tsx @@ -34,6 +34,7 @@ import type { ScreenProps } from '../../../types.js'; import { useRouter } from '../../../router.js'; import { useFocusScope } from '../../../focus.js'; +import { useWheelScroll } from '../../../mouse.js'; import { useAppContext } from '../../../app-context.js'; import { Panel, Spinner } from '../../../components/index.js'; import { useConnection, useAsyncEffect } from '../../../hooks/index.js'; @@ -614,6 +615,13 @@ export function ScrollView({ // cannot send the next keypress somewhere the viewport never was. const scrollTo = (next: number) => setOffset(Math.min(Math.max(next, 0), maxOffset)); + // Only while this component owns the viewport: an overlay draws over it and + // scrolls itself, so the pane underneath must not move under the notch. + useWheelScroll({ + isActive: isFocused && overlay === 'none', + onWheel: (delta) => scrollTo(view.start + delta), + }); + const open = (next: DetailOverlay) => { setOverlay(next); diff --git a/src/tui/screens/db/explore/FullTextOverlay.tsx b/src/tui/screens/db/explore/FullTextOverlay.tsx index 65845f2a..42d4d19f 100644 --- a/src/tui/screens/db/explore/FullTextOverlay.tsx +++ b/src/tui/screens/db/explore/FullTextOverlay.tsx @@ -28,6 +28,7 @@ import { Box, Text, useInput, useWindowSize } from 'ink'; import type { ReactElement } from 'react'; import { useFocusScope } from '../../../focus.js'; +import { useWheelScroll } from '../../../mouse.js'; import { rowBudget, rowWindow, scrollTarget, wrapText } from './layout.js'; /** @@ -96,6 +97,11 @@ export function FullTextOverlay({ text, startRow, height, onClose }: FullTextOve const view = rowWindow(lines.length, offset, budget); const maxOffset = lines.length - view.count; + const scrollTo = (next: number) => setOffset(Math.min(Math.max(next, 0), maxOffset)); + + // Inert without a MouseProvider above it or with the setting off. + useWheelScroll({ isActive: isFocused, onWheel: (delta) => scrollTo(view.start + delta) }); + useInput((input, key) => { if (!isFocused) return; @@ -110,7 +116,7 @@ export function FullTextOverlay({ text, startRow, height, onClose }: FullTextOve const target = scrollTarget(input, key, view, maxOffset); - if (target !== null) setOffset(Math.min(Math.max(target, 0), maxOffset)); + if (target !== null) scrollTo(target); }); diff --git a/src/tui/screens/run/RunInspectScreen.tsx b/src/tui/screens/run/RunInspectScreen.tsx index 36eef206..91cf8a26 100644 --- a/src/tui/screens/run/RunInspectScreen.tsx +++ b/src/tui/screens/run/RunInspectScreen.tsx @@ -10,8 +10,8 @@ * noorm run inspect sql/users/001_create.sql.tmpl # With pre-filled path * ``` */ -import { useState, useCallback, useEffect } from 'react'; -import { Box, Text, useInput } from 'ink'; +import { useState, useCallback, useEffect, useMemo } from 'react'; +import { Box, Text, useInput, useWindowSize } from 'ink'; import { join, relative } from 'path'; import type { ReactElement } from 'react'; @@ -22,7 +22,9 @@ import { useRouter } from '../../router.js'; import { useFocusScope } from '../../focus.js'; import { useSettings, useAppContext } from '../../app-context.js'; import { Panel, Spinner, SearchableList } from '../../components/index.js'; -import { useAsyncEffect, useConnection } from '../../hooks/index.js'; +import { ScrollPane, rowBudget, wrapText } from '../../components/terminal/index.js'; +import { useAsyncEffect, useConnection, viewportRows } from '../../hooks/index.js'; +import { maskSecret } from '../../../core/shared/index.js'; import { discoverFiles } from '../../../core/runner/index.js'; import { buildContext } from '../../../core/template/context.js'; import { processFile } from '../../../core/template/engine.js'; @@ -43,7 +45,27 @@ type Phase = 'loading' | 'picker' | 'inspecting' | 'expanded' | 'preview' | 'err const BUILTIN_HELPERS = new Set(['quote', 'escape', 'uuid', 'now', 'json', 'include']); const STANDARD_KEYS = new Set(['config', 'secrets', 'globalSecrets', 'env']); -interface CategorizedContext { +/** + * Rows a scrolling phase spends inside its Panel before the pane starts. + * + * The `File:` line and the gap under it. The file name stays out of the pane on + * purpose: it is the one thing a reader needs at every scroll position, and a + * heading that scrolls away is a heading that is missing when it is wanted. + */ +const HEADER_ROWS = 2; + +/** + * A template context split into the groups the screen draws as sections. + * + * Exported because the line builders below are unit-tested directly, the way + * `ExploreDetailScreen`'s row builders are — rendering the whole screen to + * assert on a mask would mean standing up a project, a config and a connection + * for a pure function. + * + * @example + * const lines = contextLines(categorizeContext(ctx, helperKeys, []), projectRoot, 96); + */ +export interface CategorizedContext { dataFiles: Array<{ key: string; value: unknown }>; helpers: Array<{ key: string; value: unknown }>; helperErrors: HelperLoadError[]; @@ -276,42 +298,244 @@ async function resolveRenderSecrets( } +/** Widest the name column grows before it truncates. */ +const NAME_CAP = 30; + +/** Narrowest the name column shrinks to, however little the terminal offers. */ +const NAME_MIN = 12; + +/** Left inset every entry under a section heading shares. */ +const ENTRY_INDENT = 2; + /** - * Component that handles keyboard input for a specific focus scope. + * Width of the name column, derived from what this context actually holds. + * + * Same idiom as the explore rows and the Form label gutter: size once from the + * content, cap it, truncate past the cap. A context of short names does not pay + * for the one environment variable with a sixty-character name. */ -function KeyHandler({ - focusLabel, - onEscape, - onKey, -}: { - focusLabel: string; - onEscape?: () => void; - onKey?: (input: string, key: { escape: boolean }) => void; -}): null { - - const { isFocused } = useFocusScope(focusLabel); +function nameColumnWidth(names: string[], budget: number): number { - useInput((input, key) => { + let widest = 0; - if (!isFocused) return; + for (const name of names) { + + if (name.length > widest) widest = name.length; - if (key.escape && onEscape) { + } - onEscape(); + return Math.max(NAME_MIN, Math.min(widest, NAME_CAP, budget - ENTRY_INDENT - NAME_MIN)); - return; +} - } +/** + * Text with its line breaks flattened, so it can occupy exactly one row. + * + * `wrap="truncate"` bounds a line's width, not its height: Ink still breaks on + * an embedded newline, so a single `` holding one draws two rows and puts + * the viewport's arithmetic out by one for everything below it. Nothing on this + * screen controls the strings it displays — a secret can be a PEM key, an + * environment variable can hold anything, a helper's error message can be a + * multi-line diagnostic — so the flattening happens where text enters a + * one-row cell rather than at each of those sources. + */ +function oneLine(text: string): string { + + return text.replace(/[\r\n]+/g, ' '); - if (onKey) { +} + +/** + * One `name detail` line, exactly one row tall. + * + * `flexShrink={0}` on the name cell because Ink's `width` is a flex basis and + * flex items shrink by default: without it a long detail squeezes the name on + * that row alone, and the column wanders down the page. Both cells truncate + * rather than wrap, which bounds their width; `oneLine` is what bounds their + * height. + */ +function entryRow(key: string, name: string, color: string, detail: string, width: number): ReactElement { - onKey(input, key); + return ( + + + {oneLine(name)} + + {oneLine(detail)} + + ); - } +} + +/** + * A heading, its entries, and the blank line under them. + * + * An empty section contributes nothing rather than a bare heading, so a project + * with no data files does not scroll past a promise of some. + */ +function sectionLines(key: string, title: string, rows: ReactElement[]): ReactElement[] { + + if (rows.length === 0) return []; + + return [ + {title}, + ...rows, + , + ]; + +} + +/** + * Secret keys, each with as much of its value as is safe to show. + * + * A count answers "is anything there". The question this screen is actually + * asked is "did this template get the value I think it got", and only the value + * answers that — a stale password and a fresh one are both `Object (7 keys)`. + * How much of it is safe to show is `maskSecret`'s decision, not this + * component's; see `core/shared/mask.ts`. The length rides alongside as a + * number rather than as mask width so that "set but empty" and "set to the + * wrong 8-character value" stay distinguishable without the asterisks + * themselves leaking anything. + */ +function secretRows(prefix: string, values: Record, color: string, width: number): ReactElement[] { + + return Object.keys(values).sort().map((key) => { + + const value = values[key] ?? ''; + + // Code points, matching how `maskSecret` counts. Reporting UTF-16 units + // beside a mask banded on characters would call the same value two + // different lengths. + const count = [...value].length; + + return entryRow(`${prefix}:${key}`, key, color, `${maskSecret(value)} (${count} chars)`, width); }); - return null; +} + +/** + * The summary view as one element per visual line. + * + * Flattened rather than nested because Ink has no scroll offset: the only way + * to reach content past the bottom of the terminal is to draw a slice of a flat + * list, and a tree cannot be sliced. + * + * `$.env` is listed and masked like the other two secret tiers. It is the whole + * of `process.env` (`core/template/context.ts`), which on a developer's machine + * routinely carries tokens that never went near noorm's vault, and nothing here + * can tell which of its keys those are. Masking every value is the answer that + * is wrong in the harmless direction. + * + * @example + * + */ +export function contextLines(context: CategorizedContext, projectRoot: string, budget: number): ReactElement[] { + + const envKeys = Object.keys(context.env); + const width = nameColumnWidth( + [ + ...context.dataFiles.map(({ key }) => `$.${key}`), + ...context.helpers.map(({ key }) => `$.${key}`), + ...context.builtins.map(({ key }) => `$.${key}`), + ...Object.keys(context.secrets), + ...Object.keys(context.globalSecrets), + ...envKeys, + '$.config', + ], + budget, + ); + + // A helper error gets the whole row rather than the two-column treatment. + // The name column is sized from the `$.name` entries beside it, which are + // short, and a path is the one thing this row exists to say — put it in + // that column and `sql/helpers/slug.js` renders as `sql/helpers…`, naming + // no file at all. + const helperEntries = [ + ...context.helpers.map(({ key, value }) => + entryRow(`helper:${key}`, `$.${key}`, 'magenta', describeType(value), width)), + ...context.helperErrors.map(({ filepath, error }) => ( + + {oneLine(`${' '.repeat(ENTRY_INDENT)}${relative(projectRoot, filepath)} — ${error.message}`)} + + )), + ]; + + return [ + ...sectionLines('data', 'Data Files', context.dataFiles.map(({ key, value }) => + entryRow(`data:${key}`, `$.${key}`, 'green', describeType(value), width))), + ...sectionLines('helpers', 'Helpers ($helpers)', helperEntries), + ...sectionLines('builtins', 'Built-ins', context.builtins.map(({ key }) => + entryRow(`builtin:${key}`, `$.${key}`, 'blue', 'Function', width))), + ...sectionLines('config', 'Config', [ + entryRow('config', '$.config', 'yellow', context.config ? describeType(context.config) : '(not set)', width), + ]), + ...sectionLines( + 'secrets', + `Secrets ($.secrets — ${Object.keys(context.secrets).length})`, + secretRows('secret', context.secrets, 'red', width), + ), + ...sectionLines( + 'globalSecrets', + `Global Secrets ($.globalSecrets — ${Object.keys(context.globalSecrets).length})`, + secretRows('globalSecret', context.globalSecrets, 'red', width), + ), + ...sectionLines( + 'env', + `Environment ($.env — ${envKeys.length})`, + secretRows('env', context.env, 'gray', width), + ), + ]; + +} + +/** + * Plain text as one element per visual line. + * + * Wrapped here rather than left to Ink because a `` that wraps itself + * occupies however many rows the terminal decides, and the viewport has to know + * the count before Ink lays it out. + */ +function textLines(key: string, text: string, budget: number, style: { color?: string; dim?: boolean } = {}): ReactElement[] { + + return wrapText(text, budget).map((line, index) => ( + {line} + )); + +} + +/** + * The expanded view as one element per visual line. + * + * Reports shapes rather than values, so what can overflow a row here is a long + * key or a wide shape summary, and both are wrapped to the budget rather than + * truncated — the expanded view exists to show what a summary cut. + * + * @example + * + */ +export function expandedLines(context: CategorizedContext, budget: number): ReactElement[] { + + const lines: ReactElement[] = []; + + for (const { key, value } of context.dataFiles) { + + lines.push({oneLine(`$.${key}`)}); + lines.push(...describeTypeExpanded(value, 1).flatMap((line, index) => + textLines(`exp:${key}:${index}`, line, budget, { dim: true }))); + lines.push( ); + + } + + if (context.config !== undefined && context.config !== null) { + + lines.push($.config); + lines.push(...describeTypeExpanded(context.config, 1).flatMap((line, index) => + textLines(`exp:config:${index}`, line, budget, { dim: true }))); + + } + + return lines; } @@ -325,6 +549,12 @@ export function RunInspectScreen({ params }: ScreenProps): ReactElement { const { settings } = useSettings(); const { db, dialect } = useConnection(); + // useWindowSize, not useStdout: stdout.columns and .rows mutate on resize + // without telling React, so anything derived from them would freeze at + // mount size. Above the early returns, or the hook count changes once the + // load resolves. + const { columns: terminalColumns, rows: terminalRows } = useWindowSize(); + const [phase, setPhase] = useState('loading'); const [allFiles, setAllFiles] = useState([]); const [selectedFile, setSelectedFile] = useState(params.path ?? null); @@ -333,6 +563,18 @@ export function RunInspectScreen({ params }: ScreenProps): ReactElement { const [renderDuration, setRenderDuration] = useState(null); const [error, setError] = useState(null); + // One scope for the whole screen rather than one per phase, because the + // scroll pane and the action keys have to agree on who is focused and two + // scopes cannot: React runs a child's effects before its parent's, so a + // screen-level push lands *above* its own child's and takes the keys the + // child was mounted to receive. `skip` is how a screen that sometimes hosts + // a focusable child stays out of the stack while that child is up — here, + // the file picker's `SearchableList`. + const { isFocused } = useFocusScope({ + label: 'RunInspect', + skip: phase === 'picker' && allFiles.length > 0, + }); + const projectRoot = process.cwd(); // Load template files on mount @@ -511,42 +753,79 @@ export function RunInspectScreen({ params }: ScreenProps): ReactElement { }); - // Handlers for inspecting phase - const handleInspectKey = useCallback((input: string) => { + const handleInspectEscape = useCallback(() => { - if (input === 'e') { + setSelectedFile(null); + setContext(null); + setError(null); + setPhase('picker'); - setPhase('expanded'); + }, []); - } - else if (input === 'p') { + const displayPath = selectedFile ? relative(projectRoot, selectedFile) : ''; - handlePreview(); + const paneHeight = viewportRows(terminalRows, HEADER_ROWS); + const budget = rowBudget(terminalColumns); + + const summaryLines = useMemo( + () => (context ? contextLines(context, projectRoot, budget) : []), + [context, projectRoot, budget], + ); + + const detailLines = useMemo( + () => (context ? expandedLines(context, budget) : []), + [context, budget], + ); + + // The render error and the rendered SQL share the pane, because they are + // the same thing to a reader: what came back from asking for this template. + // A stack trace overflows a terminal as readily as a schema does. + const previewLines = useMemo( + () => (error !== null + ? textLines('previewError', error, budget, { color: 'red' }) + : textLines('preview', renderedSql ?? '', budget)), + [error, renderedSql, budget], + ); + + const errorLines = useMemo( + () => textLines('error', error ?? 'Unknown error', budget, { dim: true }), + [error, budget], + ); - } - else if (input === 'r') { + useInput((input, key) => { - handleRefresh(); + if (!isFocused) return; - } + if (key.escape) { - }, [handlePreview, handleRefresh]); + // An error raised against a chosen template goes back to the + // picker, like a successful inspection does. Only a failure to + // discover any templates at all leaves the screen, because there is + // no picker to go back to. + if (phase === 'inspecting' || (phase === 'error' && selectedFile)) handleInspectEscape(); + else if (phase === 'expanded' || phase === 'preview') setPhase('inspecting'); + else back(); - const handleInspectEscape = useCallback(() => { + return; - setSelectedFile(null); - setContext(null); - setPhase('picker'); + } - }, []); + if (phase !== 'inspecting') return; - const handleBackToInspect = useCallback(() => { + // Ink reports a Ctrl chord as the bare letter with `key.ctrl` set, so + // without this Ctrl+E would expand and Ctrl+R would re-render. Ctrl+D + // is safe either way — it arrives as `d`, which none of these match — + // but the pane below reads it, so the modifier check has to happen + // before any of them. + if (key.ctrl || key.meta) return; - setPhase('inspecting'); + if (input === 'e') setPhase('expanded'); - }, []); + if (input === 'p') handlePreview(); - const displayPath = selectedFile ? relative(projectRoot, selectedFile) : ''; + if (input === 'r') handleRefresh(); + + }); // Loading if (phase === 'loading') { @@ -561,19 +840,26 @@ export function RunInspectScreen({ params }: ScreenProps): ReactElement { } - // Error (for context loading errors) - if (phase === 'error' && !selectedFile) { + // Error, from discovering the file list or from building the context. + // + // Both, deliberately: this used to require `!selectedFile`, which is true + // only of a discovery failure, so a template whose helper threw set + // `phase: 'error'` with a file selected and fell through every branch to + // "Unknown phase" — the one error a reader is most likely to hit was the + // one the screen would not show. + if (phase === 'error') { return ( - - Error - {error} + Error{displayPath ? `: ${displayPath}` : ''} + + [↑↓] Scroll + [^U/^D] Half [Esc] Back @@ -608,7 +894,6 @@ export function RunInspectScreen({ params }: ScreenProps): ReactElement { ) : ( <> - No template files found in {sqlPath}/ @@ -639,111 +924,19 @@ export function RunInspectScreen({ params }: ScreenProps): ReactElement { return ( - - - File: + + File: {displayPath} - - - {context.dataFiles.length > 0 && ( - - Data Files - {context.dataFiles.map(({ key, value }) => ( - - - $.{key} - - {describeType(value)} - - ))} - - )} - - {(context.helpers.length > 0 || context.helperErrors.length > 0) && ( - - Helpers ($helpers) - {context.helpers.map(({ key, value }) => ( - - - $.{key} - - {describeType(value)} - - ))} - {context.helperErrors.map(({ filepath, error: helperErr }) => ( - - Failed to load: {relative(projectRoot, filepath)} - {helperErr.message} - - ))} - - )} - - - Built-ins - {context.builtins.map(({ key }) => ( - - - $.{key} - - Function - - ))} - - - - Config - - - $.config - - - {context.config ? describeType(context.config) : '(not set)'} - - - - - - Secrets - - - $.secrets - - - Object ({Object.keys(context.secrets).length} keys) - - - - - $.globalSecrets - - - Object ({Object.keys(context.globalSecrets).length} keys) - - - - - - Environment - - - $.env - - - Object ({Object.keys(context.env).length} keys) - - - + + + [↑↓] Scroll + [^U/^D] Half [e] Expand [p] Preview SQL [r] Refresh @@ -759,38 +952,19 @@ export function RunInspectScreen({ params }: ScreenProps): ReactElement { return ( - - - File: + + File: {displayPath} - - - {context.dataFiles.map(({ key, value }) => ( - - $.{key} - {describeTypeExpanded(value, 1).map((line, i) => ( - {line} - ))} - - ))} - - {context.config !== undefined && context.config !== null && ( - - $.config - {describeTypeExpanded(context.config, 1).map((line, i) => ( - {line} - ))} - - )} + + + [↑↓] Scroll + [^U/^D] Half [Esc] Back to summary @@ -802,13 +976,10 @@ export function RunInspectScreen({ params }: ScreenProps): ReactElement { if (phase === 'preview') { const hasError = error !== null; + const timing = renderDuration !== null ? ` · ${renderDuration.toFixed(1)}ms` : ''; return ( - - - File: + + File: {displayPath} - - - {!hasError && renderDuration !== null && ( - - Rendered in: - {renderDuration.toFixed(1)}ms - - )} - - - {hasError ? ( - {error} - ) : ( - {renderedSql} - )} - + {hasError ? '' : timing} + + + [↑↓] Scroll + [^U/^D] Half [Esc] Back to summary diff --git a/tests/cli/components/scroll-pane.test.tsx b/tests/cli/components/scroll-pane.test.tsx new file mode 100644 index 00000000..38b5f698 --- /dev/null +++ b/tests/cli/components/scroll-pane.test.tsx @@ -0,0 +1,261 @@ +/** + * ScrollPane tests. + * + * Ink has no scroll offset, so content past the bottom of the terminal is not + * merely unscrolled — it is unreachable, and the screen's own footer is what + * gets pushed off to make room for it. The contract pinned here is that every + * line handed to the pane can be brought on screen by a key, and that the pane + * never draws more rows than the budget it was given, because the budget is + * what the screen subtracted its chrome from. + * + * The "not focused" case is load-bearing rather than incidental: the pane takes + * focus as a prop and registers its handler unconditionally (Ink's `useInput` + * never re-registers once skipped), so the guard inside the handler is the only + * thing stopping a background pane from consuming a focused screen's arrows. + */ +import { describe, it, expect } from 'bun:test'; +import { render } from 'ink-testing-library'; +import { Text } from 'ink'; +import React from 'react'; + +import { ScrollPane } from '../../../src/tui/components/terminal/ScrollPane.js'; +import { MouseProvider } from '../../../src/tui/mouse.js'; + +/** An SGR wheel-down press. 64 is the wheel bit; the low bit picks the direction. */ +const WHEEL_DOWN = '\x1B[<65;10;10M'; + +/** An SGR wheel-up press. */ +const WHEEL_UP = '\x1B[<64;10;10M'; + +/** Rows the pane may draw, indicators included. */ +const HEIGHT = 10; + +/** More lines than the height, so there is always something below the fold. */ +const TOTAL = 40; + +// eslint-disable-next-line no-control-regex -- matching the ANSI SGR escape is the point +const ANSI_PATTERN = /\[[0-9;]*m/g; + +function strip(frame: string | undefined): string { + + return (frame ?? '').replace(ANSI_PATTERN, ''); + +} + +/** + * Poll rather than sleep a guessed duration: a fixed wait is the suite's known + * flake class under load. + */ +async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise { + + const deadline = Date.now() + timeoutMs; + + while (!predicate() && Date.now() < deadline) { + + await new Promise((resolve) => setTimeout(resolve, 10)); + + } + +} + +function lines(count = TOTAL) { + + return Array.from({ length: count }, (_, index) => ( + line-{index} + )); + +} + +/** + * One keypress, then wait for Ink to take it. + * + * Writes in a tight loop are coalesced: twenty synchronous `\x04` writes reach + * `useInput` as one twenty-character string, which is not twenty Ctrl+D events + * and scrolls exactly one half-page. Yielding between presses is what makes a + * press a press. + */ +async function press(stdin: { write: (data: string) => void }, sequence: string): Promise { + + stdin.write(sequence); + + await new Promise((resolve) => setTimeout(resolve, 10)); + +} + +describe('cli: ScrollPane', () => { + + it('should draw no more rows than the height it was given', async () => { + + const { stdin, lastFrame, unmount } = render( + , + ); + + await waitFor(() => strip(lastFrame()).includes('line-0')); + + // At rest and mid-scroll both, because the second is where the pane is + // tallest: `rowWindow` reserves the two indicator rows as a pair, so at + // the ends of the scroll one of them is unused and the pane is a row + // shorter than its budget rather than a row longer. + expect(strip(lastFrame()).split('\n').length).toBeLessThanOrEqual(HEIGHT); + + await press(stdin, '\x04'); + + expect(strip(lastFrame()).split('\n').length).toBeLessThanOrEqual(HEIGHT); + expect(strip(lastFrame())).toContain('↑'); + + unmount(); + + }); + + it('should hold back the lines past the fold, and say how many', async () => { + + const { lastFrame, unmount } = render( + , + ); + + await waitFor(() => strip(lastFrame()).includes('line-0')); + + const frame = strip(lastFrame()); + + expect(frame).toContain('line-0'); + expect(frame).not.toContain('line-39'); + expect(frame).toContain('more'); + + unmount(); + + }); + + it('should reach the last line by paging', async () => { + + const { stdin, lastFrame, unmount } = render( + , + ); + + await waitFor(() => strip(lastFrame()).includes('line-0')); + + // Ctrl+D, the advertised half-page key, enough times to pass the end. + for (let count = 0; count < 20; count += 1) { + + await press(stdin, '\x04'); + + if (strip(lastFrame()).includes('line-39')) break; + + } + + expect(strip(lastFrame())).toContain('line-39'); + + unmount(); + + }); + + it('should move one line at a time on an arrow', async () => { + + const { stdin, lastFrame, unmount } = render( + , + ); + + await waitFor(() => strip(lastFrame()).includes('line-0')); + + stdin.write('\x1B[B'); + + await waitFor(() => !strip(lastFrame()).includes('line-0')); + + expect(strip(lastFrame())).toContain('line-1'); + expect(strip(lastFrame())).not.toContain('line-0'); + + unmount(); + + }); + + it('should ignore every scroll key while it is not focused', async () => { + + const { stdin, lastFrame, unmount } = render( + , + ); + + await waitFor(() => strip(lastFrame()).includes('line-0')); + + const before = strip(lastFrame()); + + stdin.write('\x1B[B'); + stdin.write('\x04'); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(strip(lastFrame())).toBe(before); + + unmount(); + + }); + + it('should scroll on a wheel notch', async () => { + + // The reason this is not optional: the TUI runs in the alternate + // screen, which has no scrollback, and mouse tracking takes the wheel + // notches a terminal would otherwise turn into arrow keys. Unhandled + // here means the wheel does nothing at all. + const { stdin, lastFrame, unmount } = render( + + + , + ); + + await waitFor(() => strip(lastFrame()).includes('line-0')); + + await press(stdin, WHEEL_DOWN); + await press(stdin, WHEEL_DOWN); + + await waitFor(() => !strip(lastFrame()).includes('line-0')); + + expect(strip(lastFrame())).toContain('line-2'); + expect(strip(lastFrame())).not.toContain('line-0'); + + await press(stdin, WHEEL_UP); + + await waitFor(() => strip(lastFrame()).includes('line-1')); + + expect(strip(lastFrame())).toContain('line-1'); + + unmount(); + + }); + + it('should ignore a wheel notch aimed at a pane that is not focused', async () => { + + const { stdin, lastFrame, unmount } = render( + + + , + ); + + await waitFor(() => strip(lastFrame()).includes('line-0')); + + const before = strip(lastFrame()); + + await press(stdin, WHEEL_DOWN); + await press(stdin, WHEEL_DOWN); + + expect(strip(lastFrame())).toBe(before); + + unmount(); + + }); + + it('should draw content that fits without stealing a row for an indicator', async () => { + + const { lastFrame, unmount } = render( + , + ); + + await waitFor(() => strip(lastFrame()).includes('line-0')); + + const frame = strip(lastFrame()); + + expect(frame.split('\n')).toHaveLength(3); + expect(frame).not.toContain('more'); + + unmount(); + + }); + +}); diff --git a/tests/cli/screens/run/inspect-lines.test.tsx b/tests/cli/screens/run/inspect-lines.test.tsx new file mode 100644 index 00000000..06c26eef --- /dev/null +++ b/tests/cli/screens/run/inspect-lines.test.tsx @@ -0,0 +1,238 @@ +/** + * Inspect context line-builder tests. + * + * The inspect screen rendered a nested tree that grew with the context, so on + * any real project the bottom of it — secrets, global secrets, environment — + * sat below the fold with no key that could reach it. Ink has no scroll offset, + * so the fix is structural: the view is built as a flat list of one element per + * visual line, which is the only shape a viewport can slice. These tests pin + * that shape and what the lines are allowed to say. + * + * The secret cases are the load-bearing ones. `$.secrets` used to show a key + * count, which cannot answer the question the screen is opened to answer, and + * the fix is a partial reveal — so what is pinned is that the plaintext is + * absent and the masked form is present. Asserting only the latter would pass + * on a line that printed both. + */ +import { describe, it, expect } from 'bun:test'; +import { render } from 'ink-testing-library'; +import { Box } from 'ink'; +import React from 'react'; + +import type { CategorizedContext } from '../../../../src/tui/screens/run/RunInspectScreen.js'; + +import { contextLines, expandedLines } from '../../../../src/tui/screens/run/RunInspectScreen.js'; + +/** Row budget inside the inspect Panel on a 100-column terminal. */ +const WIDE = 96; + +// eslint-disable-next-line no-control-regex -- matching the ANSI SGR escape is the point +const ANSI_PATTERN = /\[[0-9;]*m/g; + +function draw(lines: React.ReactElement[]): string { + + const { lastFrame, unmount } = render({lines}); + const frame = (lastFrame() ?? '').replace(ANSI_PATTERN, ''); + + unmount(); + + return frame; + +} + +function makeContext(overrides: Partial = {}): CategorizedContext { + + return { + dataFiles: [{ key: 'users', value: [{ id: 1, name: 'ada' }] }], + helpers: [], + helperErrors: [], + builtins: [{ key: 'quote', value: () => '' }], + config: { host: 'localhost' }, + secrets: {}, + globalSecrets: {}, + env: {}, + ...overrides, + }; + +} + +describe('cli: inspect context lines', () => { + + it('should reveal part of a secret rather than only a key count', () => { + + const lines = contextLines( + makeContext({ secrets: { DB_PASSWORD: 'sup3rs3cr3tvalue' } }), + '/project', + WIDE, + ); + const frame = draw(lines); + + expect(frame).toContain('DB_PASSWORD'); + expect(frame).toContain('su*****alue'); + expect(frame).not.toContain('sup3rs3cr3tvalue'); + + }); + + it('should report the length as a number rather than as mask width', () => { + + const frame = draw(contextLines( + makeContext({ secrets: { TOKEN: 'sup3rs3cr3tvalue' } }), + '/project', + WIDE, + )); + + expect(frame).toContain('(16 chars)'); + + }); + + it('should tell a secret that is set but empty from one that is short', () => { + + const frame = draw(contextLines( + makeContext({ secrets: { BLANK: '', TINY: 'abc' } }), + '/project', + WIDE, + )); + + expect(frame).toContain('(empty)'); + expect(frame).toContain('(0 chars)'); + expect(frame).not.toContain('abc'); + + }); + + it('should mask global secrets on the same terms as config-scoped ones', () => { + + const frame = draw(contextLines( + makeContext({ globalSecrets: { LICENSE: 'aaaabbbbccccdddd' } }), + '/project', + WIDE, + )); + + expect(frame).toContain('aaaa'.slice(0, 2) + '*****' + 'dddd'); + expect(frame).not.toContain('aaaabbbbccccdddd'); + + }); + + it('should mask environment values, which carry secrets nothing here can identify', () => { + + const frame = draw(contextLines( + makeContext({ env: { AWS_SECRET_ACCESS_KEY: 'abcdefghijklmnopqrst' } }), + '/project', + WIDE, + )); + + expect(frame).toContain('AWS_SECRET_ACCESS_KEY'); + expect(frame).toContain('ab*****qrst'); + expect(frame).not.toContain('abcdefghijklmnopqrst'); + + }); + + it('should keep a multi-line secret to one row', () => { + + // `wrap="truncate"` bounds width, not height: Ink still breaks on an + // embedded newline, so one of these rows would draw three and put the + // viewport's count out by two for everything below it. + // + // The newline has to fall inside the revealed window to reach the + // screen at all. A whole PEM key does not test this: the mask keeps + // two leading and four trailing characters, which for + // `-----BEGIN…-----` are dashes, so the newlines never survive masking + // and the case passes whether or not the guard is there. + const lines = contextLines( + makeContext({ secrets: { TLS_KEY: '\nMIIEvQIBADANBgkq\n' } }), + '/project', + WIDE, + ); + + expect(draw(lines).split('\n')).toHaveLength(lines.length); + + }); + + it('should keep a multi-line environment value and helper error to one row', () => { + + const lines = contextLines( + makeContext({ + env: { SSH_KEY: 'line one\nline two\nline three' }, + helperErrors: [{ + filepath: '/project/sql/helpers/slug.js', + error: new Error('Unexpected token\n at line 3\n at line 4'), + }], + }), + '/project', + WIDE, + ); + + expect(draw(lines).split('\n')).toHaveLength(lines.length); + + }); + + it('should keep a data-file string preview with a newline to one row', () => { + + // `describeType` truncates a string preview by character count and was + // safe under the old nested tree, which counted no rows. + const lines = contextLines( + makeContext({ dataFiles: [{ key: 'banner', value: 'first line\nsecond line' }] }), + '/project', + WIDE, + ); + + expect(draw(lines).split('\n')).toHaveLength(lines.length); + + }); + + it('should draw one row per line, so a viewport can count them', () => { + + const lines = contextLines( + makeContext({ + secrets: Object.fromEntries( + Array.from({ length: 12 }, (_, index) => [`SECRET_${index}`, 'x'.repeat(20)]), + ), + }), + '/project', + WIDE, + ); + + expect(draw(lines).split('\n')).toHaveLength(lines.length); + + }); + + it('should omit a section that has nothing in it', () => { + + const frame = draw(contextLines(makeContext({ dataFiles: [] }), '/project', WIDE)); + + expect(frame).not.toContain('Data Files'); + expect(frame).toContain('Built-ins'); + + }); + + it('should name the helper that failed to load, and why', () => { + + const frame = draw(contextLines( + makeContext({ + helperErrors: [{ + filepath: '/project/sql/helpers/slug.js', + error: new Error('Unexpected token'), + }], + }), + '/project', + WIDE, + )); + + expect(frame).toContain('sql/helpers/slug.js'); + expect(frame).toContain('Unexpected token'); + + }); + + it('should wrap the expanded view so a narrow terminal still gets one row per line', () => { + + // A long key, not a long value: the expanded view reports shapes and + // never prints a value, so the key is what can overflow a row. + const value = { ['deeply_nested_'.repeat(30)]: 1 }; + const wide = expandedLines(makeContext({ dataFiles: [{ key: 'doc', value }] }), WIDE); + const narrow = expandedLines(makeContext({ dataFiles: [{ key: 'doc', value }] }), 20); + + expect(narrow.length).toBeGreaterThan(wide.length); + expect(draw(narrow).split('\n')).toHaveLength(narrow.length); + + }); + +}); diff --git a/tests/core/shared/mask.test.ts b/tests/core/shared/mask.test.ts new file mode 100644 index 00000000..51b72a08 --- /dev/null +++ b/tests/core/shared/mask.test.ts @@ -0,0 +1,119 @@ +/** + * maskSecret band tests. + * + * The bands exist so that the shorter a value is, the less of it a reader is + * shown: a four-character secret has no middle to hide, so revealing its ends + * would reveal the value. What is pinned here is how much of the input survives + * each band, not how the asterisks look — widening a band fails these tests + * even when the output still reads as masked, which is the point. A test that + * only checked for the presence of a `*` would pass on a full reveal with one + * asterisk appended. + */ +import { describe, it, expect } from 'bun:test'; + +import { maskSecret } from '../../../src/core/shared/mask.js'; + +/** Everything of the value that survived the mask, in order. */ +function revealed(value: string): string { + + return maskSecret(value).replaceAll('*', ''); + +} + +describe('core: maskSecret', () => { + + it('should distinguish a value that is set but empty from one that is masked', () => { + + expect(maskSecret('')).toBe('(empty)'); + + }); + + it('should reveal nothing of a value with no middle to hide', () => { + + for (const value of ['a', 'ab', 'abc', 'abcd']) { + + expect(maskSecret(value)).toBe('*****'); + expect(revealed(value)).toBe(''); + + } + + }); + + it('should reveal one character at each end from five through eight', () => { + + expect(maskSecret('abcde')).toBe('a*****e'); + expect(maskSecret('hunter2!')).toBe('h*****!'); + expect(revealed('hunter2!')).toHaveLength(2); + + }); + + it('should reveal only a suffix from nine through twelve', () => { + + expect(maskSecret('abcdefghi')).toBe('*****fghi'); + expect(maskSecret('abcdefghijkl')).toBe('*****ijkl'); + expect(revealed('abcdefghijkl')).toHaveLength(4); + + }); + + it('should reveal a short prefix and a suffix past twelve', () => { + + expect(maskSecret('abcdefghijklm')).toBe('ab*****jklm'); + expect(maskSecret('postgres://user:pw@host/db')).toBe('po*****t/db'); + + }); + + it('should reveal no more of a long value than of a barely-long one', () => { + + expect(revealed('x'.repeat(4096))).toHaveLength(6); + expect(revealed('abcdefghijklm')).toHaveLength(6); + + }); + + it('should leak no interior character, however recognisable', () => { + + const value = 'PREFIXsecretmiddleSUFFIX'; + + expect(maskSecret(value)).toBe('PR*****FFIX'); + expect(maskSecret(value)).not.toContain('secretmiddle'); + + }); + + it('should never split a surrogate pair', () => { + + // `.slice` counts UTF-16 code units, so slicing a non-BMP character in + // half yields a lone surrogate and the reveal renders as a replacement + // glyph — corrupting the one part a reader is meant to recognise. + const masked = maskSecret('🔥alpha-beta-gamma-omega🎉'); + + expect(masked).toBe('🔥a*****ega🎉'); + + // `for...of` walks code points, so a paired emoji arrives whole and a + // broken one arrives as a bare surrogate in D800-DFFF. + for (const character of masked) { + + const codePoint = character.codePointAt(0) ?? 0; + + expect(codePoint < 0xD800 || codePoint > 0xDFFF).toBe(true); + + } + + }); + + it('should count bands in characters, not code units', () => { + + // Four emoji are eight code units. Counting units would put this in the + // reveal-both-ends band, showing half of a four-character value. + expect(maskSecret('🔥🎉🚀🌟')).toBe('*****'); + + }); + + it('should never encode the length in the width of the mask', () => { + + const short = maskSecret('abcdefghijklm'); + const long = maskSecret('abcdefghijklm'.repeat(20)); + + expect(short.length).toBe(long.length); + + }); + +});