diff --git a/docs/developer/development.md b/docs/developer/development.md index b45f6da6..f9521f67 100644 --- a/docs/developer/development.md +++ b/docs/developer/development.md @@ -158,16 +158,47 @@ failed renderer build leaves the displayed renderer and last published assets untouched, and the next valid edit retries normally. An edit used by Electron's main process or local backend prints the message -`restart required` and leaves the running window untouched. Stop and restart the loop -when that change should enter the application; the loop never turns a runtime -edit into an automatic app restart. Watcher implementation updates hand the -running app to the replacement watcher without quitting it. Generated output, -dependency directories, Git metadata, and instance state do not trigger +`restart required` and leaves the running window untouched. Stop and restart +the loop when that change should enter the application; the loop never turns a +runtime edit into an automatic app restart. Watcher implementation updates hand +the running app to the replacement watcher without quitting it. Generated +output, dependency directories, Git metadata, and instance state do not trigger rebuilds. Keep only one loop per instance and use `npm start` for deterministic one-shot work. End the loop with Ctrl-C; it asks the addressed instance to quit through the managed durability path and waits for that process before returning. +### Shared development element callouts + +While the live development loop is running, Option-click a rendered element +whose path from its nearest unique ID is at most 128 elements. Markover pins a +bright bounding box to that element and copies one opaque `mko-ui-v1:` +reference to the clipboard. Deeper targets report that they cannot be +referenced and copy nothing. Paste a copied reference into the agent thread; no +screenshot, DevTools inspection, or hand-drawn circle is needed. + +An agent highlights the same element in the addressed running instance with: + +```sh +npm --silent run markover -- --instance dev element highlight '' +``` + +Clear the pinned box with: + +```sh +npm --silent run markover -- --instance dev element clear +``` + +Omit `--instance dev`, or pass `--instance canonical`, when `npm run dev` +addresses the canonical checkout instead of a pull-request worktree. Element +commands address only an already-running watcher and never cold-start Markover. + +References use a validated unique-ID anchor and deterministic same-tag child +indices, sibling counts, and structural fingerprints. +Stale or ambiguous references fail instead of selecting a different element. +The picker and authenticated highlight route exist only in a running live +development watcher; release and non-watch instances do not expose them. + ## Development review links Install or inspect the forwarding-only handler for the current worktree's open diff --git a/scripts/app-layout.ts b/scripts/app-layout.ts index a99149f6..0c70e336 100644 --- a/scripts/app-layout.ts +++ b/scripts/app-layout.ts @@ -14,6 +14,7 @@ export const runtimeModuleNames = [ 'codex-thread-titles', 'development-control', 'development-config', + 'development-element', 'durability-shutdown', 'ipc-contract', 'ipc-security', diff --git a/scripts/markover.ts b/scripts/markover.ts index 1ee515be..eabafb9d 100644 --- a/scripts/markover.ts +++ b/scripts/markover.ts @@ -41,6 +41,7 @@ import { import { serviceEndpointPath } from '../src/service-endpoint' import { guidance } from '../src/agent-guidance' import { normalizeSettings } from '../src/settings' +import { isDevelopmentElementCalloutResult } from '../src/development-element' import { parseMarkdown } from '../src/tree' import { cleanupDevelopmentInstance, @@ -110,6 +111,8 @@ export type ParsedCommand = ParsedInstanceTarget & ( | { command: 'canonical'; action: 'doctor' } | { command: 'canonical'; action: 'refresh'; install: boolean } | { command: 'cleanup'; expectedIdentity: `pr-${number}` } + | { command: 'element'; action: 'clear' } + | { command: 'element'; action: 'highlight'; reference: string } | { command: 'edit'; reviewId: string } | { command: 'resolve' @@ -309,6 +312,11 @@ export function helpPayload() { usage: '--instance dev cleanup ', purpose: 'Move one stopped worktree-local instance to macOS Trash after its development URL handler has been removed.' }, + { + name: 'element', + usage: '[--instance ] element highlight | [--instance ] element clear', + purpose: 'Highlight or clear one exact element callout in the addressed running live development window.' + }, { name: 'help', aliases: ['info', '--help', '-h'], @@ -398,7 +406,8 @@ export function parseCommandArguments(args: string[]): ParsedCommand { command !== 'pending' && command !== 'resolve' && command !== 'unresolve' && - command !== 'cleanup' + command !== 'cleanup' && + command !== 'element' ) { if (command === 'check') { throw commandError( @@ -414,7 +423,28 @@ export function parseCommandArguments(args: string[]): ParsedCommand { } throw commandError( `Unknown command: ${command}`, - 'markover ...' + 'markover ...' + ) + } + + if (command === 'element') { + if (rest.length === 1 && rest[0] === 'clear') { + return targeted({ command, action: 'clear' as const }) + } + if ( + rest.length === 2 && + rest[0] === 'highlight' && + rest[1]?.startsWith('mko-ui-v1:') + ) { + return targeted({ + command, + action: 'highlight' as const, + reference: rest[1] + }) + } + throw commandError( + 'element requires highlight with one copied reference, or clear.', + 'markover [--instance ] element highlight | markover [--instance ] element clear' ) } @@ -1152,6 +1182,7 @@ export async function ensureService({ export interface ExecuteCommandOptions { endpointPath?: string + requestLocal?: typeof requestJson ensure?: () => Promise resolveTarget?: ( selector: InstanceSelector, @@ -1622,6 +1653,31 @@ export async function executeCommand( const cleanup = options.cleanup || cleanupDevelopmentInstance return cleanup(instance, parsed.expectedIdentity) } + if (parsed.command === 'element') { + const endpointPath = options.endpointPath || ( + await resolveTarget(selector) + ).service.endpointPath + const result = await (options.requestLocal || requestJson)( + endpointPath, + 'POST', + '/development/element-callout', + parsed.action === 'clear' + ? { action: 'clear' } + : { action: 'highlight', reference: parsed.reference } + ) + if (!isDevelopmentElementCalloutResult(result)) { + throw new LocalServiceError( + 'INVALID_RESPONSE', + 'Markover returned an invalid development element callout response.', + 200 + ) + } + return { + ...(result.bounds ? { bounds: result.bounds } : {}), + ...(result.reference ? { reference: result.reference } : {}), + status: result.status + } + } const profile = parsed.instance === undefined ? await (options.loadRemoteProfile || loadRemoteProfile)() diff --git a/src/contracts.ts b/src/contracts.ts index 1d86943c..fc87d527 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -7,6 +7,10 @@ import type { StartupPhaseEvent, StartupReady } from './startup-contract' +import type { + DevelopmentElementCalloutCommand, + DevelopmentElementCalloutResult +} from './development-element' export interface DiffStats { additions: number @@ -754,6 +758,11 @@ declare global { createLocalReview: (tree: ReviewTree) => Promise onOpenMarkdownRequested: (callback: () => void) => void onReviewBatchModeRequested: (callback: () => void) => void + onDevelopmentElementCallout: ( + callback: ( + command: DevelopmentElementCalloutCommand + ) => DevelopmentElementCalloutResult | Promise + ) => void checksum: (source: string) => Promise copyText: (text: string) => void readClipboardImage: () => Promise diff --git a/src/development-element.ts b/src/development-element.ts new file mode 100644 index 00000000..c0655658 --- /dev/null +++ b/src/development-element.ts @@ -0,0 +1,503 @@ +const REFERENCE_PREFIX = 'mko-ui-v1:' +const MAXIMUM_REFERENCE_LENGTH = 32 * 1024 +const MAXIMUM_ANCHOR_LENGTH = 512 +const MAXIMUM_PATH_LENGTH = 128 +const ELEMENT_NAME_PATTERN = /^[a-z][a-z0-9-]{0,63}$/ + +interface DevelopmentElementPathSegment { + count: number + fingerprint: string + index: number + name: string +} + +interface DevelopmentElementReferencePayload { + anchorId: string | null + path: DevelopmentElementPathSegment[] + version: 1 +} + +export interface DevelopmentElementBounds { + height: number + width: number + x: number + y: number +} + +export interface DevelopmentElementCalloutCommand { + action: 'clear' | 'highlight' + reference?: string | undefined + requestId: string +} + +export interface DevelopmentElementCalloutRequest { + action: 'clear' | 'highlight' + reference?: string | undefined +} + +export interface DevelopmentElementCalloutResult { + bounds?: DevelopmentElementBounds | undefined + reference?: string | undefined + requestId: string + status: 'ambiguous' | 'cleared' | 'highlighted' | 'stale' +} + +export interface DevelopmentElementCallouts { + clear: () => void + handle: ( + command: DevelopmentElementCalloutCommand + ) => DevelopmentElementCalloutResult + reposition: () => DevelopmentElementBounds | null +} + +interface DevelopmentElementCalloutOptions { + copyText: (reference: string) => void + notify: (message: string) => void +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function encodeBase64Url(value: string): string { + const bytes = new TextEncoder().encode(value) + let binary = '' + for (const byte of bytes) binary += String.fromCharCode(byte) + return btoa(binary) + .replaceAll('+', '-') + .replaceAll('/', '_') + .replace(/=+$/, '') +} + +function decodeBase64Url(value: string): string | null { + if (!/^[A-Za-z0-9_-]+$/.test(value)) return null + const padding = (4 - (value.length % 4)) % 4 + try { + const binary = atob( + value.replaceAll('-', '+').replaceAll('_', '/') + '='.repeat(padding) + ) + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)) + return new TextDecoder('utf-8', { fatal: true }).decode(bytes) + } catch { + return null + } +} + +function isPayload(value: unknown): value is DevelopmentElementReferencePayload { + if (!isRecord(value) || value.version !== 1) return false + if ( + value.anchorId !== null && + ( + typeof value.anchorId !== 'string' || + !value.anchorId || + value.anchorId.length > MAXIMUM_ANCHOR_LENGTH + ) + ) return false + if (!Array.isArray(value.path) || value.path.length > MAXIMUM_PATH_LENGTH) { + return false + } + return value.path.every((segment) => ( + isRecord(segment) && + Object.keys(segment).sort().join(',') === 'count,fingerprint,index,name' && + typeof segment.name === 'string' && + ELEMENT_NAME_PATTERN.test(segment.name) && + typeof segment.fingerprint === 'string' && + /^[a-f0-9]{32}$/.test(segment.fingerprint) && + Number.isInteger(segment.count) && + (segment.count as number) > 0 && + Number.isInteger(segment.index) && + (segment.index as number) >= 0 && + (segment.index as number) < (segment.count as number) + )) +} + +function decodeReference( + reference: string +): DevelopmentElementReferencePayload | null { + if ( + !reference.startsWith(REFERENCE_PREFIX) || + reference.length > MAXIMUM_REFERENCE_LENGTH + ) return null + const encoded = reference.slice(REFERENCE_PREFIX.length) + const decoded = decodeBase64Url(encoded) + if (decoded === null) return null + let value: unknown + try { + value = JSON.parse(decoded) + } catch { + return null + } + if (!isPayload(value)) return null + return `${REFERENCE_PREFIX}${encodeBase64Url(JSON.stringify(value))}` === reference + ? value + : null +} + +export function isDevelopmentElementReference(value: unknown): value is string { + return typeof value === 'string' && decodeReference(value) !== null +} + +export function isDevelopmentElementCalloutCommand( + value: unknown +): value is DevelopmentElementCalloutCommand { + if (!isRecord(value) || !/^element-callout-[1-9]\d*$/.test(String(value.requestId))) { + return false + } + if (value.action === 'clear') { + return Object.keys(value).length === 2 && value.reference === undefined + } + return value.action === 'highlight' && + Object.keys(value).length === 3 && + isDevelopmentElementReference(value.reference) +} + +export function isDevelopmentElementCalloutRequest( + value: unknown +): value is DevelopmentElementCalloutRequest { + if (!isRecord(value)) return false + if (value.action === 'clear') { + return Object.keys(value).length === 1 && value.reference === undefined + } + return value.action === 'highlight' && + Object.keys(value).length === 2 && + isDevelopmentElementReference(value.reference) +} + +export function isDevelopmentElementCalloutResult( + value: unknown +): value is DevelopmentElementCalloutResult { + if ( + !isRecord(value) || + !/^element-callout-[1-9]\d*$/.test(String(value.requestId)) || + !['ambiguous', 'cleared', 'highlighted', 'stale'].includes(String(value.status)) + ) return false + const allowed = new Set(['bounds', 'reference', 'requestId', 'status']) + if (Object.keys(value).some((key) => !allowed.has(key))) return false + if (value.reference !== undefined && !isDevelopmentElementReference(value.reference)) { + return false + } + if (value.status === 'cleared') { + return value.reference === undefined && value.bounds === undefined + } + if (value.reference === undefined) return false + if (value.status !== 'highlighted') return value.bounds === undefined + const bounds = value.bounds + if (!isRecord(bounds)) return false + return Object.keys(bounds).sort().join(',') === 'height,width,x,y' && + ['height', 'width', 'x', 'y'].every((key) => ( + typeof bounds[key] === 'number' && + Number.isFinite(bounds[key]) + )) +} + +function uniqueIdAnchor( + element: Element, + document: Document +): Element | null { + let current: Element | null = element + while (current) { + if ( + current.id && + [...document.querySelectorAll('[id]')].filter( + (candidate) => candidate.id === current?.id + ).length === 1 + ) return current + current = current.parentElement + } + return null +} + +function segmentFor(element: Element): DevelopmentElementPathSegment { + const parent = element.parentElement + if (!parent) throw new Error('Development element is outside the document tree.') + const siblings = [...parent.children].filter( + (candidate) => candidate.localName === element.localName + ) + const index = siblings.indexOf(element) + if (index < 0 || !ELEMENT_NAME_PATTERN.test(element.localName)) { + throw new Error('Development element path is invalid.') + } + return { + count: siblings.length, + fingerprint: elementFingerprint(element), + name: element.localName, + index + } +} + +const FNV_PRIME_64 = 0x100000001b3n +const FNV_OFFSET_64 = 0xcbf29ce484222325n +const FNV_SECOND_OFFSET_64 = 0x6c62272e07bb0142n + +function fingerprintPart(bytes: Uint8Array, seed: bigint): string { + let hash = seed + for (const byte of bytes) { + hash ^= BigInt(byte) + hash = BigInt.asUintN(64, hash * FNV_PRIME_64) + } + return hash.toString(16).padStart(16, '0') +} + +function elementFingerprint(element: Element): string { + const snapshot = element.cloneNode(true) as Element + const candidates = [snapshot, ...snapshot.querySelectorAll('*')] + for (const candidate of candidates) { + if (candidate.hasAttribute('data-markover-development-callout')) { + candidate.remove() + continue + } + candidate.removeAttribute('style') + const stableClasses = [...candidate.classList] + .filter((name) => !name.startsWith('is-') && !name.startsWith('has-')) + .sort() + if (stableClasses.length) candidate.setAttribute('class', stableClasses.join(' ')) + else candidate.removeAttribute('class') + for (const attribute of [ + 'aria-activedescendant', + 'aria-busy', + 'aria-checked', + 'aria-current', + 'aria-disabled', + 'aria-expanded', + 'aria-hidden', + 'aria-pressed', + 'aria-selected', + 'tabindex' + ]) candidate.removeAttribute(attribute) + } + const bytes = new TextEncoder().encode(snapshot.outerHTML) + return `${fingerprintPart(bytes, FNV_OFFSET_64)}${fingerprintPart( + bytes, + FNV_SECOND_OFFSET_64 + )}` +} + +export function developmentElementReference( + element: Element, + document: Document +): string { + if (!document.documentElement.contains(element) && element !== document.documentElement) { + throw new Error('Development element is outside the document tree.') + } + const anchor = uniqueIdAnchor(element, document) + const path: DevelopmentElementPathSegment[] = [] + let current = element + while (current !== (anchor || document.documentElement)) { + if (path.length >= MAXIMUM_PATH_LENGTH) { + throw new Error('Development element path exceeds the supported depth.') + } + path.unshift(segmentFor(current)) + const parent = current.parentElement + if (!parent) throw new Error('Development element path is incomplete.') + current = parent + } + const payload: DevelopmentElementReferencePayload = { + anchorId: anchor?.id || null, + path, + version: 1 + } + const reference = `${REFERENCE_PREFIX}${encodeBase64Url(JSON.stringify(payload))}` + if (!isDevelopmentElementReference(reference)) { + throw new Error('Development element reference exceeds the supported limits.') + } + return reference +} + +export function resolveDevelopmentElementReference( + reference: string, + document: Document +): { element: Element; status: 'found' } | { status: 'ambiguous' | 'stale' } { + const payload = decodeReference(reference) + if (!payload) return { status: 'stale' } + let current: Element + if (payload.anchorId !== null) { + const anchors = [...document.querySelectorAll('[id]')].filter( + (candidate) => candidate.id === payload.anchorId + ) + if (anchors.length > 1) return { status: 'ambiguous' } + if (anchors.length === 0) return { status: 'stale' } + current = anchors[0] as Element + } else { + current = document.documentElement + } + for (const segment of payload.path) { + const candidates = [...current.children].filter( + (candidate) => candidate.localName === segment.name + ) + if (candidates.length !== segment.count) return { status: 'stale' } + const next = candidates[segment.index] + if (!next || elementFingerprint(next) !== segment.fingerprint) { + return { status: 'stale' } + } + current = next + } + return { element: current, status: 'found' } +} + +function roundedBounds(element: Element): DevelopmentElementBounds { + const bounds = element.getBoundingClientRect() + return { + height: Math.round(bounds.height), + width: Math.round(bounds.width), + x: Math.round(bounds.x), + y: Math.round(bounds.y) + } +} + +function hasRenderedVisibility(element: Element, document: Document): boolean { + const view = document.defaultView + if (!view) return true + let current: Element | null = element + while (current) { + const style = view.getComputedStyle(current) + if ( + style.display === 'none' || + style.visibility === 'hidden' || + style.visibility === 'collapse' || + style.opacity === '0' || + style.getPropertyValue('content-visibility') === 'hidden' + ) return false + current = current.parentElement + } + return true +} + +function calloutLabel(element: Element): string { + return element.id ? `${element.localName}#${element.id}` : element.localName +} + +export function installDevelopmentElementCallouts( + document: Document, + { copyText, notify }: DevelopmentElementCalloutOptions +): DevelopmentElementCallouts { + const overlay = document.createElement('div') + overlay.className = 'development-element-callout' + overlay.dataset.markoverDevelopmentCallout = 'true' + overlay.hidden = true + const label = document.createElement('span') + label.className = 'development-element-callout-label' + overlay.append(label) + document.body.append(overlay) + let pinned: Element | null = null + let pinnedReference: string | null = null + + const clear = (): void => { + pinned = null + pinnedReference = null + overlay.hidden = true + } + const position = (): DevelopmentElementBounds | null => { + if ( + !pinned || + !document.documentElement.contains(pinned) || + !hasRenderedVisibility(pinned, document) + ) { + clear() + return null + } + const bounds = roundedBounds(pinned) + if (bounds.width <= 0 || bounds.height <= 0) { + clear() + return null + } + overlay.style.left = `${String(bounds.x)}px` + overlay.style.top = `${String(bounds.y)}px` + overlay.style.width = `${String(bounds.width)}px` + overlay.style.height = `${String(bounds.height)}px` + label.textContent = calloutLabel(pinned) + overlay.hidden = false + return bounds + } + const pin = ( + element: Element, + reference: string + ): DevelopmentElementBounds | null => { + pinned = element + pinnedReference = reference + return position() + } + const MutationObserver = document.defaultView?.MutationObserver + const observer = MutationObserver + ? new MutationObserver((records) => { + if ( + pinned && + records.some((record) => !overlay.contains(record.target)) + ) position() + }) + : null + observer?.observe(document.documentElement, { + attributes: true, + childList: true, + subtree: true + }) + + const isPickerGesture = (event: MouseEvent): boolean => ( + event.button === 0 && + event.altKey && + !event.ctrlKey && + !event.metaKey && + !event.shiftKey + ) + document.addEventListener('pointerdown', (event) => { + if (!isPickerGesture(event)) return + const target = event.target as Node | null + if (!target || target.nodeType !== 1) return + const element = target as Element + if (overlay.contains(element)) return + event.preventDefault() + event.stopImmediatePropagation() + try { + const reference = developmentElementReference(element, document) + if (!pin(element, reference)) { + throw new Error('Development element has no visible bounds.') + } + copyText(reference) + notify('Element reference copied') + } catch { + clear() + notify('Element cannot be referenced') + } + }, true) + document.addEventListener('click', (event) => { + if (!isPickerGesture(event)) return + event.preventDefault() + event.stopImmediatePropagation() + }, true) + document.defaultView?.addEventListener('resize', () => { position() }) + document.addEventListener('scroll', () => { position() }, true) + + return { + clear, + handle(command) { + if (command.action === 'clear') { + clear() + return { requestId: command.requestId, status: 'cleared' } + } + const reference = command.reference as string + const resolved = resolveDevelopmentElementReference(reference, document) + if (resolved.status !== 'found') { + clear() + return { + reference, + requestId: command.requestId, + status: resolved.status + } + } + const bounds = pin(resolved.element, reference) + if (!bounds) { + return { + reference, + requestId: command.requestId, + status: 'stale' + } + } + return { + bounds, + reference: pinnedReference as string, + requestId: command.requestId, + status: 'highlighted' + } + }, + reposition: position + } +} diff --git a/src/ipc-contract.ts b/src/ipc-contract.ts index be49f0c3..d4029f87 100644 --- a/src/ipc-contract.ts +++ b/src/ipc-contract.ts @@ -9,6 +9,12 @@ import type { StartupReady, StartupWarning } from './startup-contract' +import { + isDevelopmentElementCalloutCommand, + isDevelopmentElementCalloutResult, + type DevelopmentElementCalloutCommand, + type DevelopmentElementCalloutResult +} from './development-element' import { isReviewArtifact, isReviewTree } from './review-format' import { isWorkspaceState } from './workspace-state' @@ -145,6 +151,7 @@ export interface RendererSendArguments { 'clipboard:write': [string] 'review:activate': [string] 'review:autosave': [string, ReviewTree] + 'development:element-callout-response': [DevelopmentElementCalloutResult] } export interface MainEventArguments { @@ -162,6 +169,7 @@ export interface MainEventArguments { 'review:activation-request': [ReviewActivationRequest] 'review:resolution-confirmation-request': [ReviewResolutionConfirmationRequest] 'review:trashed': [ReviewTrashedEvent] + 'development:element-callout': [DevelopmentElementCalloutCommand] } export type RendererInvokeChannel = keyof RendererInvokeArguments @@ -358,12 +366,14 @@ function isStartupInfo(value: unknown): value is StartupInfo { return hasExactKeys(value, [ 'development', 'diagnosticPath', + 'elementCallouts', 'holdPhase', 'failPhase', 'smoke' ]) && typeof value.development === 'boolean' && typeof value.diagnosticPath === 'string' && + typeof value.elementCallouts === 'boolean' && (value.holdPhase === null || isStartupPhaseValue(value.holdPhase)) && (value.failPhase === null || isStartupPhaseValue(value.failPhase)) && typeof value.smoke === 'boolean' @@ -907,6 +917,9 @@ export function assertRendererSendArguments( isReviewSessionTree(args[1], args[0]) && args[1].review.status === 'editing' break + case 'development:element-callout-response': + valid = singleArgument(args, isDevelopmentElementCalloutResult) + break } if (!valid) throw new IpcContractError(channel, 'renderer-to-main send') } @@ -1020,6 +1033,9 @@ export function assertMainEventArguments( valid = singleArgument(args, isResolutionConfirmationRequest) break case 'review:trashed': valid = singleArgument(args, isReviewTrashedEvent); break + case 'development:element-callout': + valid = singleArgument(args, isDevelopmentElementCalloutCommand) + break } if (!valid) throw new IpcContractError(channel, 'main-to-renderer event') } diff --git a/src/local-service.ts b/src/local-service.ts index 4589b124..041205bb 100644 --- a/src/local-service.ts +++ b/src/local-service.ts @@ -26,6 +26,11 @@ import { isPullRequestStatus, parseGitHubPullRequestUrl } from './pull-request' +import { + isDevelopmentElementCalloutRequest, + type DevelopmentElementCalloutRequest, + type DevelopmentElementCalloutResult +} from './development-element' export const MAXIMUM_BODY_BYTES = 16 * 1024 * 1024 export const INTERNAL_REMOTE_CREATE_PATH = '/internal/remote/reviews' @@ -129,6 +134,9 @@ export interface LocalServiceOptions { action: LocalServiceChangeAction ) => void | Promise) | undefined onDevelopmentReload?: (() => Promise) | undefined + onDevelopmentElementCallout?: (( + request: DevelopmentElementCalloutRequest + ) => Promise) | undefined onQuit?: (() => void) | undefined onUnauthorized?: ((event: UnauthorizedRequest) => void) | undefined interpretationPolicy?: (() => string) | undefined @@ -252,13 +260,18 @@ function errorStatus(error: unknown): number { const code = errorProperty(error, 'code') if (code === 'ACTIVATION_NOT_READY') return 503 if (code === 'ACTIVATION_TIMEOUT') return 504 - if (code === 'NOT_FOUND' || code === 'RECEIPT_NOT_FOUND') return 404 + if ( + code === 'DEVELOPMENT_ELEMENT_REFERENCE_STALE' || + code === 'NOT_FOUND' || + code === 'RECEIPT_NOT_FOUND' + ) return 404 if ( code === 'UNSUPPORTED_REVIEW_FORMAT' || code === 'UNSUPPORTED_REVIEW_VERSION' ) return 409 if ( code === 'INVALID_ID' || + code === 'INVALID_DEVELOPMENT_ELEMENT_CALLOUT' || code === 'INVALID_CREATION_RECEIPT' || code === 'INVALID_JSON' || code === 'INVALID_PULL_REQUEST' || @@ -275,6 +288,7 @@ function errorStatus(error: unknown): number { } if ( code === 'CLAIM_CONFLICT' || + code === 'DEVELOPMENT_ELEMENT_REFERENCE_AMBIGUOUS' || code === 'CREATION_RECEIPT_SCAN_INCOMPLETE' || code === 'DUPLICATE_CREATION_RECEIPT' || code === 'FEEDBACK_REQUIRED' || @@ -287,6 +301,8 @@ function errorStatus(error: unknown): number { code === 'SUBMISSION_CONFLICT' ) return 409 if (code === 'SHUTTING_DOWN') return 503 + if (code === 'DEVELOPMENT_ELEMENT_CALLOUT_NOT_READY') return 503 + if (code === 'DEVELOPMENT_ELEMENT_CALLOUT_TIMEOUT') return 504 if (code === 'REQUEST_UNCERTAIN') return 503 if (code === 'BODY_TOO_LARGE') return 413 return 500 @@ -313,6 +329,7 @@ export async function startLocalService({ 'Review activation is unavailable.' )), onChange = () => {}, + onDevelopmentElementCallout, onDevelopmentReload, onQuit = () => {}, onUnauthorized = () => {}, @@ -433,6 +450,35 @@ export async function startLocalService({ const url = new URL(request.url || '', 'http://127.0.0.1') + if ( + request.method === 'POST' && + url.pathname === '/development/element-callout' && + onDevelopmentElementCallout + ) { + const body = await readJson(request) + if (!isDevelopmentElementCalloutRequest(body)) { + throw serviceError( + 'INVALID_DEVELOPMENT_ELEMENT_CALLOUT', + 'Development element callout input is invalid.' + ) + } + const result = await onDevelopmentElementCallout(body) + if (result.status === 'stale') { + throw serviceError( + 'DEVELOPMENT_ELEMENT_REFERENCE_STALE', + 'The development element reference is stale.' + ) + } + if (result.status === 'ambiguous') { + throw serviceError( + 'DEVELOPMENT_ELEMENT_REFERENCE_AMBIGUOUS', + 'The development element reference is ambiguous.' + ) + } + sendJson(response, 200, result) + return + } + if ( request.method === 'POST' && url.pathname === '/development/reload' && diff --git a/src/main.ts b/src/main.ts index 0df9239c..0b7e7e4e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -36,6 +36,10 @@ import { isDevelopmentControlQuit } from './development-control' import { loadDevelopmentConfig } from './development-config' +import type { + DevelopmentElementCalloutRequest, + DevelopmentElementCalloutResult +} from './development-element' import { persistReviewSnapshots, runDurabilityShutdown @@ -172,6 +176,12 @@ interface PendingResolutionConfirmation { timeout: ReturnType } +interface PendingElementCallout { + reject: (reason?: unknown) => void + resolve: (result: DevelopmentElementCalloutResult) => void + timeout: ReturnType +} + interface PendingReviewUrl { focusState: MarkoverWindowFocusState parsed: ReviewUrl @@ -271,6 +281,7 @@ let snapshotSequence = 0 let statusSequence = 0 let activationSequence = 0 let resolutionSequence = 0 +let elementCalloutSequence = 0 let brandAssetsPromise: Promise | null = null let startupDiagnostic: StartupDiagnostic | null = null let startupBuildIdentity: BuildIdentity | null = null @@ -296,6 +307,7 @@ const pendingResolutionConfirmations = new Map< string, PendingResolutionConfirmation >() +const pendingElementCallouts = new Map() const pendingManagedReviewNotifications = new Map() const reviewProjectContexts = new Map>() const projectFavicons = new Map>() @@ -1561,6 +1573,40 @@ async function requestRendererActivation( }) } +async function requestDevelopmentElementCallout( + request: DevelopmentElementCalloutRequest +): Promise { + if ( + !developmentWatchMode || + !mainWindow || + mainWindow.isDestroyed() || + !startupReady + ) { + throw Object.assign( + new Error('Development element callouts require a running live development window.'), + { code: 'DEVELOPMENT_ELEMENT_CALLOUT_NOT_READY' } + ) + } + const window = mainWindow + await waitForRendererReady(window) + elementCalloutSequence += 1 + const requestId = `element-callout-${String(elementCalloutSequence)}` + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + pendingElementCallouts.delete(requestId) + reject(Object.assign( + new Error('Timed out waiting for the development element callout.'), + { code: 'DEVELOPMENT_ELEMENT_CALLOUT_TIMEOUT' } + )) + }, 5000) + pendingElementCallouts.set(requestId, { reject, resolve, timeout }) + sendMainEvent(window.webContents, 'development:element-callout', { + ...request, + requestId + }) + }) +} + function reviewResolutionSummary( artifact: ReviewArtifact ): ReviewResolutionSummary { @@ -1899,7 +1945,10 @@ async function startAndPublishService(): Promise { ), onActivate: activateManagedReview, ...(developmentWatchMode - ? { onDevelopmentReload: reloadDevelopmentRenderer } + ? { + onDevelopmentElementCallout: requestDevelopmentElementCallout, + onDevelopmentReload: reloadDevelopmentRenderer + } : {}), onQuit() { app.quit() @@ -2189,6 +2238,7 @@ if (!hasSingleInstanceLock) { startupInfo = { development: developmentRuntime, diagnosticPath: startupDiagnosticPath, + elementCallouts: developmentWatchMode, smoke: smokeMode, ...controls } @@ -2620,6 +2670,16 @@ if (!hasSingleInstanceLock) { ) => { requireManagedAutosave().queue(reviewId, tree) }) + privilegedIpc.on('development:element-callout-response', ( + _event: IpcMainEvent, + result: DevelopmentElementCalloutResult + ) => { + const pending = pendingElementCallouts.get(result.requestId) + if (!pending) return + clearTimeout(pending.timeout) + pendingElementCallouts.delete(result.requestId) + pending.resolve(result) + }) createWindow() diff --git a/src/preload.ts b/src/preload.ts index 141d5617..1eeed5be 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -16,6 +16,10 @@ import { type RendererSendArguments, type RendererSendChannel } from './ipc-contract' +import type { + DevelopmentElementCalloutCommand, + DevelopmentElementCalloutResult +} from './development-element' function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error) @@ -135,6 +139,34 @@ async function respondToReviewResolutionConfirmation( } satisfies ReviewResolutionConfirmationResponse) } +async function respondToDevelopmentElementCallout( + callback: ( + command: DevelopmentElementCalloutCommand + ) => DevelopmentElementCalloutResult | Promise, + command: DevelopmentElementCalloutCommand +): Promise { + try { + send( + 'development:element-callout-response', + await callback(command) + ) + } catch { + send( + 'development:element-callout-response', + command.action === 'highlight' + ? { + reference: command.reference, + requestId: command.requestId, + status: 'stale' + } + : { + requestId: command.requestId, + status: 'cleared' + } + ) + } +} + const bridge = { getStartupInfo: () => invoke('startup:info'), reportStartupPhase: (event) => invoke('startup:phase', event), @@ -163,6 +195,11 @@ const bridge = { callback() }) }, + onDevelopmentElementCallout: (callback) => { + listen('development:element-callout', (command) => { + void respondToDevelopmentElementCallout(callback, command) + }) + }, checksum: (source) => invoke('document:checksum', source), copyText: (text) => { send('clipboard:write', text) diff --git a/src/renderer.ts b/src/renderer.ts index 1389452b..e99e4794 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -12,6 +12,10 @@ import * as MarkoverAgentGuidance from './agent-guidance' import * as MarkoverAnnotationBlock from './annotation-block' import * as MarkoverAnnotations from './annotations' import { autosaveFailureMessage } from './durability-status' +import { + installDevelopmentElementCallouts, + type DevelopmentElementCallouts +} from './development-element' import { appendIncomingReview, incomingReviewAction, @@ -283,6 +287,7 @@ let sourceDiffCleanup: (() => void) | null = null let sourceDiffModule: Promise | null = null let sourceDiffRenderer: DiffRenderer | null = null let paneResizeLayoutFrame: number | null = null +let developmentElementCallouts: DevelopmentElementCallouts | null = null let statusAnnouncementFrame: number | null = null let imagePreviewReturnFocus: HTMLElement | null = null let resolutionDialogCompletion: ((confirmed: boolean) => void) | null = null @@ -546,6 +551,7 @@ const BRIDGE_METHODS = [ 'getWorkspaceState', 'getWindowFocusState', 'onOpenMarkdownRequested', + 'onDevelopmentElementCallout', 'onReviewOpened', 'onReviewUpdated', 'onReviewTrashed', @@ -3660,6 +3666,7 @@ function schedulePaneLayoutResizeUpdate(): void { paneResizeLayoutFrame = requestAnimationFrame(() => { paneResizeLayoutFrame = null updatePinnedSelection() + developmentElementCallouts?.reposition() MarkoverAnnotationBlock.updateTruncation(elements.annotationList) }) } @@ -5098,6 +5105,14 @@ async function rendererSmokeResult(): Promise<{ async function initialize(): Promise { const startupInfo = await bridge.getStartupInfo() startupUi.development(startupInfo.development) + if (startupInfo.elementCallouts) { + const callouts = installDevelopmentElementCallouts(document, { + copyText: bridge.copyText, + notify: showToast + }) + developmentElementCallouts = callouts + bridge.onDevelopmentElementCallout((command) => callouts.handle(command)) + } bridge.onWindowFocusChanged((focusState) => { windowFocusStateVersion += 1 windowFocusState = focusState diff --git a/src/startup-contract.ts b/src/startup-contract.ts index db8b5028..adf59d28 100644 --- a/src/startup-contract.ts +++ b/src/startup-contract.ts @@ -30,6 +30,7 @@ export interface BuildIdentity { export interface StartupInfo { development: boolean diagnosticPath: string + elementCallouts: boolean holdPhase: StartupPhase | null failPhase: StartupPhase | null smoke: boolean diff --git a/src/styles.css b/src/styles.css index 592afdad..e82f76c8 100644 --- a/src/styles.css +++ b/src/styles.css @@ -78,6 +78,32 @@ --pane-label-highlight: var(--markover-secondary); } +.development-element-callout { + position: fixed; + z-index: 2147483647; + box-sizing: border-box; + border: 2px solid #ff2ea6; + border-radius: 4px; + background: rgb(255 46 166 / 10%); + box-shadow: 0 0 0 1px rgb(255 255 255 / 80%); + pointer-events: none; +} + +.development-element-callout-label { + position: absolute; + bottom: 100%; + left: -2px; + max-width: min(420px, 80vw); + padding: 3px 6px; + overflow: hidden; + border-radius: 4px 4px 0 0; + background: #ff2ea6; + color: white; + font: 600 11px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; + text-overflow: ellipsis; + white-space: nowrap; +} + :root[data-palette="olive"] { --markover-primary: #4e5828; --markover-secondary: #b5d52a; diff --git a/test/development-element.test.ts b/test/development-element.test.ts new file mode 100644 index 00000000..9cd02dd7 --- /dev/null +++ b/test/development-element.test.ts @@ -0,0 +1,461 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import test from 'node:test' + +import { JSDOM } from 'jsdom' + +import { + developmentElementReference, + installDevelopmentElementCallouts, + isDevelopmentElementCalloutCommand, + isDevelopmentElementReference, + resolveDevelopmentElementReference +} from '../src/development-element' + +const root = path.resolve(__dirname, '../..') + +test('one Option-click pins an element and copies its stable reference', () => { + const dom = new JSDOM('
') + const { document, MouseEvent } = dom.window + const button = document.querySelector('button') as HTMLButtonElement + button.getBoundingClientRect = () => ({ + bottom: 62, + height: 42, + left: 10, + right: 130, + toJSON: () => ({}), + top: 20, + width: 120, + x: 10, + y: 20 + }) + const copied: string[] = [] + const notices: string[] = [] + const callouts = installDevelopmentElementCallouts(document, { + copyText: (reference) => { copied.push(reference) }, + notify: (message) => { notices.push(message) } + }) + let ordinaryClicks = 0 + button.addEventListener('click', () => { ordinaryClicks += 1 }) + + button.dispatchEvent(new MouseEvent('pointerdown', { + altKey: true, + bubbles: true, + button: 0, + cancelable: true + })) + button.dispatchEvent(new MouseEvent('click', { + altKey: true, + bubbles: true, + button: 0, + cancelable: true + })) + + assert.equal(ordinaryClicks, 0) + assert.equal(copied.length, 1) + assert.equal(isDevelopmentElementReference(copied[0]), true) + assert.deepEqual(notices, ['Element reference copied']) + const overlay = document.querySelector('.development-element-callout') + assert.ok(overlay) + assert.equal(overlay.hidden, false) + assert.equal(overlay.style.left, '10px') + assert.equal(overlay.style.width, '120px') + + const result = callouts.handle({ + action: 'highlight', + reference: copied[0], + requestId: 'element-callout-1' + }) + assert.deepEqual(result, { + bounds: { height: 42, width: 120, x: 10, y: 20 }, + reference: copied[0], + requestId: 'element-callout-1', + status: 'highlighted' + }) + assert.deepEqual(callouts.handle({ + action: 'clear', + requestId: 'element-callout-2' + }), { + requestId: 'element-callout-2', + status: 'cleared' + }) + assert.equal(overlay.hidden, true) +}) + +test('pointer capture picks rendered disabled controls', () => { + const dom = new JSDOM('') + const { document, MouseEvent } = dom.window + const button = document.querySelector('button') as HTMLButtonElement + button.getBoundingClientRect = () => ({ + bottom: 40, + height: 30, + left: 10, + right: 110, + toJSON: () => ({}), + top: 10, + width: 100, + x: 10, + y: 10 + }) + const copied: string[] = [] + installDevelopmentElementCallouts(document, { + copyText: (reference) => { copied.push(reference) }, + notify() {} + }) + + button.dispatchEvent(new MouseEvent('pointerdown', { + altKey: true, + bubbles: true, + button: 0, + cancelable: true + })) + + assert.equal(copied.length, 1) + assert.equal(isDevelopmentElementReference(copied[0]), true) +}) + +test('the picker copies only references inside its finite depth contract', () => { + const dom = new JSDOM('
') + const { document, MouseEvent } = dom.window + let parent = document.querySelector('main') as HTMLElement + for (let index = 0; index < 127; index += 1) { + const child = document.createElement('section') + parent.append(child) + parent = child + } + const supportedButton = document.createElement('button') + parent.append(supportedButton) + assert.equal( + isDevelopmentElementReference( + developmentElementReference(supportedButton, document) + ), + true + ) + supportedButton.remove() + for (let index = 0; index < 2; index += 1) { + const child = document.createElement('section') + parent.append(child) + parent = child + } + const button = document.createElement('button') + parent.append(button) + button.getBoundingClientRect = () => ({ + bottom: 40, + height: 30, + left: 10, + right: 110, + toJSON: () => ({}), + top: 10, + width: 100, + x: 10, + y: 10 + }) + const copied: string[] = [] + const notices: string[] = [] + installDevelopmentElementCallouts(document, { + copyText: (reference) => { copied.push(reference) }, + notify: (message) => { notices.push(message) } + }) + + button.dispatchEvent(new MouseEvent('pointerdown', { + altKey: true, + bubbles: true, + button: 0, + cancelable: true + })) + + assert.deepEqual(copied, []) + assert.deepEqual(notices, ['Element cannot be referenced']) +}) + +test('references fail stale or ambiguous without selecting another element', () => { + const dom = new JSDOM('
') + const { document } = dom.window + const button = document.querySelector('button') as HTMLButtonElement + const reference = developmentElementReference(button, document) + assert.equal(resolveDevelopmentElementReference(reference, document).status, 'found') + + document.querySelector('section')?.remove() + assert.deepEqual(resolveDevelopmentElementReference(reference, document), { + status: 'stale' + }) + + const anchored = developmentElementReference( + document.querySelector('#workspace') as HTMLElement, + document + ) + const duplicate = document.createElement('main') + duplicate.id = 'workspace' + document.body.append(duplicate) + assert.deepEqual(resolveDevelopmentElementReference(anchored, document), { + status: 'ambiguous' + }) +}) + +test('references reject changed same-tag sibling ordinals', () => { + const dom = new JSDOM( + '
' + ) + const { document } = dom.window + const buttons = document.querySelectorAll('button') + const reference = developmentElementReference(buttons[1] as Element, document) + const inserted = document.createElement('button') + inserted.textContent = 'Inserted' + buttons[1]?.before(inserted) + + assert.deepEqual(resolveDevelopmentElementReference(reference, document), { + status: 'stale' + }) + + const reorderedDom = new JSDOM( + '
Alpha
Beta
' + ) + const reorderedDocument = reorderedDom.window.document + const details = reorderedDocument.querySelectorAll('details') + const descendant = details[0]?.querySelector('span') as Element + const reorderedReference = developmentElementReference( + descendant, + reorderedDocument + ) + details[1]?.after(details[0] as Element) + assert.deepEqual( + resolveDevelopmentElementReference(reorderedReference, reorderedDocument), + { status: 'stale' } + ) +}) + +test('pinned callouts can reposition after application layout changes', () => { + const dom = new JSDOM('
') + const { document } = dom.window + const button = document.querySelector('button') as HTMLButtonElement + let x = 10 + button.getBoundingClientRect = () => ({ + bottom: 60, + height: 40, + left: x, + right: x + 120, + toJSON: () => ({}), + top: 20, + width: 120, + x, + y: 20 + }) + const callouts = installDevelopmentElementCallouts(document, { + copyText() {}, + notify() {} + }) + const reference = developmentElementReference(button, document) + callouts.handle({ + action: 'highlight', + reference, + requestId: 'element-callout-1' + }) + x = 75 + + assert.deepEqual(callouts.reposition(), { + height: 40, + width: 120, + x: 75, + y: 20 + }) + assert.equal( + document.querySelector('.development-element-callout')?.style.left, + '75px' + ) +}) + +test('pinned callouts follow layout-affecting renderer attributes', async () => { + const dom = new JSDOM('
') + const { document } = dom.window + const button = document.querySelector('button') as HTMLButtonElement + let x = 10 + button.getBoundingClientRect = () => ({ + bottom: 60, + height: 40, + left: x, + right: x + 120, + toJSON: () => ({}), + top: 20, + width: 120, + x, + y: 20 + }) + const callouts = installDevelopmentElementCallouts(document, { + copyText() {}, + notify() {} + }) + callouts.handle({ + action: 'highlight', + reference: developmentElementReference(button, document), + requestId: 'element-callout-1' + }) + + x = 75 + document.documentElement.dataset.treeDensity = 'compact' + await new Promise((resolve) => { dom.window.setTimeout(resolve, 0) }) + + assert.equal( + document.querySelector('.development-element-callout')?.style.left, + '75px' + ) +}) + +test('pinned callouts clear after rendered subtrees replace their target', async () => { + const dom = new JSDOM('
') + const { document } = dom.window + const section = document.querySelector('section') as HTMLElement + const button = document.querySelector('button') as HTMLButtonElement + button.getBoundingClientRect = () => ({ + bottom: 40, + height: 30, + left: 10, + right: 110, + toJSON: () => ({}), + top: 10, + width: 100, + x: 10, + y: 10 + }) + const callouts = installDevelopmentElementCallouts(document, { + copyText() {}, + notify() {} + }) + callouts.handle({ + action: 'highlight', + reference: developmentElementReference(button, document), + requestId: 'element-callout-1' + }) + + section.replaceChildren(document.createElement('button')) + await new Promise((resolve) => { dom.window.setTimeout(resolve, 0) }) + + assert.equal( + document.querySelector('.development-element-callout')?.hidden, + true + ) +}) + +test('pinned callouts clear when visibility changes hide their target', async () => { + const dom = new JSDOM('
') + const { document } = dom.window + const section = document.querySelector('section') as HTMLElement + const button = document.querySelector('button') as HTMLButtonElement + button.getBoundingClientRect = () => ({ + bottom: section.hidden ? 0 : 40, + height: section.hidden ? 0 : 30, + left: section.hidden ? 0 : 10, + right: section.hidden ? 0 : 110, + toJSON: () => ({}), + top: section.hidden ? 0 : 10, + width: section.hidden ? 0 : 100, + x: section.hidden ? 0 : 10, + y: section.hidden ? 0 : 10 + }) + const callouts = installDevelopmentElementCallouts(document, { + copyText() {}, + notify() {} + }) + callouts.handle({ + action: 'highlight', + reference: developmentElementReference(button, document), + requestId: 'element-callout-1' + }) + + section.hidden = true + await new Promise((resolve) => { dom.window.setTimeout(resolve, 0) }) + + assert.equal( + document.querySelector('.development-element-callout')?.hidden, + true + ) +}) + +test('pinned callouts clear when CSS visibility hides their target', async () => { + const dom = new JSDOM( + '
' + ) + const { document } = dom.window + const main = document.querySelector('main') as HTMLElement + const button = document.querySelector('button') as HTMLButtonElement + button.getBoundingClientRect = () => ({ + bottom: 40, + height: 30, + left: 10, + right: 110, + toJSON: () => ({}), + top: 10, + width: 100, + x: 10, + y: 10 + }) + const callouts = installDevelopmentElementCallouts(document, { + copyText() {}, + notify() {} + }) + callouts.handle({ + action: 'highlight', + reference: developmentElementReference(button, document), + requestId: 'element-callout-1' + }) + + main.classList.add('is-collapsed') + await new Promise((resolve) => { dom.window.setTimeout(resolve, 0) }) + + assert.equal( + document.querySelector('.development-element-callout')?.hidden, + true + ) +}) + +test('element commands accept only canonical references and exact actions', () => { + const dom = new JSDOM('') + const reference = developmentElementReference( + dom.window.document.querySelector('button') as HTMLButtonElement, + dom.window.document + ) + assert.equal(isDevelopmentElementCalloutCommand({ + action: 'highlight', + reference, + requestId: 'element-callout-1' + }), true) + assert.equal(isDevelopmentElementCalloutCommand({ + action: 'clear', + requestId: 'element-callout-2' + }), true) + assert.equal(isDevelopmentElementCalloutCommand({ + action: 'highlight', + reference: `${reference}x`, + requestId: 'element-callout-3' + }), false) + assert.equal(isDevelopmentElementCalloutCommand({ + action: 'clear', + reference, + requestId: 'element-callout-4' + }), false) +}) + +test('picker and service route are live-watch-only and documented', () => { + const main = fs.readFileSync(path.join(root, 'src/main.ts'), 'utf8') + const renderer = fs.readFileSync(path.join(root, 'src/renderer.ts'), 'utf8') + const docs = fs.readFileSync( + path.join(root, 'docs/developer/development.md'), + 'utf8' + ) + assert.match(main, /elementCallouts: developmentWatchMode/) + assert.match( + main, + /\.\.\.\(developmentWatchMode[\s\S]*onDevelopmentElementCallout: requestDevelopmentElementCallout/ + ) + assert.match( + renderer, + /if \(startupInfo\.elementCallouts\)[\s\S]*installDevelopmentElementCallouts/ + ) + assert.match( + renderer, + /schedulePaneLayoutResizeUpdate[\s\S]*developmentElementCallouts\?\.reposition\(\)/ + ) + assert.match(docs, /Option-click a rendered element[\s\S]*at most 128 elements/) + assert.match(docs, /--instance dev element highlight/) + assert.match(docs, /--instance dev element clear/) +}) diff --git a/test/ipc-security.test.ts b/test/ipc-security.test.ts index 54bf5348..b8b2f160 100644 --- a/test/ipc-security.test.ts +++ b/test/ipc-security.test.ts @@ -568,6 +568,43 @@ test('review resolution IPC carries exact selections and preserved feedback summ }) }) +test('development element callout IPC accepts only exact references and outcomes', () => { + const reference = `mko-ui-v1:${Buffer.from(JSON.stringify({ + anchorId: 'save', + path: [], + version: 1 + })).toString('base64url')}` + assert.doesNotThrow(() => { + assertMainEventArguments('development:element-callout', [{ + action: 'highlight', + reference, + requestId: 'element-callout-1' + }]) + }) + assert.throws(() => { + assertMainEventArguments('development:element-callout', [{ + action: 'highlight', + reference: `${reference}x`, + requestId: 'element-callout-1' + }]) + }) + assert.doesNotThrow(() => { + assertRendererSendArguments('development:element-callout-response', [{ + bounds: { height: 40, width: 120, x: 10, y: 20 }, + reference, + requestId: 'element-callout-1', + status: 'highlighted' + }]) + }) + assert.throws(() => { + assertRendererSendArguments('development:element-callout-response', [{ + reference, + requestId: 'element-callout-1', + status: 'highlighted' + }]) + }) +}) + test('application IPC uses only the centralized registration and bridge paths', () => { const main = fs.readFileSync(path.join(root, 'src/main.ts'), 'utf8') const preload = fs.readFileSync(path.join(root, 'src/preload.ts'), 'utf8') @@ -586,6 +623,7 @@ test('application IPC uses only the centralized registration and bridge paths', 'brand:assets', 'clipboard:read-image', 'clipboard:write', + 'development:element-callout-response', 'document:checksum', 'document:open', 'review:activate', diff --git a/test/local-service.test.ts b/test/local-service.test.ts index 1d899c4d..1f097374 100644 --- a/test/local-service.test.ts +++ b/test/local-service.test.ts @@ -124,6 +124,7 @@ async function serviceFixture( await options.onChange?.(artifact, action) }, onActivate: options.onActivate, + onDevelopmentElementCallout: options.onDevelopmentElementCallout, onDevelopmentReload: options.onDevelopmentReload, onQuit: options.onQuit, onUnauthorized: options.onUnauthorized, @@ -889,6 +890,85 @@ test('development reload allows the complete renderer durability barrier', async await reload }) +test('development element callouts are authenticated, exact, and opt-in', async (t) => { + const reference = `mko-ui-v1:${Buffer.from(JSON.stringify({ + anchorId: 'save', + path: [], + version: 1 + })).toString('base64url')}` + const requests: unknown[] = [] + const enabled = await serviceFixture(t, { + onDevelopmentElementCallout(request) { + requests.push(request) + return Promise.resolve({ + bounds: { height: 40, width: 120, x: 10, y: 20 }, + reference, + requestId: 'element-callout-1', + status: 'highlighted' + }) + } + }) + assert.deepEqual(await requestJson( + enabled.endpointPath, + 'POST', + '/development/element-callout', + { action: 'highlight', reference } + ), { + bounds: { height: 40, width: 120, x: 10, y: 20 }, + reference, + requestId: 'element-callout-1', + status: 'highlighted' + }) + assert.deepEqual(requests, [{ action: 'highlight', reference }]) + await assert.rejects( + requestJson( + enabled.endpointPath, + 'POST', + '/development/element-callout', + { action: 'highlight', reference: `${reference}x` } + ), + (error: unknown) => hasServiceError( + error, + 'INVALID_DEVELOPMENT_ELEMENT_CALLOUT', + 400 + ) + ) + + const stale = await serviceFixture(t, { + onDevelopmentElementCallout(request) { + return Promise.resolve({ + reference: request.reference, + requestId: 'element-callout-1', + status: 'stale' + }) + } + }) + await assert.rejects( + requestJson( + stale.endpointPath, + 'POST', + '/development/element-callout', + { action: 'highlight', reference } + ), + (error: unknown) => hasServiceError( + error, + 'DEVELOPMENT_ELEMENT_REFERENCE_STALE', + 404 + ) + ) + + const disabled = await serviceFixture(t) + await assert.rejects( + requestJson( + disabled.endpointPath, + 'POST', + '/development/element-callout', + { action: 'clear' } + ), + (error: unknown) => hasServiceError(error, 'NOT_FOUND', 404) + ) +}) + test('authenticated quit acknowledges and invokes the app callback', async (t) => { let quits = 0 const { endpointPath } = await serviceFixture(t, { diff --git a/test/markover-cli.test.ts b/test/markover-cli.test.ts index e8e59586..25545fef 100644 --- a/test/markover-cli.test.ts +++ b/test/markover-cli.test.ts @@ -222,6 +222,11 @@ test('parses lifecycle commands and PR observations', () => { }) test('development targeting is worktree-local and cleanup requires an exact identity', () => { + const reference = `mko-ui-v1:${Buffer.from(JSON.stringify({ + anchorId: 'save', + path: [], + version: 1 + })).toString('base64url')}` assert.deepEqual( parseCommandArguments(['--instance', 'dev', 'get', 'mko_aaa11111']), { @@ -239,6 +244,37 @@ test('development targeting is worktree-local and cleanup requires an exact iden instance: 'development' } ) + assert.deepEqual( + parseCommandArguments([ + '--instance', + 'dev', + 'element', + 'highlight', + reference + ]), + { + action: 'highlight', + command: 'element', + instance: 'development', + reference + } + ) + assert.deepEqual( + parseCommandArguments(['--instance', 'dev', 'element', 'clear']), + { + action: 'clear', + command: 'element', + instance: 'development' + } + ) + assert.deepEqual( + parseCommandArguments(['element', 'clear']), + { action: 'clear', command: 'element' } + ) + assert.deepEqual( + parseCommandArguments(['--instance', 'canonical', 'element', 'clear']), + { action: 'clear', command: 'element', instance: 'canonical' } + ) assert.throws( () => parseCommandArguments(['cleanup', 'pr-61']), /only for the current development worktree/ @@ -253,6 +289,74 @@ test('development targeting is worktree-local and cleanup requires an exact iden ) }) +test('element commands target only the addressed running watcher service', async () => { + const reference = `mko-ui-v1:${Buffer.from(JSON.stringify({ + anchorId: 'save', + path: [], + version: 1 + })).toString('base64url')}` + const requests: unknown[] = [] + const selectors: string[] = [] + const result = await executeCommand({ + action: 'highlight', + command: 'element', + instance: 'development', + reference + }, { + resolveTarget(selector) { + selectors.push(selector) + return Promise.resolve({ + service: { endpointPath: `/${selector}/service.json` } + } as unknown as ResolvedInstance) + }, + requestLocal(endpointPath, method, requestPath, body) { + requests.push({ body, endpointPath, method, requestPath }) + return Promise.resolve({ + bounds: { height: 40, width: 120, x: 10, y: 20 }, + reference, + requestId: 'element-callout-1', + status: 'highlighted' + }) + }, + ensure() { + throw new Error('element callouts must not cold-start Markover') + } + }) + assert.deepEqual(result, { + bounds: { height: 40, width: 120, x: 10, y: 20 }, + reference, + status: 'highlighted' + }) + assert.deepEqual(requests, [{ + body: { action: 'highlight', reference }, + endpointPath: '/development/service.json', + method: 'POST', + requestPath: '/development/element-callout' + }]) + assert.deepEqual(selectors, ['development']) + + await executeCommand({ + action: 'clear', + command: 'element', + instance: 'canonical' + }, { + resolveTarget(selector) { + selectors.push(selector) + return Promise.resolve({ + service: { endpointPath: `/${selector}/service.json` } + } as unknown as ResolvedInstance) + }, + requestLocal(endpointPath) { + assert.equal(endpointPath, '/canonical/service.json') + return Promise.resolve({ + requestId: 'element-callout-2', + status: 'cleared' + }) + } + }) + assert.deepEqual(selectors, ['development', 'canonical']) +}) + test('canonical maintenance is explicit and instance-independent', () => { assert.deepEqual( parseCommandArguments(['canonical', 'doctor']), @@ -933,7 +1037,7 @@ test('CLI help is strict JSON and misuse gives an exact recovery path', () => { assert.match(misuse.stderr, /Unknown command: wat/) assert.match( misuse.stderr, - /Usage: markover / + /Usage: markover / ) assert.match( misuse.stderr,