From e52a57b38861918a80f455d0c5536d00ee88e611 Mon Sep 17 00:00:00 2001 From: Simon Bennetts Date: Fri, 16 Oct 2026 13:07:30 +0200 Subject: [PATCH] Script editing and replay Signed-off-by: Simon Bennetts --- .github/workflows/ci.yaml | 5 +- CHANGELOG.md | 5 + CHANGELOG.rec.md | 6 + source/Background/index.ts | 81 +- source/ContentScript/index.ts | 29 +- source/ContentScript/replayOverlay.ts | 237 ++ source/ContentScript/replayer.ts | 442 ++++ source/Options/index.tsx | 52 +- source/Popup/index.tsx | 41 +- source/Popup/styles.scss | 46 +- source/ScriptView/dialogs.ts | 167 ++ source/ScriptView/index.ts | 813 +++++++ source/ScriptView/player.ts | 398 ++++ source/assets/icons/view.svg | 1 + .../zestScript/RawStatement.ts} | 27 +- source/types/zestScript/ZestScript.ts | 57 +- source/utils/constants.ts | 15 + source/utils/i18n.ts | 179 ++ source/utils/tabs.ts | 74 + source/utils/util.ts | 26 +- source/utils/zestEdit.ts | 389 ++++ source/utils/zestSummary.ts | 116 + test/Background/background.test.ts | 240 ++ test/Background/dialogs.test.ts | 217 ++ test/Background/i18n.test.ts | 90 + test/Background/options.test.ts | 253 +++ test/Background/player.test.ts | 614 +++++ test/Background/popup.test.ts | 196 ++ test/Background/popupHtml.test.ts | 74 + test/Background/popupRecorder.test.ts | 98 + test/Background/replayOverlay.test.ts | 223 ++ test/Background/replayer.test.ts | 864 +++++++ test/Background/scriptView.test.ts | 2023 +++++++++++++++++ test/Background/tabs.test.ts | 149 ++ test/Background/util.test.ts | 52 + test/Background/zestEdit.test.ts | 267 +++ test/Background/zestScript.test.ts | 127 ++ test/Background/zestSummary.test.ts | 85 + test/drivers/BaseDriver.ts | 29 +- views/help.html | 75 +- views/options.html | 12 +- views/popup.html | 6 + views/script.html | 148 ++ webpack.config.js | 8 + webpack.config.rec.js | 16 + 45 files changed, 8970 insertions(+), 102 deletions(-) create mode 100644 source/ContentScript/replayOverlay.ts create mode 100644 source/ContentScript/replayer.ts create mode 100644 source/ScriptView/dialogs.ts create mode 100644 source/ScriptView/index.ts create mode 100644 source/ScriptView/player.ts create mode 100644 source/assets/icons/view.svg rename source/{Popup/i18n.tsx => types/zestScript/RawStatement.ts} (56%) create mode 100644 source/utils/i18n.ts create mode 100644 source/utils/tabs.ts create mode 100644 source/utils/zestEdit.ts create mode 100644 source/utils/zestSummary.ts create mode 100644 test/Background/background.test.ts create mode 100644 test/Background/dialogs.test.ts create mode 100644 test/Background/i18n.test.ts create mode 100644 test/Background/options.test.ts create mode 100644 test/Background/player.test.ts create mode 100644 test/Background/popup.test.ts create mode 100644 test/Background/popupHtml.test.ts create mode 100644 test/Background/popupRecorder.test.ts create mode 100644 test/Background/replayOverlay.test.ts create mode 100644 test/Background/replayer.test.ts create mode 100644 test/Background/scriptView.test.ts create mode 100644 test/Background/tabs.test.ts create mode 100644 test/Background/util.test.ts create mode 100644 test/Background/zestEdit.test.ts create mode 100644 test/Background/zestScript.test.ts create mode 100644 test/Background/zestSummary.test.ts create mode 100644 views/script.html diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c36289a0..8589c392 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -28,7 +28,8 @@ jobs: - name: Lint run: yarn lint - - run: yarn test unitTests + - name: Unit tests + run: yarn test --testPathIgnorePatterns integrationTests integration: needs: check @@ -47,4 +48,4 @@ jobs: - run: yarn install - run: yarn run build - name: Test - run: yarn test -t "${{ matrix.browser }} Integration Test" + run: yarn test integrationTests -t "${{ matrix.browser }} Integration Test" diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cff4f03..77b59597 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ All notable changes to the full browser extension will be documented in this fil The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## Unreleased +### Added +- Script Viewer, opened from the new "View Script" button, which allows a recorded script to be viewed, edited + (add, move, edit, delete), replayed in a new window or tab (with optional step through), loaded and saved. + ## 0.2.4 - 2026-10-08 ### Fixed - Record text fields updated via JS with no associated events. diff --git a/CHANGELOG.rec.md b/CHANGELOG.rec.md index 242d502d..e6dbfa0e 100644 --- a/CHANGELOG.rec.md +++ b/CHANGELOG.rec.md @@ -3,6 +3,12 @@ All notable changes to the recorder browser extension will be documented in this The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## Unreleased +### Added +- Script Viewer, opened from the new "View Script" button, which allows a recorded script to be viewed, edited + (add, move, edit, delete), replayed in a new window or tab (with optional step through), loaded and saved. +- Configuration page, opened from the popup, for the options that apply to the recorder. + ## 0.2.4 - 2026-10-08 ### Fixed - Record text fields updated via JS with no associated events. diff --git a/source/Background/index.ts b/source/Background/index.ts index 53d716dd..7edcd5e3 100644 --- a/source/Background/index.ts +++ b/source/Background/index.ts @@ -30,6 +30,7 @@ import { REPORT_OBJECT, RESET_ZEST_SCRIPT, SESSION_STORAGE, + SET_ZEST_STATEMENTS, STOP_RECORDING, ZEST_SCRIPT, } from '../utils/constants'; @@ -179,6 +180,27 @@ function sendZestScriptToZAP( } } +/* + Everything that changes the script goes through this queue, so that each change and its write to storage finish before the next one starts. + Otherwise, for example, saving edited statements could overwrite statements recorded while the save was being stored. +*/ +let scriptChanges: Promise = Promise.resolve(); + +function inOrder(change: () => Promise | T): Promise { + const result = scriptChanges.then(change); + scriptChanges = result.catch(() => undefined); + return result; +} + +function storeScript(): Promise { + return Browser.storage.local + .set({ + zestStatements: zestScript.getStatements(), + zestCurIndex: zestScript.getCurIndex(), + }) + .catch((error) => console.error(`Failed to store the script: ${error}`)); +} + async function handleMessage( request: MessageEvent, zapurl: string, @@ -231,41 +253,52 @@ async function handleMessage( break; } - case ZEST_SCRIPT: { - const data = zestScript.addStatement(request.data); - sendZestScriptToZAP(data, zapkey, zapurl); - Browser.storage.local.set({ - zestStatements: zestScript.getStatements(), - zestCurIndex: zestScript.getCurIndex(), + case ZEST_SCRIPT: + await inOrder(async () => { + const data = zestScript.addStatement(request.data); + sendZestScriptToZAP(data, zapkey, zapurl); + await storeScript(); }); break; - } case GET_ZEST_SCRIPT: - return zestScript.getZestScript(); + return inOrder(() => zestScript.getZestScript()); + + case SET_ZEST_STATEMENTS: + await inOrder(async () => { + // Only change the script once the new one has been stored, so that if + // storing fails the script and what is stored stay the same + const prepared = zestScript.prepareStatements(request.data); + await Browser.storage.local.set({ + zestStatements: prepared.statements, + zestCurIndex: prepared.curIndex, + }); + zestScript.restoreStatements(prepared.statements, prepared.curIndex); + }); + break; case RESET_ZEST_SCRIPT: - zestScript.reset(); - Browser.storage.local.set({zestStatements: [], zestCurIndex: 1}); + await inOrder(async () => { + zestScript.reset(); + await storeScript(); + }); break; - case STOP_RECORDING: { - if (zestScript.getZestStatementCount() > 0) { - const {zapclosewindowhandle} = await Browser.storage.sync.get({ - zapclosewindowhandle: false, - }); - if (zapclosewindowhandle) { - const stmt = new ZestStatementWindowClose(0); - const data = zestScript.addStatement(stmt.toJSON()); - sendZestScriptToZAP(data, zapkey, zapurl); - Browser.storage.local.set({ - zestStatements: zestScript.getStatements(), - zestCurIndex: zestScript.getCurIndex(), + case STOP_RECORDING: + await inOrder(async () => { + if (zestScript.getZestStatementCount() > 0) { + const {zapclosewindowhandle} = await Browser.storage.sync.get({ + zapclosewindowhandle: false, }); + if (zapclosewindowhandle) { + const stmt = new ZestStatementWindowClose(0); + const data = zestScript.addStatement(stmt.toJSON()); + sendZestScriptToZAP(data, zapkey, zapurl); + await storeScript(); + } } - } + }); break; - } default: // Handle unknown request type diff --git a/source/ContentScript/index.ts b/source/ContentScript/index.ts index 9b4f905f..a098a6c9 100644 --- a/source/ContentScript/index.ts +++ b/source/ContentScript/index.ts @@ -26,6 +26,7 @@ import { InteractableState, } from '../types/ReportedModel'; import Recorder from './recorder'; +import Replayer from './replayer'; import { hasPointerStyle, getInteractableState, @@ -40,6 +41,11 @@ import { LOCAL_ZAP_ENABLE, LOCAL_ZAP_RECORD, REPORT_EVENT, + REPLAY_ABORT, + REPLAY_FINISHED, + REPLAY_PAUSE, + REPLAY_PING, + REPLAY_STATEMENT, REPORT_OBJECT, SESSION_STORAGE, URL_ZAP_ENABLE, @@ -58,6 +64,8 @@ let trackedElementRefs: WeakRef[] = []; const recorder = new Recorder(); +const replayer = new Replayer(); + function reportStorage( name: string, storage: Storage, @@ -429,7 +437,26 @@ Browser.runtime.onMessage.addListener( message: any, _sender: Runtime.MessageSender, _sendResponse: (response?: any) => void - ) => { + ): any => { + if (message.type === REPLAY_STATEMENT) { + return replayer.execute(message.data, message.display); + } + if (message.type === REPLAY_PING) { + // Lets the player know that the page is ready to be sent statements + return Promise.resolve(true); + } + if (message.type === REPLAY_ABORT) { + replayer.abort(); + return Promise.resolve(true); + } + if (message.type === REPLAY_PAUSE) { + replayer.pause(message.data); + return Promise.resolve(true); + } + if (message.type === REPLAY_FINISHED) { + replayer.finish(message.data); + return Promise.resolve(true); + } if (message.type === ZAP_START_RECORDING) { configureExtension(); recorder.recordUserInteractions(); diff --git a/source/ContentScript/replayOverlay.ts b/source/ContentScript/replayOverlay.ts new file mode 100644 index 00000000..b63e6ec3 --- /dev/null +++ b/source/ContentScript/replayOverlay.ts @@ -0,0 +1,237 @@ +/* + * Zed Attack Proxy (ZAP) and its related source files. + * + * ZAP is an HTTP/HTTPS proxy for assessing web application security. + * + * Copyright 2026 The ZAP Development Team + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Browser from 'webextension-polyfill'; + +type OverlayState = 'running' | 'ok' | 'failed'; + +interface OverlayAction { + label: string; + onClick: () => void; +} + +interface ReplayStatus { + title: string; + heading: string; + text: string; + state: OverlayState; +} + +const POSITION_KEY = 'zapreplayoverlaypos'; +const COLORS: Record = { + running: '#2979ff', + ok: '#1a7f37', + failed: '#e74c3c', +}; +const STYLE = ` + :host { all: initial; } + .box { + box-sizing: border-box; + width: 360px; + padding: 10px 14px; + background: #f9f9f9; + color: #333; + border: 2px solid #2979ff; + border-radius: 5px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25); + font-family: Roboto, Arial, sans-serif; + text-align: center; + cursor: move; + user-select: none; + touch-action: none; + } + .title { font-weight: bold; font-size: 16px; } + .heading { font-size: 13px; color: #666; margin: 2px 0; } + .text { font-size: 15px; word-break: break-all; } + .actions { display: flex; justify-content: center; gap: 10px; margin-top: 8px; } + .actions button { font: inherit; font-size: 14px; padding: 4px 16px; cursor: pointer; } +`; + +/** + * A small movable box shown on the page being replayed. + * It lives in a shadow root attached to the html element (not the body) so + * that it cannot change, or be matched by, the locators used in the script. + * Nothing in here is allowed to throw into the replay. + */ +class ReplayOverlay { + private host?: HTMLElement; + + private box?: HTMLElement; + + private titleElement?: HTMLElement; + + private heading?: HTMLElement; + + private text?: HTMLElement; + + private actions?: HTMLElement; + + private hideTimer?: ReturnType; + + show( + status: ReplayStatus, + hideAfterMsec = 0, + actions: OverlayAction[] = [] + ): void { + try { + this.ensureCreated(); + clearTimeout(this.hideTimer); + (this.host as HTMLElement).style.display = 'block'; + (this.box as HTMLElement).style.borderColor = COLORS[status.state]; + (this.titleElement as HTMLElement).textContent = status.title; + (this.heading as HTMLElement).textContent = status.heading; + (this.text as HTMLElement).textContent = status.text; + this.setActions(actions); + if (hideAfterMsec > 0) { + this.hideTimer = setTimeout(() => this.hide(), hideAfterMsec); + } + } catch (e) { + console.log('ZAP replay overlay error', e); + } + } + + private setActions(actions: OverlayAction[]): void { + const container = this.actions as HTMLElement; + container.textContent = ''; + actions.forEach((action) => { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = action.label; + button.addEventListener('click', () => { + // Only one choice can be made for each pause + container.querySelectorAll('button').forEach((b) => { + b.disabled = true; + }); + action.onClick(); + }); + container.appendChild(button); + }); + } + + hide(): void { + if (this.host) { + this.host.style.display = 'none'; + } + } + + private ensureCreated(): void { + if (this.host?.isConnected) { + return; + } + const host = document.createElement('zap-replay-overlay'); + host.style.cssText = + 'all: initial; position: fixed; z-index: 2147483647; top: 16px; right: 16px;'; + const root = host.attachShadow({mode: 'closed'}); + const style = document.createElement('style'); + style.textContent = STYLE; + const box = document.createElement('div'); + box.className = 'box'; + const title = document.createElement('div'); + title.className = 'title'; + const heading = document.createElement('div'); + heading.className = 'heading'; + const text = document.createElement('div'); + text.className = 'text'; + const actions = document.createElement('div'); + actions.className = 'actions'; + box.append(title, heading, text, actions); + root.append(style, box); + document.documentElement.appendChild(host); + this.host = host; + this.box = box; + this.titleElement = title; + this.heading = heading; + this.text = text; + this.actions = actions; + this.makeDraggable(host, box); + this.restorePosition(host); + } + + private makeDraggable(host: HTMLElement, box: HTMLElement): void { + let start: {x: number; y: number; left: number; top: number} | undefined; + box.addEventListener('pointerdown', (e) => { + // Let the buttons be clicked rather than dragged + if ((e.target as Element).tagName === 'BUTTON') { + return; + } + const rect = host.getBoundingClientRect(); + start = {x: e.clientX, y: e.clientY, left: rect.left, top: rect.top}; + box.setPointerCapture(e.pointerId); + e.preventDefault(); + }); + box.addEventListener('pointermove', (e) => { + if (!start) { + return; + } + this.moveTo( + host, + start.left + e.clientX - start.x, + start.top + e.clientY - start.y + ); + }); + box.addEventListener('pointerup', () => { + if (!start) { + return; + } + start = undefined; + this.savePosition(host); + }); + } + + private moveTo(host: HTMLElement, left: number, top: number): void { + const maxLeft = Math.max(window.innerWidth - host.offsetWidth, 0); + const maxTop = Math.max(window.innerHeight - host.offsetHeight, 0); + host.style.right = 'auto'; + host.style.left = `${Math.min(Math.max(left, 0), maxLeft)}px`; + host.style.top = `${Math.min(Math.max(top, 0), maxTop)}px`; + } + + // The position is kept as a fraction of the window so that it is remembered + // from page to page. + private savePosition(host: HTMLElement): void { + const rect = host.getBoundingClientRect(); + Browser.storage.local + .set({ + [POSITION_KEY]: { + left: rect.left / window.innerWidth, + top: rect.top / window.innerHeight, + }, + }) + .catch(() => {}); + } + + private restorePosition(host: HTMLElement): void { + Browser.storage.local + .get(POSITION_KEY) + .then((items) => { + const pos = items[POSITION_KEY] as {left: number; top: number}; + if (pos && host.isConnected) { + this.moveTo( + host, + pos.left * window.innerWidth, + pos.top * window.innerHeight + ); + } + }) + .catch(() => {}); + } +} + +export default ReplayOverlay; +export type {OverlayAction, ReplayStatus}; diff --git a/source/ContentScript/replayer.ts b/source/ContentScript/replayer.ts new file mode 100644 index 00000000..740fba56 --- /dev/null +++ b/source/ContentScript/replayer.ts @@ -0,0 +1,442 @@ +/* + * Zed Attack Proxy (ZAP) and its related source files. + * + * ZAP is an HTTP/HTTPS proxy for assessing web application security. + * + * Copyright 2026 The ZAP Development Team + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Browser from 'webextension-polyfill'; +import RawStatement from '../types/zestScript/RawStatement'; +import {sleep} from '../utils/util'; +import { + ZEST_CLIENT_ELEMENT_CLEAR, + ZEST_CLIENT_ELEMENT_CLICK, + ZEST_CLIENT_ELEMENT_MOUSE_OVER, + ZEST_CLIENT_ELEMENT_SCROLL_TO, + ZEST_CLIENT_ELEMENT_SEND_KEYS, + ZEST_CLIENT_ELEMENT_SUBMIT, + ZEST_CLIENT_SWITCH_TO_FRAME, + REPLAY_CANCEL, + REPLAY_NEXT, + ZEST_COMMENT, +} from '../utils/constants'; +import ReplayOverlay from './replayOverlay'; + +/** What the box on the page shows. The text is translated by the sender. */ +interface ReplayDisplay { + title: string; + heading: string; + text: string; +} + +interface ReplayPauseDisplay extends ReplayDisplay { + nextLabel: string; + cancelLabel: string; +} + +interface ReplayFinishDisplay { + title: string; + heading: string; + message: string; + ok: boolean; +} + +/** An error that the sender turns into a translated message. */ +class ReplayError extends Error { + readonly key: string; + + readonly params: Record; + + constructor(key: string, params: Record = {}) { + super(key); + // Keeps instanceof working when classes are compiled down to older targets + Object.setPrototypeOf(this, ReplayError.prototype); + this.key = key; + this.params = params; + } +} + +interface ReplayResult { + ok: boolean; + errorKey?: string; + errorParams?: Record; +} + +const MIN_ELEMENT_WAIT_MSEC = 3000; +const POLL_INTERVAL_MSEC = 100; +const FINISHED_SHOW_MSEC = 4000; +const FAILED_SHOW_MSEC = 10000; + +function findElement( + doc: Document, + type: string, + value: string +): Element | null { + switch (type) { + case 'id': + return doc.getElementById(value); + case 'className': + return doc.getElementsByClassName(value)[0] ?? null; + case 'name': + return doc.getElementsByName(value)[0] ?? null; + case 'tagName': + return doc.getElementsByTagName(value)[0] ?? null; + case 'cssSelector': + return doc.querySelector(value); + case 'linkText': + return ( + Array.from(doc.links).find((l) => l.textContent?.trim() === value) ?? + null + ); + case 'partialLinkText': + // Part of the text of the link, as for linkText but not all of it. Nothing + // is found for no text, rather than the first link, as everything has that. + return ( + (value !== '' && + Array.from(doc.links).find((l) => + (l.textContent ?? '').includes(value) + )) || + null + ); + case 'xpath': + return doc.evaluate( + value, + doc, + null, + XPathResult.FIRST_ORDERED_NODE_TYPE, + null + ).singleNodeValue as Element | null; + default: + throw new ReplayError('script.error.badLocator', {type}); + } +} + +function fire(el: Element, eventName: string, init: EventInit = {}): void { + // Use the element's own realm so this also works for elements in frames + const win = el.ownerDocument.defaultView as Window & typeof globalThis; + el.dispatchEvent(new win.Event(eventName, {bubbles: true, ...init})); +} + +function fireMouse(el: Element, eventName: string, bubbles = true): void { + const win = el.ownerDocument.defaultView as Window & typeof globalThis; + const rect = el.getBoundingClientRect(); + const init = { + bubbles, + cancelable: bubbles, + composed: true, + view: win, + button: 0, + buttons: eventName.endsWith('down') ? 1 : 0, + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2, + }; + const event = + eventName.startsWith('pointer') && typeof win.PointerEvent === 'function' + ? new win.PointerEvent(eventName, { + ...init, + pointerId: 1, + pointerType: 'mouse', + isPrimary: true, + }) + : new win.MouseEvent(eventName, init); + el.dispatchEvent(event); +} + +/** Sends the events of a mouse moving onto an element, as real ones are. */ +function hover(el: Element): void { + fireMouse(el, 'pointerover'); + fireMouse(el, 'pointerenter', false); + fireMouse(el, 'mouseover'); + fireMouse(el, 'mouseenter', false); + fireMouse(el, 'mousemove'); +} + +function isDisabled(el: Element): boolean { + return el.matches(':disabled'); +} + +/** Sends the events a real click would, as some pages rely on all of them. */ +function click(el: HTMLElement): void { + fireMouse(el, 'pointerdown'); + fireMouse(el, 'mousedown'); + // Synthetic events do not move the focus, as a real mouse down would + el.focus(); + fireMouse(el, 'pointerup'); + fireMouse(el, 'mouseup'); + if (typeof el.click === 'function') { + el.click(); + } else { + // SVG elements do not have a click method + fireMouse(el, 'click'); + } +} + +function setValue(el: Element, value: string): void { + const setter = Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(el), + 'value' + )?.set; + if (setter) { + // Bypasses framework value trackers, so that React etc. see the change + setter.call(el, value); + } else { + (el as HTMLInputElement).value = value; + } + fire(el, 'input'); + fire(el, 'change'); +} + +function selectOption(select: HTMLSelectElement, wanted: string): void { + // The recorder records the text of the selected option + const options = Array.from(select.options); + const option = + options.find((o) => o.text === wanted) ?? + options.find((o) => o.value === wanted); + if (!option) { + throw new ReplayError('script.error.optionNotFound', {option: wanted}); + } + // Not the value, as several options can have the same one + select.selectedIndex = option.index; + fire(select, 'input'); + fire(select, 'change'); +} + +function sendKeys(el: HTMLElement, keys: string): void { + if (el.tagName === 'SELECT') { + selectOption(el as HTMLSelectElement, keys); + return; + } + const {type} = el as HTMLInputElement; + if (el.tagName === 'INPUT' && (type === 'checkbox' || type === 'radio')) { + // The click that changes these is recorded as a separate statement, and + // typing into them must not change their value + return; + } + el.focus(); + setValue(el, ((el as HTMLInputElement).value ?? '') + keys); +} + +/** + * Replays individual Zest statements in this page. Frame switches are tracked + * here, and reset to the top window when the page is reloaded. + */ +class Replayer { + private win: Window = window; + + private parents: Window[] = []; + + private overlay = new ReplayOverlay(); + + private minElementWaitMsec: number; + + private aborted = false; + + constructor(minElementWaitMsec = MIN_ELEMENT_WAIT_MSEC) { + this.minElementWaitMsec = minElementWaitMsec; + } + + async execute( + statement: RawStatement, + display?: ReplayDisplay + ): Promise { + this.aborted = false; + if (display) { + this.overlay.show({...display, state: 'running'}); + } + try { + await this.run(statement); + return {ok: true}; + } catch (e) { + if (e instanceof ReplayError) { + return {ok: false, errorKey: e.key, errorParams: e.params}; + } + return { + ok: false, + errorKey: 'script.error.unexpected', + errorParams: {message: e instanceof Error ? e.message : String(e)}, + }; + } + } + + /** Stops the statement being run, if any, as the script has been stopped. */ + abort(): void { + this.aborted = true; + } + + private checkNotAborted(): void { + if (this.aborted) { + throw new ReplayError('script.error.stopped'); + } + } + + /** Shows the next statement, with buttons to run it or to cancel. */ + pause(display: ReplayPauseDisplay): void { + this.overlay.show({...display, state: 'running'}, 0, [ + { + label: display.nextLabel, + onClick: (): void => { + Browser.runtime.sendMessage({type: REPLAY_NEXT}); + }, + }, + { + label: display.cancelLabel, + onClick: (): void => { + Browser.runtime.sendMessage({type: REPLAY_CANCEL}); + }, + }, + ]); + } + + finish(display: ReplayFinishDisplay): void { + this.overlay.show( + { + title: display.title, + heading: display.heading, + text: display.message, + state: display.ok ? 'ok' : 'failed', + }, + display.ok ? FINISHED_SHOW_MSEC : FAILED_SHOW_MSEC + ); + } + + /** + * Waits for the element to be found and, if needed, to be enabled, as + * clicking or typing in a disabled element does nothing. + */ + private async waitForElement( + statement: RawStatement, + needsEnabled: boolean + ): Promise { + const type = String(statement.type); + const value = String(statement.element); + const timeout = Math.max( + Number(statement.waitForMsec) || 0, + this.minElementWaitMsec + ); + const end = Date.now() + timeout; + for (;;) { + this.checkNotAborted(); + const el = findElement(this.win.document, type, value); + if (el && !(needsEnabled && isDisabled(el))) { + return el; + } + if (Date.now() >= end) { + throw new ReplayError( + el ? 'script.error.elementDisabled' : 'script.error.elementNotFound', + {type, element: value} + ); + } + // eslint-disable-next-line no-await-in-loop + await sleep(POLL_INTERVAL_MSEC); + } + } + + private findNamedFrame(name: string): Window | null { + const frames = Array.from( + this.win.document.querySelectorAll('iframe, frame') + ) as (HTMLIFrameElement | HTMLFrameElement)[]; + const match = frames.find( + (f) => f.getAttribute('name') === name || f.id === name + ); + return match?.contentWindow ?? null; + } + + private switchToFrame(statement: RawStatement): void { + if (statement.parent === true) { + const parent = this.parents.pop(); + if (!parent) { + throw new ReplayError('script.error.topWindow'); + } + this.win = parent; + return; + } + // As in ZAP, a name is used in preference to the index + const name = String(statement.frameName ?? ''); + const wanted = name !== '' ? name : Number(statement.frameIndex); + const frame = + name !== '' ? this.findNamedFrame(name) : this.win.frames[Number(wanted)]; + if (!frame) { + throw new ReplayError('script.error.frameNotFound', {frame: wanted}); + } + try { + // Cross origin frames throw when accessed + if (!frame.document) { + throw new Error('no document'); + } + } catch { + throw new ReplayError('script.error.frameCrossOrigin', {frame: wanted}); + } + this.parents.push(this.win); + this.win = frame; + } + + private async run(statement: RawStatement): Promise { + const type = String(statement.elementType); + if (type === ZEST_CLIENT_SWITCH_TO_FRAME) { + this.switchToFrame(statement); + return; + } + if (type === ZEST_COMMENT) { + return; + } + const needsEnabled = + type === ZEST_CLIENT_ELEMENT_CLICK || + type === ZEST_CLIENT_ELEMENT_SEND_KEYS || + type === ZEST_CLIENT_ELEMENT_CLEAR; + const el = (await this.waitForElement( + statement, + needsEnabled + )) as HTMLElement; + // The script may have been stopped while waiting + this.checkNotAborted(); + switch (type) { + case ZEST_CLIENT_ELEMENT_CLICK: + click(el); + break; + case ZEST_CLIENT_ELEMENT_SCROLL_TO: + el.scrollIntoView({block: 'center'}); + break; + case ZEST_CLIENT_ELEMENT_MOUSE_OVER: + hover(el); + break; + case ZEST_CLIENT_ELEMENT_SEND_KEYS: + sendKeys(el, String(statement.value ?? '')); + break; + case ZEST_CLIENT_ELEMENT_CLEAR: + el.focus(); + setValue(el, ''); + break; + case ZEST_CLIENT_ELEMENT_SUBMIT: { + const form = el.tagName === 'FORM' ? el : (el as HTMLInputElement).form; + if (!form) { + throw new ReplayError('script.error.notInForm'); + } + (form as HTMLFormElement).requestSubmit(); + break; + } + default: + throw new ReplayError('script.error.badStatement', {type}); + } + } +} + +export default Replayer; +export {ReplayError}; +export type { + ReplayDisplay, + ReplayFinishDisplay, + ReplayPauseDisplay, + ReplayResult, +}; diff --git a/source/Options/index.tsx b/source/Options/index.tsx index bcbc20cc..fd49b260 100644 --- a/source/Options/index.tsx +++ b/source/Options/index.tsx @@ -18,7 +18,12 @@ * limitations under the License. */ import Browser from 'webextension-polyfill'; -import {ZAP_ENABLE, ZAP_KEY, ZAP_URL} from '../utils/constants'; +import { + IS_FULL_EXTENSION, + ZAP_ENABLE, + ZAP_KEY, + ZAP_URL, +} from '../utils/constants'; import {DEFAULT_STATEMENT_DELAY} from '../types/zestScript/ZestScript'; console.log('Options loading'); @@ -26,10 +31,6 @@ console.log('Options loading'); // Saves options to chrome.storage function saveOptions(): void { console.log('Options save_options'); - const zapurl = (document.getElementById(ZAP_URL) as HTMLInputElement).value; - const zapkey = (document.getElementById(ZAP_KEY) as HTMLInputElement).value; - const zapenable = (document.getElementById(ZAP_ENABLE) as HTMLInputElement) - .checked; const zapclosewindowhandle = ( document.getElementById('window-close-input') as HTMLInputElement ).checked; @@ -38,18 +39,49 @@ function saveOptions(): void { .value, 10 ); - Browser.storage.sync.set({ - zapurl, - zapkey, - zapenable, + const settings: Record = { zapclosewindowhandle, zapstatementdelay, - }); + }; + if (IS_FULL_EXTENSION) { + // The recorder does not show these, so it should not change them + settings.zapenable = ( + document.getElementById(ZAP_ENABLE) as HTMLInputElement + ).checked; + settings.zapurl = ( + document.getElementById(ZAP_URL) as HTMLInputElement + ).value; + settings.zapkey = ( + document.getElementById(ZAP_KEY) as HTMLInputElement + ).value; + } + Browser.storage.sync.set(settings); +} + +/** + * The recorder does not talk to ZAP, so the page only shows what applies to it: + * the text about giving ZAP access to the browser, the settings for how to + * reach ZAP, and whether to report to ZAP are only for the full extension. + */ +function showWhatApplies(): void { + if (typeof Browser.runtime.getManifest === 'function') { + const title = document.getElementById('title'); + if (title) { + title.textContent = Browser.runtime.getManifest().name; + } + } + if (!IS_FULL_EXTENSION) { + document.querySelectorAll('.full-only').forEach((el) => { + const element = el; + element.hidden = true; + }); + } } // Restores options from chrome.storage. function restoreOptions(): void { console.log('Options restore_options'); + showWhatApplies(); Browser.storage.sync .get({ diff --git a/source/Popup/index.tsx b/source/Popup/index.tsx index 4c64e8e9..107c28dc 100644 --- a/source/Popup/index.tsx +++ b/source/Popup/index.tsx @@ -19,11 +19,11 @@ */ import Browser from 'webextension-polyfill'; import './styles.scss'; -import i18n from './i18n'; +import i18n from '../utils/i18n'; import { GET_ZEST_SCRIPT, - IS_FULL_EXTENSION, + REFRESH_SCRIPT_VIEW, RESET_ZEST_SCRIPT, STOP_RECORDING, UPDATE_TITLE, @@ -31,12 +31,17 @@ import { ZAP_STOP_RECORDING, } from '../utils/constants'; import {downloadJson} from '../utils/util'; +import showOrCreateTab, {openInBackgroundIfClosed} from '../utils/tabs'; import {ZestScriptMessage} from '../types/zestScript/ZestScript'; const STOP = i18n.t('stop'); const START = i18n.t('start'); const OPTIONS = i18n.t('options'); const DOWNLOAD = i18n.t('download'); +const VIEW = i18n.t('view'); +const HELP = i18n.t('help'); +const LOGIN_URL = i18n.t('loginUrl'); +const SCRIPT_NAME = i18n.t('scriptName'); const play = document.querySelector('.play'); const pause = document.querySelector('.pause'); @@ -45,6 +50,8 @@ const wave2 = document.querySelector('.record__back-2'); const done = document.querySelector('.done'); const optionsIcon = document.querySelector('.settings') as HTMLImageElement; const downloadIcon = document.querySelector('.download') as HTMLImageElement; +const viewIcon = document.querySelector('.view') as HTMLImageElement; +const helpIcon = document.querySelector('.help') as HTMLImageElement; const zapIcon = document.querySelector('.zapicon') as HTMLImageElement; const zapImageDark = './assets/icons/ZAP_by_Checkmarx_logo-dark.png'; @@ -52,6 +59,7 @@ const zapImageLight = './assets/icons/ZAP_by_Checkmarx_logo.png'; const recordButton = document.getElementById('record-btn'); const configureButton = document.getElementById('configure-btn'); +const viewButton = document.getElementById('view-btn'); const helpButton = document.getElementById('help-btn'); const saveScript = document.getElementById('save-script'); const loginUrlInput = document.getElementById( @@ -99,6 +107,11 @@ async function restoreState(): Promise { console.log('Restore state'); optionsIcon.title = OPTIONS; downloadIcon.title = DOWNLOAD; + viewIcon.title = VIEW; + viewButton?.setAttribute('aria-label', VIEW); + helpIcon.title = HELP; + loginUrlInput.placeholder = LOGIN_URL; + scriptNameInput.placeholder = SCRIPT_NAME; Browser.storage.sync .get({ zaprecordingactive: false, @@ -147,6 +160,11 @@ function startRecording(): void { Browser.storage.sync.set({ zaprecordingactive: true, }); + // Show the script as it is recorded, but without taking the user away from + // the page that they are recording + openInBackgroundIfClosed('script.html').catch((error) => + console.log(`Could not open the script viewer: ${error}`) + ); } function toggleRecording(e: Event): void { @@ -187,6 +205,16 @@ function openOptionsPage(): void { closePopup(); } +async function openScriptPage(): Promise { + const {tab, existed} = await showOrCreateTab('script.html'); + if (existed && tab.id !== undefined) { + // Show the latest script in the tab that was already open + // The viewer is an extension page, so it is reached by runtime messaging + Browser.runtime.sendMessage({type: REFRESH_SCRIPT_VIEW}).catch(() => {}); + } + closePopup(); +} + function openHelpPage(): void { Browser.tabs.create({url: 'help.html', active: true}); closePopup(); @@ -263,11 +291,10 @@ window }); if (configureButton) { - if (IS_FULL_EXTENSION) { - configureButton.addEventListener('click', openOptionsPage); - } else { - configureButton.style.visibility = 'hidden'; - } + configureButton.addEventListener('click', openOptionsPage); +} +if (viewButton) { + viewButton.addEventListener('click', openScriptPage); } if (helpButton) { helpButton.addEventListener('click', openHelpPage); diff --git a/source/Popup/styles.scss b/source/Popup/styles.scss index 2660417d..0f450642 100644 --- a/source/Popup/styles.scss +++ b/source/Popup/styles.scss @@ -207,7 +207,48 @@ html { /* ICONS */ .setting-icon { grid-column: 1; - grid-row: 1 ; + grid-row: 1; + &__settings { + width: 4rem; + height: 4rem; + border-radius: 50%; + box-shadow: $shadow; + display: flex; + justify-content: center; + align-items: center; + font-size: 2rem; + cursor: pointer; + color: var(--greyDark); + transition: all .5s ease; + + &:active { + box-shadow: $inner-shadow; + color: var(--primary); + } + &:hover {color: var(--primary);} + } +} + +.view-icon { + grid-column: 1; + grid-row: 2; + /* A real button, so that it can be used from the keyboard, which looks like the other icons */ + width: 4rem; + height: 4rem; + justify-self: start; + padding: 0; + border: 0; + border-radius: 50%; + background: none; + font: inherit; + color: inherit; + cursor: pointer; + + &:focus-visible { + outline: .2rem solid var(--primary); + outline-offset: .2rem; + } + &__settings { width: 4rem; height: 4rem; @@ -295,7 +336,7 @@ html { width: 3rem; } -.download, .settings, .help, .done{ +.download, .settings, .help, .view, .done{ width: 2rem; } @@ -320,6 +361,7 @@ html { .record, .form, .setting-icon, + .view-icon, .help-icon { filter: invert(1) hue-rotate(180deg) grayscale(0.8) contrast(0.9) brightness(0.95); } diff --git a/source/ScriptView/dialogs.ts b/source/ScriptView/dialogs.ts new file mode 100644 index 00000000..86dac159 --- /dev/null +++ b/source/ScriptView/dialogs.ts @@ -0,0 +1,167 @@ +/* + * Zed Attack Proxy (ZAP) and its related source files. + * + * ZAP is an HTTP/HTTPS proxy for assessing web application security. + * + * Copyright 2026 The ZAP Development Team + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import RawStatement from '../types/zestScript/RawStatement'; +import i18n from '../utils/i18n'; +import { + LOCATOR_TYPES, + StatementTypeDef, + applyFieldValues, + getEditFields, + toFieldValues, +} from '../utils/zestEdit'; + +const editDialog = document.getElementById('edit-dialog') as HTMLDialogElement; +const editForm = document.getElementById('edit-form') as HTMLFormElement; +const editTitle = document.getElementById('edit-title') as HTMLElement; +const editFields = document.getElementById('edit-fields') as HTMLElement; +const editErrors = document.getElementById('edit-errors') as HTMLElement; +const editCancel = document.getElementById('edit-cancel') as HTMLButtonElement; +const confirmDialog = document.getElementById( + 'confirm-dialog' +) as HTMLDialogElement; +const confirmTitle = document.getElementById('confirm-title') as HTMLElement; +const confirmMessage = document.getElementById( + 'confirm-message' +) as HTMLElement; +const confirmText = document.getElementById('confirm-text') as HTMLElement; +const confirmCancel = document.getElementById( + 'confirm-cancel' +) as HTMLButtonElement; +const confirmOk = document.getElementById('confirm-ok') as HTMLButtonElement; + +function addEditField( + parent: HTMLElement, + key: string, + label: string, + kind: string, + value: string | boolean +): void { + const id = `field-${key}`; + const labelEl = document.createElement('label'); + labelEl.htmlFor = id; + labelEl.textContent = label; + let input: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; + if (kind === 'multiline') { + // A text input would remove any line breaks from the value + const textarea = document.createElement('textarea'); + textarea.rows = 4; + textarea.value = String(value); + input = textarea; + } else if (kind === 'locatorType') { + const select = document.createElement('select'); + // A type that the editor does not know about, e.g. in a loaded script, must + // be kept, as otherwise saving the statement would lose it + const types = LOCATOR_TYPES.includes(String(value)) + ? LOCATOR_TYPES + : [String(value), ...LOCATOR_TYPES]; + types.forEach((t) => select.add(new Option(t, t))); + select.value = String(value); + input = select; + } else { + input = document.createElement('input'); + input.type = kind === 'boolean' ? 'checkbox' : 'text'; + if (kind === 'boolean') { + input.checked = value === true; + } else { + input.value = String(value); + } + } + input.id = id; + input.name = key; + parent.append(labelEl, input); +} + +// A textarea reports all line breaks as LF, whatever the text had +const normalizeNewlines = (text: string): string => + text.replace(/\r\n?/g, '\n'); + +/** Shows the form for editing a statement, calling onSave with the valid result. */ +function openEditor( + def: StatementTypeDef, + statement: RawStatement, + title: string, + onSave: (updated: RawStatement) => void +): void { + editTitle.textContent = title; + editErrors.textContent = ''; + editFields.textContent = ''; + const values = toFieldValues(def, statement); + getEditFields(def).forEach((f) => + addEditField(editFields, f.key, i18n.t(f.labelKey), f.kind, values[f.key]) + ); + editForm.onsubmit = (e): void => { + e.preventDefault(); + const entered: Record = {}; + getEditFields(def).forEach((f) => { + const input = document.getElementById(`field-${f.key}`) as + HTMLInputElement | HTMLSelectElement; + if (f.kind === 'boolean') { + entered[f.key] = (input as HTMLInputElement).checked; + } else if ( + f.kind === 'multiline' && + normalizeNewlines(String(values[f.key])) === input.value + ) { + // Not edited, so keep the original line breaks rather than the LF ones + entered[f.key] = values[f.key]; + } else { + entered[f.key] = input.value; + } + }); + const result = applyFieldValues(def, statement, entered); + if (!result.statement) { + editErrors.textContent = result.errors.join('. '); + return; + } + editDialog.close(); + onSave(result.statement); + }; + editDialog.showModal(); +} + +/** Asks the user to confirm an action, calling onOk if they do. */ +function askConfirm( + title: string, + message: string, + detail: string, + okLabel: string, + onOk: () => void +): void { + confirmTitle.textContent = title; + confirmMessage.textContent = message; + confirmText.textContent = detail; + confirmText.hidden = detail === ''; + confirmOk.textContent = okLabel; + confirmOk.onclick = (): void => { + confirmDialog.close(); + onOk(); + }; + confirmDialog.showModal(); +} + +/** Closes the dialogs, e.g. because the script they were opened for has changed. */ +function closeDialogs(): void { + editDialog.close(); + confirmDialog.close(); +} + +editCancel.addEventListener('click', () => editDialog.close()); +confirmCancel.addEventListener('click', () => confirmDialog.close()); + +export {openEditor, askConfirm, closeDialogs}; diff --git a/source/ScriptView/index.ts b/source/ScriptView/index.ts new file mode 100644 index 00000000..51893f7a --- /dev/null +++ b/source/ScriptView/index.ts @@ -0,0 +1,813 @@ +/* + * Zed Attack Proxy (ZAP) and its related source files. + * + * ZAP is an HTTP/HTTPS proxy for assessing web application security. + * + * Copyright 2026 The ZAP Development Team + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Browser, {Runtime} from 'webextension-polyfill'; +import RawStatement from '../types/zestScript/RawStatement'; +import { + GET_ZEST_SCRIPT, + HISTORY_NONE, + HISTORY_RECORD, + HISTORY_REDO, + HISTORY_UNDO, + REFRESH_SCRIPT_VIEW, + REPLAY_CANCEL, + REPLAY_NEXT, + SET_ZEST_STATEMENTS, +} from '../utils/constants'; +import {summarizeStatement, summarizeZestScript} from '../utils/zestSummary'; +import type {ZestStatementSummary} from '../utils/zestSummary'; +import { + STATEMENT_TYPES, + getStatementType, + moveItem, + newStatement, + parseZestScript, +} from '../utils/zestEdit'; +import i18n from '../utils/i18n'; +import {downloadJson} from '../utils/util'; +import {ZestScriptMessage} from '../types/zestScript/ZestScript'; +import {askConfirm, closeDialogs, openEditor} from './dialogs'; +import ScriptPlayer, {RunMode, StatementState} from './player'; + +const titleElement = document.getElementById('script-title') as HTMLElement; +const messageElement = document.getElementById('script-message') as HTMLElement; +const tableBody = document.getElementById( + 'script-body' +) as HTMLTableSectionElement; +const table = document.getElementById('script-table') as HTMLElement; +const newButton = document.getElementById('new-btn') as HTMLButtonElement; +const loadButton = document.getElementById('load-btn') as HTMLButtonElement; +const saveButton = document.getElementById('save-btn') as HTMLButtonElement; +const loadFile = document.getElementById('load-file') as HTMLInputElement; +const refreshButton = document.getElementById( + 'refresh-btn' +) as HTMLButtonElement; +const runButton = document.getElementById('run-btn') as HTMLButtonElement; +const runModeSelect = document.getElementById('run-mode') as HTMLSelectElement; +const stepModeInput = document.getElementById('step-mode') as HTMLInputElement; +const stepLabel = document.getElementById('step-label') as HTMLElement; +const nextButton = document.getElementById('next-btn') as HTMLButtonElement; +const stopButton = document.getElementById('stop-btn') as HTMLButtonElement; +const undoButton = document.getElementById('undo-btn') as HTMLButtonElement; +const redoButton = document.getElementById('redo-btn') as HTMLButtonElement; +const addTypeSelect = document.getElementById('add-type') as HTMLSelectElement; +const addButton = document.getElementById('add-btn') as HTMLButtonElement; + +// Column of the table that shows the result of replaying a statement +const STATUS_CELL = 3; + +const RUN_MODE_KEY = 'zaprunmode'; + +// How long to wait for more statements to be recorded before showing them +const RECORDING_REFRESH_MSEC = 250; +// How close to the bottom of the page counts as being at the bottom +const BOTTOM_MARGIN_PX = 80; + +function t(key: string, options?: Record): string { + return i18n.t(key, options) as string; +} + +// Static text in the page is translated via data-i18n attributes +function translatePage(): void { + document.title = t('script.pageTitle'); + document.querySelectorAll('[data-i18n]').forEach((el) => { + const element = el; + element.textContent = t(element.dataset.i18n as string); + }); + document.querySelectorAll('[data-i18n-title]').forEach((el) => { + const element = el; + element.title = t(element.dataset.i18nTitle as string); + }); +} + +let statements: RawStatement[] = []; +// What undoing a change restores: the statements and the name of the script +interface UndoStep { + statements: RawStatement[]; + title: string; +} + +let undoStack: UndoStep[] = []; +// What undoing has taken away, which stays until another change is made +let redoStack: UndoStep[] = []; +let scriptTitle = ''; +let selected = -1; +let statementDelay = 0; +let statementCount = 0; +let recording = false; +let saving = false; +// The number of requests for the script that have not yet been answered +let loading = 0; +// Whether the statements shown are what is stored, as they are not if loading failed +let scriptLoaded = false; +// Changes whenever the script shown is replaced, so that dialogs opened for the old one can be ignored +let scriptVersion = 0; +// Set when the script changed while the tab could not be seen, so it is shown when it can +let refreshWhenVisible = false; +let refreshTimer: ReturnType | undefined; +// Identifies the latest request, so that replies to older ones can be ignored +let loadRequest = 0; +let player: ScriptPlayer | undefined; + +function canEdit(): boolean { + return ( + !recording && + !saving && + loading === 0 && + scriptLoaded && + player === undefined + ); +} + +function updateControls(): void { + const running = player !== undefined; + const editable = canEdit(); + runButton.hidden = running; + runModeSelect.hidden = running; + stepLabel.hidden = running; + runModeSelect.disabled = recording; + stopButton.hidden = !running; + refreshButton.disabled = running || saving; + runButton.disabled = + recording || saving || loading > 0 || !scriptLoaded || statementCount === 0; + runButton.title = recording ? t('script.stopRecordingFirst') : ''; + addButton.disabled = !editable; + addTypeSelect.disabled = !editable; + loadButton.disabled = !editable; + newButton.disabled = !editable; + saveButton.disabled = statementCount === 0; + undoButton.disabled = !editable || undoStack.length === 0; + redoButton.disabled = !editable || redoStack.length === 0; + // Locked for as long as something prevents changes, which is shown by dimming + // the buttons. Busy is only while a change or load is being completed, which + // is too short to be worth showing, as the buttons would only flicker. + table.classList.toggle('locked', recording || running || !scriptLoaded); + table.classList.toggle('busy', saving || loading > 0); +} + +function addCell(row: HTMLTableRowElement, text: string, cls = ''): void { + const cell = row.insertCell(); + cell.textContent = text; + if (cls) { + cell.className = cls; + } +} + +function addActionButton( + cell: HTMLElement, + text: string, + title: string, + handler: () => void, + enabled = true +): HTMLButtonElement { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = text; + button.title = title; + button.disabled = !enabled; + button.addEventListener('click', (e) => { + e.stopPropagation(); + if (e.detail > 0) { + // A click with the mouse gives the button the focus, and the buttons of a + // row are shown for as long as one of them has it. The rows are kept when + // the script changes, so they would still be shown when the mouse had gone. + // A click by the keyboard has no detail, and keeps the focus, so that + // the button can be used again. + button.blur(); + } + handler(); + }); + cell.appendChild(button); + return button; +} + +async function storedTitle(): Promise { + const items = await Browser.storage.sync.get({zapscriptname: ''}); + return items.zapscriptname as string; +} + +/** + * How a change affects what can be undone or redone: + * - HISTORY_RECORD: a new change, which can be undone, and which ends any redo + * - HISTORY_UNDO: undoes the latest step, which can then be redone + * - HISTORY_REDO: redoes the latest undone step, which can then be undone again + * - HISTORY_NONE: leaves them as they are + */ +type History = + | typeof HISTORY_RECORD + | typeof HISTORY_UNDO + | typeof HISTORY_REDO + | typeof HISTORY_NONE; + +/** + * Stores the new list of statements, which are renumbered by the background. + * If a title is given then that becomes the name of the script as well, and + * is put back if the statements cannot be stored. + * @returns true if they were stored. + */ +async function saveStatements( + newStatements: RawStatement[], + options: {title?: string; history?: History} = {} +): Promise { + const {title, history = HISTORY_RECORD} = options; + if (!canEdit()) { + return false; + } + // Wait for the saved statements to be shown, as the rows' buttons refer to + // their positions in the list that is currently displayed + saving = true; + updateControls(); + const before = statements; + let previousTitle = ''; + let pushed = false; + let titleChanged = false; + let stored = true; + try { + previousTitle = await storedTitle(); + if (history === HISTORY_RECORD) { + undoStack.push({statements: before, title: previousTitle}); + pushed = true; + } + if (title !== undefined && title !== previousTitle) { + await Browser.storage.sync.set({zapscriptname: title}); + titleChanged = true; + } + await Browser.runtime.sendMessage({ + type: SET_ZEST_STATEMENTS, + data: newStatements, + }); + // It is stored, so what can be undone and redone can now change + const previousState = {statements: before, title: previousTitle}; + if (history === HISTORY_RECORD) { + redoStack = []; + } else if (history === HISTORY_UNDO) { + undoStack.pop(); + redoStack.push(previousState); + } else if (history === HISTORY_REDO) { + redoStack.pop(); + undoStack.push(previousState); + } + // eslint-disable-next-line @typescript-eslint/no-use-before-define + await loadScript(); + } catch (e) { + stored = false; + if (pushed) { + undoStack.pop(); + } + if (titleChanged) { + try { + await Browser.storage.sync.set({zapscriptname: previousTitle}); + } catch { + // Nothing more can be done + } + } + // Show what is really stored, then why the change was not made + // eslint-disable-next-line @typescript-eslint/no-use-before-define + await loadScript(); + messageElement.textContent = t('script.saveFailed', { + error: e instanceof Error ? e.message : e, + }); + } finally { + saving = false; + updateControls(); + } + return stored; +} + +/** + * Called when the script shown is replaced, or may no longer be what is stored. + * Dialogs opened for the old script must not change the new one, as the + * statements they refer to may be different. + */ +function replaceScript(): void { + scriptVersion += 1; + closeDialogs(); +} + +function editStatement(position: number): void { + const statement = statements[position]; + const def = getStatementType(statement.elementType); + if (!def || !canEdit()) { + return; + } + const version = scriptVersion; + openEditor( + def, + statement, + t('script.editTitle', {type: t(def.labelKey)}), + (updated) => { + if (version !== scriptVersion) { + return; + } + const list = [...statements]; + list[position] = updated; + saveStatements(list); + } + ); +} + +function addStatement(): void { + const def = getStatementType(addTypeSelect.value); + if (!def || !canEdit()) { + return; + } + const at = selected >= 0 ? selected + 1 : statements.length; + const version = scriptVersion; + openEditor( + def, + newStatement(def.elementType), + t('script.addTitle', {type: t(def.labelKey)}), + (added) => { + if (version !== scriptVersion) { + return; + } + const list = [...statements]; + list.splice(at, 0, added); + selected = at; + saveStatements(list); + } + ); +} + +function moveStatement(position: number, delta: number): void { + const target = position + delta; + if (!canEdit() || target < 0 || target >= statements.length) { + return; + } + selected = target; + saveStatements(moveItem(statements, position, target)); +} + +function deleteStatement(position: number): void { + if (!canEdit()) { + return; + } + const version = scriptVersion; + askConfirm( + t('script.deleteTitle'), + t('script.deleteMessage'), + summarizeStatement(statements[position]), + t('script.delete'), + () => { + if (version !== scriptVersion) { + return; + } + selected = -1; + saveStatements(statements.filter((_s, i) => i !== position)); + } + ); +} + +function newScript(): void { + if (!canEdit()) { + return; + } + const version = scriptVersion; + const clear = async (): Promise => { + // The state may have changed while the dialog was open + if (version !== scriptVersion || !canEdit()) { + return; + } + selected = -1; + await saveStatements([], { + title: '', + history: + statements.length > 0 || scriptTitle !== '' + ? HISTORY_RECORD + : HISTORY_NONE, + }); + }; + if (statements.length === 0 && scriptTitle === '') { + clear(); + return; + } + askConfirm( + t('script.newTitle'), + t('script.newMessage', {count: statements.length}), + '', + t('script.discard'), + clear + ); +} + +async function undo(): Promise { + if (!canEdit() || undoStack.length === 0) { + return; + } + // The step is only forgotten once it has been undone, so a failure can be retried + const step = undoStack[undoStack.length - 1]; + selected = -1; + await saveStatements(step.statements, { + title: step.title, + history: HISTORY_UNDO, + }); +} + +async function redo(): Promise { + if (!canEdit() || redoStack.length === 0) { + return; + } + const step = redoStack[redoStack.length - 1]; + selected = -1; + await saveStatements(step.statements, { + title: step.title, + history: HISTORY_REDO, + }); +} + +/** What to say while a recording is being made, as the script is changing. */ +function recordingNote(count: number): string { + return count === 0 + ? t('script.recordingEmpty') + : t('script.recording', {count}); +} + +function isAtBottom(): boolean { + return ( + window.innerHeight + window.scrollY >= + document.documentElement.scrollHeight - BOTTOM_MARGIN_PX + ); +} + +/** + * Makes a row of the table, with its cells and buttons but not what they show. + * A row stays for its position in the script, whatever statement is there, so + * that a change to the script does not replace the rows, as the one under the + * mouse would then lose its hover until the mouse moved, and its buttons would + * flicker. + */ +function createRow(position: number): HTMLTableRowElement { + const row = tableBody.insertRow(); + addCell(row, '', 'index'); + addCell(row, ''); + const actions = row.insertCell(); + actions.className = 'actions'; + addCell(row, '', 'status'); + addActionButton(actions, '▲', t('script.moveUp'), () => + moveStatement(position, -1) + ); + addActionButton(actions, '▼', t('script.moveDown'), () => + moveStatement(position, 1) + ); + addActionButton(actions, '✎', t('script.edit'), () => + editStatement(position) + ); + addActionButton(actions, '✕', t('script.delete'), () => + deleteStatement(position) + ); + // This does nothing for a statement that cannot be edited + row.addEventListener('dblclick', () => editStatement(position)); + row.addEventListener('click', () => { + selected = position; + Array.from(tableBody.rows).forEach((r, i) => + r.classList.toggle('selected', i === position) + ); + }); + return row; +} + +/** Shows a statement in the row for its position. */ +function updateRow( + row: HTMLTableRowElement, + position: number, + statement: ZestStatementSummary +): void { + // Anything left by running the script is cleared, as the script is replaced + row.className = ''; + row.classList.toggle('disabled', !statement.enabled); + row.classList.toggle('selected', position === selected); + row.cells[0].textContent = String(statement.index); + row.cells[1].textContent = statement.summary; + row.cells[STATUS_CELL].textContent = ''; + const [up, down, edit] = Array.from(row.querySelectorAll('button')); + // The first statement cannot move up, and the last cannot move down + up.disabled = position === 0; + down.disabled = position === statements.length - 1; + edit.hidden = !getStatementType(statements[position].elementType); +} + +function render(msg: ZestScriptMessage): void { + // Only follow the recording if the end of the script is being looked at, so + // that scrolling up to look at something is not undone by the next statement + const followRecording = recording && isAtBottom(); + replaceScript(); + scriptLoaded = true; + const script = JSON.parse(msg.script); + statements = script.statements ?? []; + statementDelay = Number(script.options?.statementDelay) || 0; + statementCount = msg.statementCount; + if (selected >= statements.length) { + selected = -1; + } + scriptTitle = msg.title; + titleElement.textContent = + msg.title !== '' ? msg.title : t('script.untitled'); + if (msg.statementCount === 0) { + messageElement.textContent = recording + ? recordingNote(0) + : t('script.noStatements'); + tableBody.textContent = ''; + table.hidden = true; + return; + } + messageElement.textContent = recording + ? recordingNote(msg.statementCount) + : t('script.statementCount', {count: msg.statementCount}); + table.hidden = false; + const summaries = summarizeZestScript(msg.script); + while (tableBody.rows.length > summaries.length) { + tableBody.deleteRow(-1); + } + summaries.forEach((statement, position) => { + updateRow( + tableBody.rows[position] ?? createRow(position), + position, + statement + ); + }); + if (followRecording) { + tableBody.rows[tableBody.rows.length - 1]?.scrollIntoView({ + block: 'nearest', + }); + } +} + +async function loadScript(): Promise { + loadRequest += 1; + const request = loadRequest; + loading += 1; + updateControls(); + try { + const msg = (await Browser.runtime.sendMessage({ + type: GET_ZEST_SCRIPT, + })) as ZestScriptMessage; + // A newer request, or a script that is now running, makes this reply out of date + if (request === loadRequest && !player) { + render(msg); + } + } catch (e) { + if (request === loadRequest) { + // What is shown is no longer known to be what is stored, so nothing can + // be changed until it has been loaded again + replaceScript(); + scriptLoaded = false; + messageElement.textContent = t('script.loadScriptFailed', {error: e}); + table.hidden = true; + statementCount = 0; + } + } finally { + loading -= 1; + updateControls(); + } +} + +async function loadFromFile(): Promise { + const file = loadFile.files?.[0]; + loadFile.value = ''; + if (!file || !canEdit()) { + return; + } + try { + const script = parseZestScript(await file.text()); + selected = -1; + // The name is replaced even if it is empty, as the old one is not this script's + if (await saveStatements(script.statements, {title: script.title})) { + messageElement.textContent = t('script.loaded', { + count: script.statements.length, + file: file.name, + }); + } + } catch (e) { + messageElement.textContent = t('script.loadFileFailed', { + file: file.name, + error: e instanceof Error ? e.message : e, + }); + } +} + +async function saveToFile(): Promise { + const msg = (await Browser.runtime.sendMessage({ + type: GET_ZEST_SCRIPT, + })) as ZestScriptMessage; + if (msg.statementCount === 0) { + return; + } + const title = msg.title !== '' ? msg.title : t('script.defaultFileName'); + downloadJson(msg.script, title + (title.slice(-4) === '.zst' ? '' : '.zst')); +} + +function showState( + position: number, + state: StatementState, + message?: string +): void { + const row = tableBody.rows[position]; + if (!row) { + // The script shown is not the one being run, which should not be possible + return; + } + row.classList.remove('running', 'ok', 'failed', 'skipped'); + row.classList.add(state); + const labels = {running: '…', ok: '✓', failed: `✗ ${message}`, skipped: ''}; + row.cells[STATUS_CELL].textContent = labels[state]; + if (state === 'running') { + row.scrollIntoView({block: 'nearest'}); + } +} + +async function runScript(): Promise { + if (player || recording || saving || statementCount === 0) { + return; + } + Array.from(tableBody.rows).forEach((row) => { + row.classList.remove('running', 'ok', 'failed', 'skipped'); + row.cells[STATUS_CELL].textContent = ''; + }); + player = new ScriptPlayer( + runModeSelect.value as RunMode, + stepModeInput.checked + ); + player.onPauseChange = (paused, nextStatement): void => { + nextButton.hidden = !paused; + messageElement.textContent = paused + ? t('script.paused', {statement: nextStatement}) + : t('script.running'); + }; + updateControls(); + messageElement.textContent = t('script.running'); + let message = t('script.failed'); + try { + const ok = await player.play(statements, statementDelay, showState); + message = ok ? t('script.completed') : t('script.failed'); + } catch (e) { + message = t('script.runError', { + error: e instanceof Error ? e.message : e, + }); + } finally { + // Whatever happened, the controls must not stay locked + player = undefined; + nextButton.hidden = true; + } + messageElement.textContent = message; + updateControls(); +} + +async function loadRecordingState(): Promise { + const items = await Browser.storage.sync.get({zaprecordingactive: false}); + recording = items.zaprecordingactive as boolean; + if (recording && scriptLoaded) { + messageElement.textContent = recordingNote(statementCount); + } + updateControls(); +} + +/** Whether the statements stored are the ones that are being shown already. */ +function isShown(stored: unknown): boolean { + if (!Array.isArray(stored) || stored.length !== statements.length) { + return false; + } + try { + return stored.every( + (statement, i) => + JSON.stringify(JSON.parse(statement)) === JSON.stringify(statements[i]) + ); + } catch { + return false; + } +} + +function refreshStoredScript(): void { + refreshTimer = undefined; + if (document.hidden) { + // Not worth doing while it cannot be seen + refreshWhenVisible = true; + return; + } + refreshWhenVisible = false; + loadScript(); +} + +/** + * The script is changed by recording, which stores each statement as it is + * recorded, so show it. The changes are gathered, as they can come quickly. + */ +function storedScriptChanged(stored: unknown): void { + // The viewer's own changes, which it shows itself, need nothing more + if (saving || player || isShown(stored)) { + return; + } + // The script is not the one that the undo and redo steps are for + undoStack = []; + redoStack = []; + updateControls(); + if (refreshTimer === undefined) { + refreshTimer = setTimeout(refreshStoredScript, RECORDING_REFRESH_MSEC); + } +} + +Browser.storage.onChanged.addListener((changes, areaName) => { + if (changes.zaprecordingactive) { + recording = changes.zaprecordingactive.newValue === true; + // The script is replaced by a new recording, so old undo states are stale + undoStack = []; + redoStack = []; + selected = -1; + if (recording) { + // The script is about to be replaced, so no dialog for it can be used + replaceScript(); + // Replaying would otherwise be recorded + player?.stop(); + messageElement.textContent = recordingNote(statementCount); + updateControls(); + } else { + // Shown now, rather than when the changes that are waiting are gathered + clearTimeout(refreshTimer); + refreshTimer = undefined; + loadScript(); + } + } + if (areaName === 'local' && changes.zestStatements) { + storedScriptChanged(changes.zestStatements.newValue); + } +}); + +document.addEventListener('visibilitychange', () => { + if (!document.hidden && refreshWhenVisible) { + refreshWhenVisible = false; + loadScript(); + } +}); + +STATEMENT_TYPES.forEach((type) => + addTypeSelect.add(new Option(t(type.labelKey), type.elementType)) +); + +document.addEventListener('DOMContentLoaded', () => { + translatePage(); + loadRecordingState(); + loadScript(); +}); + +// Remember the run mode, if the browser allows it +try { + runModeSelect.value = localStorage.getItem(RUN_MODE_KEY) ?? 'window'; +} catch { + // Leave the default +} +runModeSelect.addEventListener('change', () => { + try { + localStorage.setItem(RUN_MODE_KEY, runModeSelect.value); + } catch { + // Not remembered + } +}); + +newButton.addEventListener('click', newScript); +loadButton.addEventListener('click', () => loadFile.click()); +loadFile.addEventListener('change', loadFromFile); +saveButton.addEventListener('click', saveToFile); +refreshButton.addEventListener('click', loadScript); +runButton.addEventListener('click', runScript); +stopButton.addEventListener('click', () => player?.stop()); +nextButton.addEventListener('click', () => player?.next()); +addButton.addEventListener('click', addStatement); +undoButton.addEventListener('click', undo); +redoButton.addEventListener('click', redo); + +// The same actions are available from the box shown in the page being replayed +Browser.runtime.onMessage.addListener( + (message: unknown, sender: Runtime.MessageSender) => { + const {type} = message as {type: string}; + if (type === REPLAY_NEXT || type === REPLAY_CANCEL) { + // Every open viewer gets these, but only the one replaying in that tab + // may act on them + if (!player?.ownsTab(sender?.tab?.id)) { + return; + } + if (type === REPLAY_NEXT) { + player.next(); + } else { + player.stop(); + } + } else if (type === REFRESH_SCRIPT_VIEW && !player && !saving) { + // The tab has been shown again, e.g. from the popup + loadScript(); + } + } +); diff --git a/source/ScriptView/player.ts b/source/ScriptView/player.ts new file mode 100644 index 00000000..6c372e75 --- /dev/null +++ b/source/ScriptView/player.ts @@ -0,0 +1,398 @@ +/* + * Zed Attack Proxy (ZAP) and its related source files. + * + * ZAP is an HTTP/HTTPS proxy for assessing web application security. + * + * Copyright 2026 The ZAP Development Team + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Browser from 'webextension-polyfill'; +import RawStatement from '../types/zestScript/RawStatement'; +import {sleep} from '../utils/util'; +import { + REPLAY_ABORT, + REPLAY_FINISHED, + REPLAY_PAUSE, + REPLAY_PING, + REPLAY_STATEMENT, + ZEST_CLIENT_LAUNCH, + ZEST_CLIENT_WINDOW_CLOSE, + ZEST_COMMENT, +} from '../utils/constants'; +import i18n from '../utils/i18n'; +import {isReplayableUrl} from '../utils/zestEdit'; +import {summarizeStatement} from '../utils/zestSummary'; +import type { + ReplayDisplay, + ReplayFinishDisplay, + ReplayPauseDisplay, + ReplayResult, +} from '../ContentScript/replayer'; + +type RunMode = 'window' | 'tab'; + +type StatementState = 'running' | 'ok' | 'failed' | 'skipped'; + +const TAB_LOAD_TIMEOUT_MSEC = 30000; +const SEND_RETRIES = 20; +const SEND_RETRY_MSEC = 250; +// Gives a page a chance to start navigating after an action +const SETTLE_MSEC = 200; +// How long to wait for a page to load before showing how the script finished +const FINISH_WAIT_MSEC = 2000; + +function t(key: string, options?: Record): string { + return i18n.t(key, options) as string; +} + +/** + * Waits for the tab to finish loading, giving up if it takes too long or if + * the signal is aborted. The timer and listener are always cleaned up. + */ +function waitForTabLoad( + tabId: number, + signal?: AbortSignal, + timeoutMsec = TAB_LOAD_TIMEOUT_MSEC +): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error(t('script.error.stopped'))); + return; + } + const state: { + timer?: ReturnType; + onUpdated?: (id: number, info: {status?: string}) => void; + onAbort?: () => void; + } = {}; + const finish = (error?: Error): void => { + clearTimeout(state.timer); + if (state.onUpdated) { + Browser.tabs.onUpdated.removeListener(state.onUpdated); + } + if (state.onAbort) { + signal?.removeEventListener('abort', state.onAbort); + } + if (error) { + reject(error); + } else { + resolve(); + } + }; + state.onUpdated = (id, info): void => { + if (id === tabId && info.status === 'complete') { + finish(); + } + }; + state.onAbort = (): void => finish(new Error(t('script.error.stopped'))); + state.timer = setTimeout( + () => finish(new Error(t('script.error.loadTimeout'))), + timeoutMsec + ); + Browser.tabs.onUpdated.addListener(state.onUpdated); + signal?.addEventListener('abort', state.onAbort); + Browser.tabs.get(tabId).then( + (tab) => state.onUpdated?.(tabId, tab), + (error) => finish(error) + ); + }); +} + +/** + * Replays a Zest script in a new window or tab. The first launch statement + * creates it and everything else runs against it. Window close and comment statements are ignored. + */ +class ScriptPlayer { + private mode: RunMode; + + private stepThrough: boolean; + + private resumeNext?: () => void; + + /** Called when the player starts or stops waiting for the user to press Next. */ + onPauseChange?: (paused: boolean, nextStatement: string) => void; + + private stopped = false; + + // Aborted when the script is stopped, to end any waits at once + private aborter = new AbortController(); + + private tabId?: number; + + constructor(mode: RunMode = 'window', stepThrough = false) { + this.mode = mode; + this.stepThrough = stepThrough; + } + + stop(): void { + this.stopped = true; + this.aborter.abort(); + this.resumeNext?.(); + if (this.tabId !== undefined) { + // Stops a statement that is waiting for its element or about to act + Browser.tabs + .sendMessage(this.tabId, {type: REPLAY_ABORT}) + .catch(() => {}); + } + } + + /** Whether the tab is the one that this player is replaying the script in. */ + ownsTab(tabId?: number): boolean { + return tabId !== undefined && tabId === this.tabId; + } + + /** Runs the next statement when stepping through the script. */ + next(): void { + this.resumeNext?.(); + } + + // Best effort - the controls are also available in the script tab + private async showStepControls( + display: ReplayPauseDisplay, + signal: AbortSignal + ): Promise { + try { + await waitForTabLoad(this.tabId as number, signal); + if (!this.resumeNext) { + // Next or Stop was used while the page was loading + return; + } + await Browser.tabs.sendMessage(this.tabId as number, { + type: REPLAY_PAUSE, + data: display, + }); + } catch { + // Ignore, e.g. the tab has been closed + } + } + + private async waitForNext( + statementText: string, + display: ReplayPauseDisplay + ): Promise { + const resumed = new Promise((resolve) => { + this.resumeNext = resolve; + }); + this.onPauseChange?.(true, statementText); + // Ends the wait for the page to show the buttons, once they are not needed + const paused = new AbortController(); + this.showStepControls(display, paused.signal); + await resumed; + paused.abort(); + this.resumeNext = undefined; + this.onPauseChange?.(false, statementText); + this.checkNotStopped(); + } + + /** + * Waits until the content script in the page answers. This is done with a + * message that has no effect, so that it is safe to repeat. + */ + private async waitForPage(): Promise { + let lastError: unknown; + for (let i = 0; i < SEND_RETRIES; i += 1) { + this.checkNotStopped(); + try { + // eslint-disable-next-line no-await-in-loop + await Browser.tabs.sendMessage(this.tabId as number, { + type: REPLAY_PING, + }); + return; + } catch (e) { + // The content script may not be ready yet + lastError = e; + // eslint-disable-next-line no-await-in-loop + await sleep(SEND_RETRY_MSEC, this.aborter.signal); + } + } + throw new Error(t('script.error.talkFailed', {error: lastError})); + } + + private async tabExists(): Promise { + try { + await Browser.tabs.get(this.tabId as number); + return true; + } catch { + return false; + } + } + + private async sendToTab( + statement: RawStatement, + display: ReplayDisplay + ): Promise { + await this.waitForPage(); + this.checkNotStopped(); + // The statement is only ever sent once, as it may have had an effect, even + // if there was no answer + try { + return (await Browser.tabs.sendMessage(this.tabId as number, { + type: REPLAY_STATEMENT, + data: statement, + display, + })) as ReplayResult; + } catch (e) { + if (await this.tabExists()) { + // The page was replaced, e.g. by a click that loads another page, which + // closes the channel the answer would have used + return {ok: true}; + } + throw new Error(t('script.error.talkFailed', {error: e})); + } + } + + private checkNotStopped(): void { + if (this.stopped) { + throw new Error(t('script.error.stopped')); + } + } + + private async runStatement( + statement: RawStatement, + display: ReplayDisplay + ): Promise { + this.checkNotStopped(); + if (statement.elementType === ZEST_CLIENT_LAUNCH) { + const url = String(statement.url); + if (!isReplayableUrl(url)) { + throw new Error(t('script.error.badUrl', {url})); + } + if (this.tabId === undefined) { + if (this.mode === 'window') { + const win = await Browser.windows.create({url, focused: true}); + this.tabId = win.tabs?.[0]?.id; + } else { + const tab = await Browser.tabs.create({url, active: true}); + this.tabId = tab.id; + } + if (this.tabId === undefined) { + throw new Error(t('script.error.openFailed')); + } + } else { + await Browser.tabs.update(this.tabId, {url}); + } + await waitForTabLoad(this.tabId as number, this.aborter.signal); + this.checkNotStopped(); + return; + } + if (this.tabId === undefined) { + throw new Error(t('script.error.noLaunch')); + } + await waitForTabLoad(this.tabId, this.aborter.signal); + const result = await this.sendToTab(statement, display); + // A statement that finished after the script was stopped is not a success + this.checkNotStopped(); + if (!result.ok) { + throw new Error( + t(result.errorKey ?? 'script.error.unexpected', result.errorParams) + ); + } + await sleep(SETTLE_MSEC, this.aborter.signal); + this.checkNotStopped(); + } + + // Best effort - the overlay is purely informational + private async notifyFinished(ok: boolean, message: string): Promise { + const display: ReplayFinishDisplay = { + ok, + message, + title: t('script.overlayTitle'), + heading: ok ? t('script.complete') : t('script.failedHeading'), + }; + try { + if (!this.stopped) { + // Only for a short time, as nothing depends on this being shown + await waitForTabLoad(this.tabId as number, undefined, FINISH_WAIT_MSEC); + } + } catch { + // Try to show it anyway + } + try { + await Browser.tabs.sendMessage(this.tabId as number, { + type: REPLAY_FINISHED, + data: display, + }); + } catch { + // Ignore, e.g. the tab has been closed + } + } + + /** + * Runs the statements in order, stopping at the first failure. + * @returns true if all of the statements ran successfully. + */ + async play( + statements: RawStatement[], + delayMsec: number, + onState: (position: number, state: StatementState, message?: string) => void + ): Promise { + this.stopped = false; + this.aborter = new AbortController(); + this.tabId = undefined; + let first = true; + for (let i = 0; i < statements.length; i += 1) { + const statement = statements[i]; + if ( + statement.enabled === false || + statement.elementType === ZEST_CLIENT_WINDOW_CLOSE || + statement.elementType === ZEST_COMMENT + ) { + onState(i, 'skipped'); + } else { + onState(i, 'running'); + try { + const text = summarizeStatement(statement); + const counts = {position: i + 1, total: statements.length}; + const display: ReplayDisplay = { + title: t('script.overlayTitle'), + heading: t('script.step', counts), + text, + }; + if (!first && this.stepThrough) { + // eslint-disable-next-line no-await-in-loop + await this.waitForNext(text, { + title: display.title, + heading: t('script.stepPaused', counts), + text: t('script.nextStatement', {statement: text}), + nextLabel: t('script.next'), + cancelLabel: t('script.cancel'), + }); + } else if (!first && delayMsec > 0) { + // eslint-disable-next-line no-await-in-loop + await sleep(delayMsec, this.aborter.signal); + this.checkNotStopped(); + } + first = false; + // eslint-disable-next-line no-await-in-loop + await this.runStatement(statement, display); + onState(i, 'ok'); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + onState(i, 'failed', message); + if (this.tabId !== undefined) { + // eslint-disable-next-line no-await-in-loop + await this.notifyFinished(false, message); + } + return false; + } + } + } + if (this.tabId !== undefined) { + await this.notifyFinished(true, t('script.completed')); + } + return true; + } +} + +export default ScriptPlayer; +export type {RunMode, StatementState}; diff --git a/source/assets/icons/view.svg b/source/assets/icons/view.svg new file mode 100644 index 00000000..a20923ac --- /dev/null +++ b/source/assets/icons/view.svg @@ -0,0 +1 @@ + diff --git a/source/Popup/i18n.tsx b/source/types/zestScript/RawStatement.ts similarity index 56% rename from source/Popup/i18n.tsx rename to source/types/zestScript/RawStatement.ts index f51a51dd..3c5788b3 100644 --- a/source/Popup/i18n.tsx +++ b/source/types/zestScript/RawStatement.ts @@ -3,7 +3,7 @@ * * ZAP is an HTTP/HTTPS proxy for assessing web application security. * - * Copyright 2023 The ZAP Development Team + * Copyright 2026 The ZAP Development Team * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,27 +17,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import i18n from 'i18next'; -import LanguageDetector from 'i18next-browser-languagedetector'; -i18n.use(LanguageDetector).init({ - resources: { - en: { - translation: { - start: 'Start Recording', - stop: 'Stop Recording', - download: 'Download Script', - options: 'Options', - help: 'Help', - }, - }, - }, - defaultNS: 'translation', - fallbackLng: 'en', - debug: false, - interpolation: { - escapeValue: false, - }, -}); +/** A Zest statement as JSON, which may be of a type that the extension does not model. */ +type RawStatement = Record; -export default i18n; +export default RawStatement; diff --git a/source/types/zestScript/ZestScript.ts b/source/types/zestScript/ZestScript.ts index 413d54af..8ce0c4f8 100644 --- a/source/types/zestScript/ZestScript.ts +++ b/source/types/zestScript/ZestScript.ts @@ -57,6 +57,32 @@ class ZestScript { this.curIndex = 1; } + /** + * Works out the statements, numbered from 1, that would replace the current + * ones, without changing the script. Use restoreStatements to apply them + * once they have been persisted. + * @throws if the statements are not a list of objects. + */ + prepareStatements(statements: unknown): { + statements: string[]; + curIndex: number; + } { + if ( + !Array.isArray(statements) || + !statements.every( + (s) => s !== null && typeof s === 'object' && !Array.isArray(s) + ) + ) { + throw new Error('The statements must be a list of objects'); + } + return { + statements: statements.map((statement, i) => + JSON.stringify({...statement, index: i + 1}) + ), + curIndex: statements.length + 1, + }; + } + getStatements(): string[] { return [...this.zestStatements]; } @@ -106,22 +132,21 @@ class ZestScript { } getZestScript(): Promise { - return new Promise((resolve) => { - Browser.storage.sync - .get({ - zapscriptname: this.title, - zapstatementdelay: DEFAULT_STATEMENT_DELAY, - }) - .then((items) => { - this.title = items.zapscriptname as string; - this.statementDelay = items.zapstatementdelay as number; - resolve({ - script: this.toJSON(), - title: this.title, - statementCount: this.getZestStatementCount(), - }); - }); - }); + // A failure to read the settings must reject, not leave the caller waiting + return Browser.storage.sync + .get({ + zapscriptname: this.title, + zapstatementdelay: DEFAULT_STATEMENT_DELAY, + }) + .then((items) => { + this.title = items.zapscriptname as string; + this.statementDelay = items.zapstatementdelay as number; + return { + script: this.toJSON(), + title: this.title, + statementCount: this.getZestStatementCount(), + }; + }); } } diff --git a/source/utils/constants.ts b/source/utils/constants.ts index 7e30803c..d897a170 100644 --- a/source/utils/constants.ts +++ b/source/utils/constants.ts @@ -43,7 +43,22 @@ export const ZEST_SCRIPT = 'zestScript'; export const STOP_RECORDING = 'stopRecording'; export const RESET_ZEST_SCRIPT = 'resetZestScript'; export const GET_ZEST_SCRIPT = 'getZestScript'; +export const SET_ZEST_STATEMENTS = 'setZestStatements'; export const UPDATE_TITLE = 'updateTitle'; +export const REPLAY_STATEMENT = 'replayStatement'; +export const REPLAY_FINISHED = 'replayFinished'; +export const REPLAY_PAUSE = 'replayPause'; +export const REPLAY_NEXT = 'replayNext'; +export const REPLAY_CANCEL = 'replayCancel'; +export const REPLAY_ABORT = 'replayAbort'; +export const REPLAY_PING = 'replayPing'; +export const REFRESH_SCRIPT_VIEW = 'refreshScriptView'; + +// How a change to the script affects what can be undone and redone +export const HISTORY_RECORD = 'record'; +export const HISTORY_UNDO = 'undo'; +export const HISTORY_REDO = 'redo'; +export const HISTORY_NONE = 'none'; export const REPORT_EVENT = 'reportEvent'; export const REPORT_OBJECT = 'reportObject'; diff --git a/source/utils/i18n.ts b/source/utils/i18n.ts new file mode 100644 index 00000000..462fcf4d --- /dev/null +++ b/source/utils/i18n.ts @@ -0,0 +1,179 @@ +/* + * Zed Attack Proxy (ZAP) and its related source files. + * + * ZAP is an HTTP/HTTPS proxy for assessing web application security. + * + * Copyright 2023 The ZAP Development Team + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import i18n from 'i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; + +i18n.use(LanguageDetector).init({ + resources: { + en: { + translation: { + start: 'Start Recording', + stop: 'Stop Recording', + download: 'Download Script', + view: 'View Script', + options: 'Options', + help: 'Help', + loginUrl: 'Login URL', + scriptName: 'Script Name', + script: { + untitled: 'Untitled script', + defaultFileName: 'script', + noStatements: + 'There are no statements. Record a script, or use Add to create one.', + statementCount: '{{count}} statement(s)', + recording: 'Recording... {{count}} statement(s)', + recordingEmpty: 'Recording... no statements yet', + loadScriptFailed: 'Failed to load script: {{error}}', + saveFailed: 'Failed to save the changes: {{error}}', + loaded: 'Loaded {{count}} statement(s) from {{file}}', + loadFileFailed: 'Failed to load {{file}}: {{error}}', + running: 'Running...', + completed: 'Script completed successfully', + failed: 'Script failed', + runError: 'Script failed: {{error}}', + paused: 'Paused. Next: {{statement}}', + stopRecordingFirst: 'Stop recording before running the script', + moveUp: 'Move up', + moveDown: 'Move down', + edit: 'Edit', + delete: 'Delete', + editTitle: 'Edit {{type}}', + addTitle: 'Add {{type}}', + deleteTitle: 'Delete statement', + deleteMessage: 'Are you sure you want to delete this statement?', + newTitle: 'New script', + newMessage: + 'This will discard the current script and its {{count}} statement(s). Are you sure?', + discard: 'Discard', + new: 'New', + load: 'Load...', + save: 'Save', + refresh: 'Refresh', + run: 'Run', + runInWindow: 'in New Window', + runInTab: 'in New Tab', + runModeTitle: 'Where to run the script', + stepThrough: 'Step through', + next: 'Next', + stop: 'Stop', + undo: 'Undo', + redo: 'Redo', + add: 'Add', + columnStatement: 'Statement', + columnResult: 'Result', + cancel: 'Cancel', + pageTitle: 'ZAP Recorded Script', + overlayTitle: 'ZAP Replay', + step: 'Step {{position}} of {{total}}', + stepPaused: 'Step {{position}} of {{total}} - paused', + nextStatement: 'Next: {{statement}}', + complete: 'Complete', + failedHeading: 'Failed', + error: { + stopped: 'Stopped', + noLaunch: 'The script must start with a launch statement', + badUrl: 'Only http and https URLs can be launched: {{url}}', + openFailed: 'Failed to open the page', + talkFailed: 'Could not talk to the page: {{error}}', + loadTimeout: 'Timed out waiting for the page to load', + elementNotFound: 'Element not found: {{type}}: {{element}}', + elementDisabled: 'Element is disabled: {{type}}: {{element}}', + badLocator: 'Unsupported locator type: {{type}}', + badStatement: 'Unsupported statement: {{type}}', + optionNotFound: 'Option not found: {{option}}', + frameNotFound: 'Frame not found: {{frame}}', + frameCrossOrigin: + 'Cannot access frame {{frame}}, it may be cross origin', + topWindow: 'Already in the top window', + notInForm: 'Element is not in a form', + unexpected: '{{message}}', + }, + }, + summary: { + launch: 'Launch {{browser}} {{url}}', + click: 'Click element {{locator}}', + scrollTo: 'Scroll to element {{locator}}', + sendKeys: 'Send keys "{{keys}}" to element {{locator}}', + submit: 'Submit element {{locator}}', + clear: 'Clear element {{locator}}', + mouseOver: 'Mouse over element {{locator}}', + switchToFrame: 'Switch to frame {{frame}}', + frameParent: 'parent', + frameName: 'name {{name}}', + frameIndex: 'index {{index}}', + closeWindow: 'Close window {{handle}}', + comment: 'Comment: {{comment}}', + unknown: 'Unknown statement', + }, + edit: { + type: { + launch: 'Launch', + click: 'Click', + sendKeys: 'Send keys', + clear: 'Clear', + submit: 'Submit', + scrollTo: 'Scroll to', + mouseOver: 'Mouse over', + switchToFrame: 'Switch to frame', + closeWindow: 'Close window', + comment: 'Comment', + }, + field: { + locatorType: 'Locator type', + locator: 'Locator', + waitFor: 'Wait for (ms)', + keys: 'Keys', + browser: 'Browser', + url: 'URL', + frameIndex: 'Frame index (-1 for parent)', + frameName: 'Frame name', + sleep: 'Sleep (seconds)', + comment: 'Comment', + enabled: 'Enabled', + }, + error: { + required: '{{field}} is required', + wholeNumber: '{{field}} must be a whole number', + badUrl: 'URL must be a valid http or https URL', + notJson: 'The file is not valid JSON', + notZest: 'The file is not a Zest script', + unsupportedMetadata: + 'The script uses features that cannot be preserved ({{features}})', + }, + metadata: { + type: 'script type', + description: 'description', + prefix: 'prefix', + parameters: 'parameters', + authentication: 'authentication', + }, + }, + }, + }, + }, + defaultNS: 'translation', + fallbackLng: 'en', + debug: false, + interpolation: { + escapeValue: false, + }, +}); + +export default i18n; diff --git a/source/utils/tabs.ts b/source/utils/tabs.ts new file mode 100644 index 00000000..5d83ffae --- /dev/null +++ b/source/utils/tabs.ts @@ -0,0 +1,74 @@ +/* + * Zed Attack Proxy (ZAP) and its related source files. + * + * ZAP is an HTTP/HTTPS proxy for assessing web application security. + * + * Copyright 2026 The ZAP Development Team + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Browser from 'webextension-polyfill'; + +/** + * Shows the tab for one of the extension's pages if it is already open, + * otherwise opens it in a new tab. Any problem looking for an existing tab + * (e.g. the browser not supporting the query) just results in a new tab. + */ +async function showOrCreateTab( + page: string +): Promise<{tab: Browser.Tabs.Tab; existed: boolean}> { + try { + const [existing] = await Browser.tabs.query({ + url: Browser.runtime.getURL(page), + }); + if (existing?.id !== undefined) { + const tab = await Browser.tabs.update(existing.id, {active: true}); + if (existing.windowId !== undefined) { + try { + await Browser.windows.update(existing.windowId, {focused: true}); + } catch { + // The tab is shown, it just may not be in front + } + } + return {tab: tab ?? existing, existed: true}; + } + } catch (e) { + console.log('ZAP could not look for an existing tab', e); + } + return { + tab: await Browser.tabs.create({url: page, active: true}), + existed: false, + }; +} + +/** + * Makes sure that one of the extension's pages is open, by opening it in a tab + * behind the current one if it is not. This never switches to the page, so the + * user stays where they are. + */ +async function openInBackgroundIfClosed(page: string): Promise { + try { + const [existing] = await Browser.tabs.query({ + url: Browser.runtime.getURL(page), + }); + if (existing) { + return; + } + } catch (e) { + console.log('ZAP could not look for an existing tab', e); + } + await Browser.tabs.create({url: page, active: false}); +} + +export default showOrCreateTab; +export {openInBackgroundIfClosed}; diff --git a/source/utils/util.ts b/source/utils/util.ts index f9be7767..1d9c99f5 100644 --- a/source/utils/util.ts +++ b/source/utils/util.ts @@ -34,4 +34,28 @@ function downloadJson(json: string, title: string): void { URL.revokeObjectURL(url); } -export {downloadJson}; +/** + * Waits for the given time. If a signal is given then it stops waiting as soon + * as the signal is aborted, so callers should check for that afterwards. + */ +function sleep(msec: number, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal?.aborted) { + resolve(); + return; + } + const state: {timer?: ReturnType; wake?: () => void} = + {}; + state.wake = (): void => { + clearTimeout(state.timer); + resolve(); + }; + state.timer = setTimeout(() => { + signal?.removeEventListener('abort', state.wake as () => void); + resolve(); + }, msec); + signal?.addEventListener('abort', state.wake, {once: true}); + }); +} + +export {downloadJson, sleep}; diff --git a/source/utils/zestEdit.ts b/source/utils/zestEdit.ts new file mode 100644 index 00000000..f23e680b --- /dev/null +++ b/source/utils/zestEdit.ts @@ -0,0 +1,389 @@ +/* + * Zed Attack Proxy (ZAP) and its related source files. + * + * ZAP is an HTTP/HTTPS proxy for assessing web application security. + * + * Copyright 2026 The ZAP Development Team + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import RawStatement from '../types/zestScript/RawStatement'; +import { + DEFAULT_WINDOW_HANDLE, + ZEST_CLIENT_ELEMENT_CLEAR, + ZEST_CLIENT_ELEMENT_CLICK, + ZEST_CLIENT_ELEMENT_MOUSE_OVER, + ZEST_CLIENT_ELEMENT_SCROLL_TO, + ZEST_CLIENT_ELEMENT_SEND_KEYS, + ZEST_CLIENT_ELEMENT_SUBMIT, + ZEST_CLIENT_LAUNCH, + ZEST_CLIENT_SWITCH_TO_FRAME, + ZEST_CLIENT_WINDOW_CLOSE, + ZEST_COMMENT, +} from './constants'; +import i18n from './i18n'; + +type FieldKind = 'text' | 'multiline' | 'number' | 'boolean' | 'locatorType'; + +interface FieldDef { + key: string; + /** The i18n key of the label. */ + labelKey: string; + kind: FieldKind; + required?: boolean; +} + +interface StatementTypeDef { + elementType: string; + /** The i18n key of the name of the statement type. */ + labelKey: string; + fields: FieldDef[]; + defaults: RawStatement; +} + +type FieldValues = Record; + +const LOCATOR_TYPES = [ + 'id', + 'cssSelector', + 'xpath', + 'className', + 'name', + 'linkText', + 'partialLinkText', + 'tagName', +]; + +const LOCATOR_FIELDS: FieldDef[] = [ + {key: 'type', labelKey: 'edit.field.locatorType', kind: 'locatorType'}, + { + key: 'element', + labelKey: 'edit.field.locator', + kind: 'text', + required: true, + }, +]; + +const WAIT_FIELD: FieldDef = { + key: 'waitForMsec', + labelKey: 'edit.field.waitFor', + kind: 'number', +}; + +const ELEMENT_DEFAULTS = { + windowHandle: DEFAULT_WINDOW_HANDLE, + type: 'id', + element: '', +}; + +const ENABLED_FIELD: FieldDef = { + key: 'enabled', + labelKey: 'edit.field.enabled', + kind: 'boolean', +}; + +const STATEMENT_TYPES: StatementTypeDef[] = [ + { + elementType: ZEST_CLIENT_LAUNCH, + labelKey: 'edit.type.launch', + fields: [ + { + key: 'browserType', + labelKey: 'edit.field.browser', + kind: 'text', + required: true, + }, + {key: 'url', labelKey: 'edit.field.url', kind: 'text', required: true}, + ], + defaults: { + windowHandle: DEFAULT_WINDOW_HANDLE, + browserType: 'Chrome', + url: '', + capabilities: '', + headless: false, + }, + }, + { + elementType: ZEST_CLIENT_ELEMENT_CLICK, + labelKey: 'edit.type.click', + fields: [...LOCATOR_FIELDS, WAIT_FIELD], + defaults: {...ELEMENT_DEFAULTS, waitForMsec: 0}, + }, + { + elementType: ZEST_CLIENT_ELEMENT_SEND_KEYS, + labelKey: 'edit.type.sendKeys', + fields: [ + ...LOCATOR_FIELDS, + {key: 'value', labelKey: 'edit.field.keys', kind: 'multiline'}, + WAIT_FIELD, + ], + defaults: {...ELEMENT_DEFAULTS, value: '', waitForMsec: 0}, + }, + { + elementType: ZEST_CLIENT_ELEMENT_CLEAR, + labelKey: 'edit.type.clear', + fields: LOCATOR_FIELDS, + defaults: {...ELEMENT_DEFAULTS}, + }, + { + elementType: ZEST_CLIENT_ELEMENT_SUBMIT, + labelKey: 'edit.type.submit', + fields: [...LOCATOR_FIELDS, WAIT_FIELD], + defaults: {...ELEMENT_DEFAULTS, waitForMsec: 0}, + }, + { + elementType: ZEST_CLIENT_ELEMENT_SCROLL_TO, + labelKey: 'edit.type.scrollTo', + fields: [...LOCATOR_FIELDS, WAIT_FIELD], + defaults: {...ELEMENT_DEFAULTS, waitForMsec: 0}, + }, + { + elementType: ZEST_CLIENT_ELEMENT_MOUSE_OVER, + labelKey: 'edit.type.mouseOver', + fields: [...LOCATOR_FIELDS, WAIT_FIELD], + defaults: {...ELEMENT_DEFAULTS, waitForMsec: 0}, + }, + { + elementType: ZEST_CLIENT_SWITCH_TO_FRAME, + labelKey: 'edit.type.switchToFrame', + fields: [ + {key: 'frameIndex', labelKey: 'edit.field.frameIndex', kind: 'number'}, + {key: 'frameName', labelKey: 'edit.field.frameName', kind: 'text'}, + ], + defaults: { + windowHandle: DEFAULT_WINDOW_HANDLE, + frameIndex: 0, + frameName: '', + parent: false, + }, + }, + { + elementType: ZEST_CLIENT_WINDOW_CLOSE, + labelKey: 'edit.type.closeWindow', + fields: [ + {key: 'sleepInSeconds', labelKey: 'edit.field.sleep', kind: 'number'}, + ], + defaults: {windowHandle: DEFAULT_WINDOW_HANDLE, sleepInSeconds: 0}, + }, + { + elementType: ZEST_COMMENT, + labelKey: 'edit.type.comment', + fields: [ + {key: 'comment', labelKey: 'edit.field.comment', kind: 'multiline'}, + ], + defaults: {comment: ''}, + }, +]; + +/** Only web pages can be launched by a replay. */ +function isReplayableUrl(url: string): boolean { + try { + const {protocol} = new URL(url); + return protocol === 'http:' || protocol === 'https:'; + } catch { + return false; + } +} + +function getStatementType(elementType: unknown): StatementTypeDef | undefined { + return STATEMENT_TYPES.find((t) => t.elementType === elementType); +} + +function newStatement(elementType: string): RawStatement { + const def = getStatementType(elementType); + if (!def) { + throw new Error(`Unsupported statement type: ${elementType}`); + } + return {...def.defaults, index: 0, enabled: true, elementType}; +} + +/** The fields shown when editing, including the enabled flag. */ +function getEditFields(def: StatementTypeDef): FieldDef[] { + return [...def.fields, ENABLED_FIELD]; +} + +/** Initial form values for a statement. */ +function toFieldValues( + def: StatementTypeDef, + statement: RawStatement +): FieldValues { + const values: FieldValues = {}; + getEditFields(def).forEach((f) => { + const v = statement[f.key]; + if (f.kind === 'boolean') { + values[f.key] = f.key === 'enabled' ? v !== false : v === true; + } else { + values[f.key] = v === undefined || v === null ? '' : String(v); + } + }); + return values; +} + +/** + * Applies edited form values to a statement. Fields that are not shown in + * the form (e.g. windowHandle) are preserved. + * @returns the new statement, or the validation errors. + */ +function applyFieldValues( + def: StatementTypeDef, + statement: RawStatement, + values: FieldValues +): {statement?: RawStatement; errors: string[]} { + const errors: string[] = []; + const result: RawStatement = {...statement}; + getEditFields(def).forEach((f) => { + const raw = values[f.key]; + if (f.kind === 'boolean') { + result[f.key] = raw === true; + return; + } + const text = typeof raw === 'string' ? raw : ''; + if (f.kind === 'number') { + if (!/^-?\d+$/.test(text.trim())) { + errors.push( + i18n.t('edit.error.wholeNumber', {field: i18n.t(f.labelKey)}) + ); + } else { + result[f.key] = Number(text.trim()); + } + } else if (f.required && text.trim() === '') { + errors.push(i18n.t('edit.error.required', {field: i18n.t(f.labelKey)})); + } else { + result[f.key] = text; + } + }); + if ( + def.elementType === ZEST_CLIENT_LAUNCH && + typeof result.url === 'string' && + result.url.trim() !== '' && + !isReplayableUrl(result.url.trim()) + ) { + errors.push(i18n.t('edit.error.badUrl')); + } + if (def.elementType === ZEST_CLIENT_SWITCH_TO_FRAME) { + result.parent = result.frameIndex === -1; + } + return errors.length > 0 ? {errors} : {statement: result, errors}; +} + +/** Returns a copy of the list with an item moved, or the same order if out of range. */ +function moveItem(items: T[], from: number, to: number): T[] { + const result = [...items]; + if (from < 0 || from >= items.length || to < 0 || to >= items.length) { + return result; + } + const [item] = result.splice(from, 1); + result.splice(to, 0, item); + return result; +} + +// The names of the script level settings that are not kept +const METADATA_KEYS = { + type: 'edit.metadata.type', + description: 'edit.metadata.description', + prefix: 'edit.metadata.prefix', + parameters: 'edit.metadata.parameters', + authentication: 'edit.metadata.authentication', +}; + +/** + * The script level settings that the extension cannot keep: it only keeps the + * title and the statements, and exports the other settings as the defaults. + * @returns the names of the settings in the script which are not the defaults. + */ +function unsupportedMetadata( + script: Record +): (keyof typeof METADATA_KEYS)[] { + const unsupported: (keyof typeof METADATA_KEYS)[] = []; + const isEmpty = (v: unknown): boolean => + v === undefined || v === null || v === ''; + if (script.type !== undefined && script.type !== 'StandAlone') { + unsupported.push('type'); + } + if (!isEmpty(script.description)) { + unsupported.push('description'); + } + if (!isEmpty(script.prefix)) { + unsupported.push('prefix'); + } + const params = script.parameters as Record | undefined; + if ( + params && + ((params.tokens && + Object.keys(params.tokens as Record).length > 0) || + (params.tokenStart !== undefined && params.tokenStart !== '{{') || + (params.tokenEnd !== undefined && params.tokenEnd !== '}}')) + ) { + unsupported.push('parameters'); + } + if ( + Array.isArray(script.authentication) && + script.authentication.length > 0 + ) { + unsupported.push('authentication'); + } + return unsupported; +} + +/** + * Parses the contents of a Zest script file. + * @throws if it is not a Zest script, or has settings that cannot be kept. + */ +function parseZestScript(text: string): { + title: string; + statements: RawStatement[]; +} { + let script; + try { + script = JSON.parse(text); + } catch { + throw new Error(i18n.t('edit.error.notJson')); + } + const {statements} = script ?? {}; + if ( + !Array.isArray(statements) || + !statements.every( + (s) => s && typeof s === 'object' && typeof s.elementType === 'string' + ) + ) { + throw new Error(i18n.t('edit.error.notZest')); + } + const unsupported = unsupportedMetadata(script); + if (unsupported.length > 0) { + throw new Error( + i18n.t('edit.error.unsupportedMetadata', { + features: unsupported + .map((m) => i18n.t(`edit.metadata.${m}`)) + .join(', '), + }) + ); + } + return { + title: typeof script.title === 'string' ? script.title : '', + statements, + }; +} + +export { + isReplayableUrl, + parseZestScript, + LOCATOR_TYPES, + STATEMENT_TYPES, + getStatementType, + newStatement, + getEditFields, + toFieldValues, + applyFieldValues, + moveItem, +}; +export type {FieldDef, FieldValues, StatementTypeDef}; diff --git a/source/utils/zestSummary.ts b/source/utils/zestSummary.ts new file mode 100644 index 00000000..4a2f105c --- /dev/null +++ b/source/utils/zestSummary.ts @@ -0,0 +1,116 @@ +/* + * Zed Attack Proxy (ZAP) and its related source files. + * + * ZAP is an HTTP/HTTPS proxy for assessing web application security. + * + * Copyright 2026 The ZAP Development Team + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import RawStatement from '../types/zestScript/RawStatement'; +import { + ZEST_CLIENT_ELEMENT_CLEAR, + ZEST_CLIENT_ELEMENT_CLICK, + ZEST_CLIENT_ELEMENT_MOUSE_OVER, + ZEST_CLIENT_ELEMENT_SCROLL_TO, + ZEST_CLIENT_ELEMENT_SEND_KEYS, + ZEST_CLIENT_ELEMENT_SUBMIT, + ZEST_CLIENT_LAUNCH, + ZEST_CLIENT_SWITCH_TO_FRAME, + ZEST_CLIENT_WINDOW_CLOSE, + ZEST_COMMENT, +} from './constants'; +import i18n from './i18n'; + +interface ZestStatementSummary { + index: number; + enabled: boolean; + summary: string; +} + +function str(value: unknown): string { + return value === undefined || value === null ? '' : String(value); +} + +function describeElement(statement: RawStatement): string { + return `${str(statement.type)}: ${str(statement.element)}`; +} + +function describeFrame(statement: RawStatement): string { + if (statement.parent === true) { + return i18n.t('summary.frameParent'); + } + const name = str(statement.frameName); + return name !== '' + ? i18n.t('summary.frameName', {name}) + : i18n.t('summary.frameIndex', {index: str(statement.frameIndex)}); +} + +function summarizeStatement(statement: RawStatement): string { + const elementType = str(statement.elementType); + const locator = describeElement(statement); + switch (elementType) { + case ZEST_CLIENT_LAUNCH: + return i18n.t('summary.launch', { + browser: str(statement.browserType), + url: str(statement.url), + }); + case ZEST_CLIENT_ELEMENT_CLICK: + return i18n.t('summary.click', {locator}); + case ZEST_CLIENT_ELEMENT_SCROLL_TO: + return i18n.t('summary.scrollTo', {locator}); + case ZEST_CLIENT_ELEMENT_SEND_KEYS: + return i18n.t('summary.sendKeys', { + keys: str(statement.value), + locator, + }); + case ZEST_CLIENT_ELEMENT_SUBMIT: + return i18n.t('summary.submit', {locator}); + case ZEST_CLIENT_ELEMENT_CLEAR: + return i18n.t('summary.clear', {locator}); + case ZEST_CLIENT_ELEMENT_MOUSE_OVER: + return i18n.t('summary.mouseOver', {locator}); + case ZEST_CLIENT_SWITCH_TO_FRAME: + return i18n.t('summary.switchToFrame', { + frame: describeFrame(statement), + }); + case ZEST_CLIENT_WINDOW_CLOSE: + return i18n.t('summary.closeWindow', { + handle: str(statement.windowHandle), + }); + case ZEST_COMMENT: + return i18n.t('summary.comment', {comment: str(statement.comment)}); + default: + return elementType !== '' ? elementType : i18n.t('summary.unknown'); + } +} + +/** + * Converts a Zest script (as a JSON string) into a list of one-line summaries, + * one per statement. + * @throws if the script is not valid JSON. + */ +function summarizeZestScript(scriptJson: string): ZestStatementSummary[] { + const script = JSON.parse(scriptJson); + const statements: RawStatement[] = Array.isArray(script.statements) + ? script.statements + : []; + return statements.map((statement, i) => ({ + index: typeof statement.index === 'number' ? statement.index : i + 1, + enabled: statement.enabled !== false, + summary: summarizeStatement(statement), + })); +} + +export {summarizeZestScript, summarizeStatement}; +export type {ZestStatementSummary}; diff --git a/test/Background/background.test.ts b/test/Background/background.test.ts new file mode 100644 index 00000000..1c895b53 --- /dev/null +++ b/test/Background/background.test.ts @@ -0,0 +1,240 @@ +/** + * @jest-environment jsdom + */ +/* + * Zed Attack Proxy (ZAP) and its related source files. + * + * ZAP is an HTTP/HTTPS proxy for assessing web application security. + * + * Copyright 2026 The ZAP Development Team + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import {TextEncoder, TextDecoder} from 'util'; +import * as src from '../../source/Background/index'; + +console.log(src); + +jest.mock('webextension-polyfill'); + +// These lines must appear before the JSDOM import +global.TextEncoder = TextEncoder; +global.TextDecoder = TextDecoder as typeof global.TextDecoder; + +// eslint-disable-next-line import/order,import/first +import Browser from 'webextension-polyfill'; + +type Listener = (message: unknown, sender: unknown) => Promise; + +const listener = (Browser.runtime.onMessage.addListener as jest.Mock).mock + .calls[0][0] as Listener; + +const comment = (text: string): Record => ({ + elementType: 'ZestComment', + comment: text, +}); + +async function statementCount(): Promise { + const msg = (await listener({type: 'getZestScript'}, {})) as { + statementCount: number; + }; + return msg.statementCount; +} + +// Recording sends statements to ZAP +global.fetch = jest.fn(); + +const set = Browser.storage.local.set as jest.Mock; + +(Browser.storage.sync.get as jest.Mock).mockResolvedValue({ + zapurl: 'http://zap/', + zapkey: 'key', +}); + +beforeEach(async () => { + set.mockClear(); + await listener({type: 'resetZestScript'}, {}); + set.mockClear(); +}); + +test('replacing the statements stores and then uses them', async () => { + await listener( + {type: 'setZestStatements', data: [comment('a'), comment('b')]}, + {} + ); + + expect(await statementCount()).toBe(2); + const stored = set.mock.calls[0][0]; + expect(stored.zestCurIndex).toBe(3); + expect(stored.zestStatements.map((s: string) => JSON.parse(s).index)).toEqual( + [1, 2] + ); +}); + +test('the script is unchanged if the new statements cannot be stored', async () => { + await listener({type: 'setZestStatements', data: [comment('a')]}, {}); + set.mockRejectedValueOnce(new Error('QUOTA_BYTES quota exceeded')); + + await expect( + listener( + {type: 'setZestStatements', data: [comment('x'), comment('y')]}, + {} + ) + ).rejects.toThrow('QUOTA_BYTES quota exceeded'); + + expect(await statementCount()).toBe(1); +}); + +test('invalid statements are rejected without storing anything', async () => { + await listener({type: 'setZestStatements', data: [comment('a')]}, {}); + set.mockClear(); + + await expect( + listener({type: 'setZestStatements', data: 'nope'}, {}) + ).rejects.toThrow('The statements must be a list of objects'); + + expect(set).not.toHaveBeenCalled(); + expect(await statementCount()).toBe(1); +}); + +describe('changes made while a save is being stored', () => { + const tick = (): Promise => + new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + // Makes the next write to storage wait until the returned function is called + function holdNextWrite(): () => void { + let release: () => void = () => {}; + set.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + }) + ); + return () => release(); + } + + const lastStored = (): {zestStatements: string[]; zestCurIndex: number} => + set.mock.calls[set.mock.calls.length - 1][0]; + + test('a recorded statement is not lost', async () => { + const release = holdNextWrite(); + const saving = listener( + {type: 'setZestStatements', data: [comment('a'), comment('b')]}, + {} + ); + const recorded = listener( + {type: 'zestScript', data: JSON.stringify(comment('recorded'))}, + {} + ); + await tick(); + + release(); + await Promise.all([saving, recorded]); + + expect(await statementCount()).toBe(3); + // and what is stored is the same as what is in memory + expect(lastStored().zestStatements).toHaveLength(3); + expect(lastStored().zestCurIndex).toBe(4); + }); + + test('a reset is not undone', async () => { + const release = holdNextWrite(); + const saving = listener( + {type: 'setZestStatements', data: [comment('a'), comment('b')]}, + {} + ); + const reset = listener({type: 'resetZestScript'}, {}); + await tick(); + + release(); + await Promise.all([saving, reset]); + + expect(await statementCount()).toBe(0); + expect(lastStored().zestStatements).toEqual([]); + }); + + test('reading waits for a save that is being stored', async () => { + const release = holdNextWrite(); + const saving = listener( + {type: 'setZestStatements', data: [comment('a'), comment('b')]}, + {} + ); + const reading = statementCount(); + await tick(); + + release(); + await saving; + + expect(await reading).toBe(2); + }); + + test('a failed save does not stop later changes', async () => { + set.mockRejectedValueOnce(new Error('QUOTA_BYTES quota exceeded')); + const failing = listener( + {type: 'setZestStatements', data: [comment('a')]}, + {} + ); + const recorded = listener( + {type: 'zestScript', data: JSON.stringify(comment('recorded'))}, + {} + ); + + await expect(failing).rejects.toThrow('QUOTA_BYTES'); + await recorded; + + expect(await statementCount()).toBe(1); + }); +}); + +describe('a failed read of the settings', () => { + const syncGet = Browser.storage.sync.get as jest.Mock; + + test('does not stop later changes', async () => { + // The first read is for the zap settings, and the second is for the script + syncGet + .mockResolvedValueOnce({zapurl: 'http://zap/', zapkey: 'key'}) + .mockRejectedValueOnce(new Error('storage unavailable')); + + await expect(listener({type: 'getZestScript'}, {})).rejects.toThrow( + 'storage unavailable' + ); + + // Everything queued after it still runs + await listener({type: 'setZestStatements', data: [comment('a')]}, {}); + await listener( + {type: 'zestScript', data: JSON.stringify(comment('recorded'))}, + {} + ); + expect(await statementCount()).toBe(2); + }); + + test('does not stop changes that were already waiting', async () => { + // Both messages read the zap settings before the script is read + syncGet + .mockResolvedValueOnce({zapurl: 'http://zap/', zapkey: 'key'}) + .mockResolvedValueOnce({zapurl: 'http://zap/', zapkey: 'key'}) + .mockRejectedValueOnce(new Error('storage unavailable')); + + const failing = listener({type: 'getZestScript'}, {}); + const waiting = listener( + {type: 'setZestStatements', data: [comment('a'), comment('b')]}, + {} + ); + + await expect(failing).rejects.toThrow('storage unavailable'); + await waiting; + expect(await statementCount()).toBe(2); + }); +}); diff --git a/test/Background/dialogs.test.ts b/test/Background/dialogs.test.ts new file mode 100644 index 00000000..28e45359 --- /dev/null +++ b/test/Background/dialogs.test.ts @@ -0,0 +1,217 @@ +/** + * @jest-environment jsdom + */ +/* + * Zed Attack Proxy (ZAP) and its related source files. + * + * ZAP is an HTTP/HTTPS proxy for assessing web application security. + * + * Copyright 2026 The ZAP Development Team + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import * as fs from 'fs'; +import * as path from 'path'; +import {getStatementType, newStatement} from '../../source/utils/zestEdit'; + +jest.mock('webextension-polyfill', () => ({ + __esModule: true, + default: {runtime: {}}, +})); + +// jsdom does not implement modal dialogs +beforeAll(() => { + HTMLDialogElement.prototype.showModal = function showModal(): void { + this.setAttribute('open', ''); + }; + HTMLDialogElement.prototype.close = function close(): void { + this.removeAttribute('open'); + }; +}); + +beforeEach(() => { + document.documentElement.innerHTML = fs.readFileSync( + path.join(__dirname, '..', '..', 'views', 'script.html'), + 'utf8' + ); + // The dialogs find their elements when the module is loaded + jest.resetModules(); +}); + +function submit(): void { + (document.getElementById('edit-form') as HTMLFormElement).dispatchEvent( + new Event('submit', {cancelable: true}) + ); +} + +async function load(): Promise< + typeof import('../../source/ScriptView/dialogs') +> { + // eslint-disable-next-line node/no-unsupported-features/es-syntax + return import('../../source/ScriptView/dialogs'); +} + +describe('locator types', () => { + const def = getStatementType('ZestClientElementClick'); + const statement = (type: unknown): Record => ({ + ...newStatement('ZestClientElementClick'), + element: 'target', + type, + }); + + test('a type that is not known is kept when something else is changed', async () => { + const dialogs = await load(); + const onSave = jest.fn(); + + dialogs.openEditor( + def as NonNullable, + statement('data-test-id'), + 'Edit', + onSave + ); + const select = document.getElementById('field-type') as HTMLSelectElement; + expect(select.value).toBe('data-test-id'); + (document.getElementById('field-enabled') as HTMLInputElement).checked = + false; + submit(); + + expect(onSave).toHaveBeenCalledTimes(1); + expect(onSave.mock.calls[0][0]).toMatchObject({ + type: 'data-test-id', + element: 'target', + enabled: false, + }); + }); + + test('the types that are known are still offered', async () => { + const dialogs = await load(); + + dialogs.openEditor( + def as NonNullable, + statement('data-test-id'), + 'Edit', + jest.fn() + ); + + const options = Array.from( + (document.getElementById('field-type') as HTMLSelectElement).options + ).map((o) => o.value); + expect(options).toEqual( + expect.arrayContaining([ + 'id', + 'cssSelector', + 'xpath', + 'className', + 'linkText', + 'partialLinkText', + ]) + ); + expect(options[0]).toBe('data-test-id'); + }); + + test('a known type does not get an extra option', async () => { + const dialogs = await load(); + + dialogs.openEditor( + def as NonNullable, + statement('xpath'), + 'Edit', + jest.fn() + ); + + const select = document.getElementById('field-type') as HTMLSelectElement; + expect(select.value).toBe('xpath'); + expect(select.options).toHaveLength(8); + }); + + test('partialLinkText can be chosen', async () => { + const dialogs = await load(); + const onSave = jest.fn(); + + dialogs.openEditor( + def as NonNullable, + statement('id'), + 'Edit', + onSave + ); + (document.getElementById('field-type') as HTMLSelectElement).value = + 'partialLinkText'; + (document.getElementById('field-element') as HTMLInputElement).value = + 'Sign in'; + submit(); + + expect(onSave.mock.calls[0][0]).toMatchObject({ + type: 'partialLinkText', + element: 'Sign in', + }); + }); + + test('a statement that has partialLinkText keeps it, without an extra option', async () => { + const dialogs = await load(); + const onSave = jest.fn(); + + dialogs.openEditor( + def as NonNullable, + statement('partialLinkText'), + 'Edit', + onSave + ); + const select = document.getElementById('field-type') as HTMLSelectElement; + expect(select.value).toBe('partialLinkText'); + expect(select.options).toHaveLength(8); + (document.getElementById('field-enabled') as HTMLInputElement).checked = + false; + submit(); + + expect(onSave.mock.calls[0][0].type).toBe('partialLinkText'); + }); + + test('another type can still be chosen', async () => { + const dialogs = await load(); + const onSave = jest.fn(); + + dialogs.openEditor( + def as NonNullable, + statement('data-test-id'), + 'Edit', + onSave + ); + (document.getElementById('field-type') as HTMLSelectElement).value = 'id'; + submit(); + + expect(onSave.mock.calls[0][0].type).toBe('id'); + }); +}); + +describe('closing the dialogs', () => { + test('closes an open edit dialog and an open confirmation', async () => { + const dialogs = await load(); + const def = getStatementType('ZestComment'); + dialogs.openEditor( + def as NonNullable, + newStatement('ZestComment'), + 'Edit', + jest.fn() + ); + dialogs.askConfirm('Title', 'Message', '', 'OK', jest.fn()); + const edit = document.getElementById('edit-dialog') as HTMLElement; + const confirm = document.getElementById('confirm-dialog') as HTMLElement; + expect(edit.hasAttribute('open')).toBe(true); + expect(confirm.hasAttribute('open')).toBe(true); + + dialogs.closeDialogs(); + + expect(edit.hasAttribute('open')).toBe(false); + expect(confirm.hasAttribute('open')).toBe(false); + }); +}); diff --git a/test/Background/i18n.test.ts b/test/Background/i18n.test.ts new file mode 100644 index 00000000..91128ce0 --- /dev/null +++ b/test/Background/i18n.test.ts @@ -0,0 +1,90 @@ +/** + * @jest-environment jsdom + */ +/* + * Zed Attack Proxy (ZAP) and its related source files. + * + * ZAP is an HTTP/HTTPS proxy for assessing web application security. + * + * Copyright 2026 The ZAP Development Team + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import * as fs from 'fs'; +import * as path from 'path'; +import i18n from '../../source/utils/i18n'; + +const root = path.join(__dirname, '..', '..'); + +function read(file: string): string { + return fs.readFileSync(path.join(root, file), 'utf8'); +} + +function matches(text: string, pattern: RegExp): string[] { + return Array.from(text.matchAll(pattern), (m) => m[1]); +} + +test('every key used by the script viewer page has a translation', () => { + const html = read('views/script.html'); + const keys = [ + ...matches(html, /data-i18n="([^"]+)"/g), + ...matches(html, /data-i18n-title="([^"]+)"/g), + ]; + expect(keys.length).toBeGreaterThan(10); + expect(keys.filter((key) => !i18n.exists(key))).toEqual([]); +}); + +// Keys are used in t('...') calls and as values such as labelKey and errorKey +const SOURCES = [ + 'source/ScriptView/index.ts', + 'source/ScriptView/dialogs.ts', + 'source/ScriptView/player.ts', + 'source/ContentScript/replayer.ts', + 'source/utils/zestEdit.ts', + 'source/utils/zestSummary.ts', + 'source/Popup/index.tsx', +]; + +test.each(SOURCES)('every key used by %s has a translation', (file) => { + const text = read(file); + const keys = [ + ...matches(text, /\bt\('([\w.]+)'/g), + ...matches(text, /'((?:script|summary|edit)\.[\w.]+)'/g), + ]; + expect( + keys.filter((key) => !key.endsWith('.html') && !i18n.exists(key)) + ).toEqual([]); +}); + +test('translations are returned, not the keys', () => { + expect(i18n.t('script.new')).toBe('New'); + expect(i18n.t('script.newMessage', {count: 3})).toContain('3 statement(s)'); +}); + +test('every translation is used', () => { + const used = [...SOURCES, 'views/script.html'].map(read).join('\n'); + const missing: string[] = []; + const walk = (node: Record, prefix: string): void => { + Object.entries(node).forEach(([key, value]) => { + const full = prefix + key; + if (typeof value === 'object' && value !== null) { + walk(value as Record, `${full}.`); + } else if (!used.includes(full)) { + missing.push(full); + } + }); + }; + walk(i18n.getResourceBundle('en', 'translation'), ''); + // Keys of the popup are used with the short name e.g. i18n.t('start') + expect(missing).toEqual([]); +}); diff --git a/test/Background/options.test.ts b/test/Background/options.test.ts new file mode 100644 index 00000000..7972e58c --- /dev/null +++ b/test/Background/options.test.ts @@ -0,0 +1,253 @@ +/** + * @jest-environment jsdom + */ +/* + * Zed Attack Proxy (ZAP) and its related source files. + * + * ZAP is an HTTP/HTTPS proxy for assessing web application security. + * + * Copyright 2026 The ZAP Development Team + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import * as fs from 'fs'; +import * as path from 'path'; + +const RECORDER = 'ZAP by Checkmarx Recorder'; +const FULL = 'ZAP by Checkmarx Browser Extension'; + +let mockStored: Record = {}; +const mockSet = jest.fn(); + +const html = fs.readFileSync( + path.join(__dirname, '..', '..', 'views', 'options.html'), + 'utf8' +); + +function flush(): Promise { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + +// Each load of the page adds a listener to the document, which would still be +// there for the next one, so they are taken away +const added: [string, EventListenerOrEventListenerObject][] = []; + +function removeListeners(): void { + added + .splice(0) + .forEach(([type, listener]) => + document.removeEventListener(type, listener) + ); +} + +// Loads the page as the extension with the given name would show it +async function load(manifestName: string | undefined): Promise { + jest.resetModules(); + jest.doMock('webextension-polyfill', () => ({ + __esModule: true, + default: { + runtime: { + getManifest: manifestName + ? (): {name: string} => ({name: manifestName}) + : undefined, + }, + storage: { + sync: { + get: (defaults: Record): Promise => + Promise.resolve({...defaults, ...mockStored}), + set: (items: Record): void => mockSet(items), + }, + }, + }, + })); + removeListeners(); + document.documentElement.innerHTML = html; + jest.spyOn(console, 'log').mockImplementation(() => {}); + const addListener = document.addEventListener.bind(document); + jest + .spyOn(document, 'addEventListener') + .mockImplementation((type, listener, options) => { + added.push([type, listener]); + addListener(type, listener, options); + }); + // eslint-disable-next-line node/no-unsupported-features/es-syntax + await import('../../source/Options/index'); + jest.restoreAllMocks(); + jest.spyOn(console, 'log').mockImplementation(() => {}); + document.dispatchEvent(new Event('DOMContentLoaded')); + await flush(); + // Saving would otherwise try to submit the form + (document.querySelector('form') as HTMLFormElement).addEventListener( + 'submit', + (e) => e.preventDefault() + ); +} + +const byId = (id: string): HTMLElement => + document.getElementById(id) as HTMLElement; +const isShown = (el: Element | null): boolean => + el !== null && el.closest('[hidden]') === null; +const rowOf = (id: string): Element | null => byId(id).closest('tr'); + +beforeEach(() => { + mockSet.mockClear(); + mockStored = {}; +}); + +afterEach(() => { + removeListeners(); + jest.restoreAllMocks(); +}); + +describe('the recorder', () => { + beforeEach(() => load(RECORDER)); + + test('does not show the text about giving ZAP access to the browser', () => { + const intro = Array.from(document.querySelectorAll('.full-only')).filter( + (el) => el.tagName === 'DIV' + ); + + expect(intro).toHaveLength(2); + intro.forEach((el) => expect(isShown(el)).toBe(false)); + expect(document.body.textContent).toContain('gives ZAP access'); + }); + + test('does not show the ZAP url', () => { + expect(isShown(byId('zapurl'))).toBe(false); + expect(isShown(rowOf('zapurl'))).toBe(false); + }); + + test('does not show the ZAP api key', () => { + expect(isShown(byId('zapkey'))).toBe(false); + expect(isShown(rowOf('zapkey'))).toBe(false); + }); + + test('does not show the checkbox to enable ZAP', () => { + expect(isShown(byId('zapenable'))).toBe(false); + expect(isShown(rowOf('zapenable'))).toBe(false); + }); + + test('shows the options that apply to it', () => { + expect(isShown(byId('window-close-input'))).toBe(true); + expect(isShown(byId('statement-delay-input'))).toBe(true); + expect(isShown(byId('save'))).toBe(true); + }); + + test('is called by its own name', () => { + expect(byId('title').textContent).toBe(RECORDER); + }); + + test('shows the options as they are stored', async () => { + mockStored = {zapstatementdelay: 250, zapclosewindowhandle: false}; + await load(RECORDER); + + expect((byId('statement-delay-input') as HTMLInputElement).value).toBe( + '250' + ); + expect((byId('window-close-input') as HTMLInputElement).checked).toBe( + false + ); + }); + + test('leaves the setting to enable ZAP as it is, even if it was set', async () => { + mockStored = {zapenable: true}; + await load(RECORDER); + + byId('save').click(); + + expect(mockSet.mock.calls[0][0]).not.toHaveProperty('zapenable'); + }); + + test('saves its options, but not the ones for ZAP', () => { + (byId('statement-delay-input') as HTMLInputElement).value = '300'; + (byId('window-close-input') as HTMLInputElement).checked = false; + + byId('save').click(); + + expect(mockSet).toHaveBeenCalledTimes(1); + const saved = mockSet.mock.calls[0][0]; + expect(saved).toMatchObject({ + zapclosewindowhandle: false, + zapstatementdelay: 300, + }); + expect(saved).not.toHaveProperty('zapurl'); + expect(saved).not.toHaveProperty('zapkey'); + expect(saved).not.toHaveProperty('zapenable'); + }); +}); + +describe('the full extension', () => { + beforeEach(() => load(FULL)); + + test('shows the text about giving ZAP access to the browser', () => { + document + .querySelectorAll('.full-only') + .forEach((el) => expect(isShown(el)).toBe(true)); + expect(document.body.textContent).toContain('gives ZAP access'); + }); + + test('shows the ZAP url and api key', () => { + expect(isShown(byId('zapurl'))).toBe(true); + expect(isShown(byId('zapkey'))).toBe(true); + }); + + test('shows all of the other options', () => { + [ + 'zapenable', + 'window-close-input', + 'statement-delay-input', + 'save', + ].forEach((id) => expect(isShown(byId(id))).toBe(true)); + }); + + test('is called by its own name', () => { + expect(byId('title').textContent).toBe(FULL); + }); + + test('shows the options as they are stored', async () => { + mockStored = {zapurl: 'http://localhost:8080/', zapkey: 'secret'}; + await load(FULL); + + expect((byId('zapurl') as HTMLInputElement).value).toBe( + 'http://localhost:8080/' + ); + expect((byId('zapkey') as HTMLInputElement).value).toBe('secret'); + }); + + test('saves all of its options', async () => { + mockStored = {zapurl: 'http://localhost:8080/', zapkey: 'secret'}; + await load(FULL); + (byId('statement-delay-input') as HTMLInputElement).value = '300'; + + byId('save').click(); + + expect(mockSet.mock.calls[0][0]).toMatchObject({ + zapurl: 'http://localhost:8080/', + zapkey: 'secret', + zapstatementdelay: 300, + zapclosewindowhandle: true, + zapenable: false, + }); + }); +}); + +describe('when the manifest cannot be read', () => { + test('the page is still shown, as the full extension', async () => { + await load(undefined); + + expect(isShown(byId('zapurl'))).toBe(true); + expect(byId('title').textContent).toBe(FULL); + }); +}); diff --git a/test/Background/player.test.ts b/test/Background/player.test.ts new file mode 100644 index 00000000..79ec01e9 --- /dev/null +++ b/test/Background/player.test.ts @@ -0,0 +1,614 @@ +/* + * Zed Attack Proxy (ZAP) and its related source files. + * + * ZAP is an HTTP/HTTPS proxy for assessing web application security. + * + * Copyright 2026 The ZAP Development Team + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Browser from 'webextension-polyfill'; +import ScriptPlayer from '../../source/ScriptView/player'; + +jest.mock('webextension-polyfill', () => ({ + __esModule: true, + default: { + // constants.ts looks at the manifest, which is not there in tests + runtime: {}, + tabs: { + create: jest.fn(), + update: jest.fn(), + get: jest.fn(), + sendMessage: jest.fn(), + onUpdated: {addListener: jest.fn(), removeListener: jest.fn()}, + }, + windows: {create: jest.fn()}, + }, +})); + +const mocks = Browser as unknown as { + tabs: { + onUpdated: {addListener: jest.Mock; removeListener: jest.Mock}; + create: jest.Mock; + update: jest.Mock; + get: jest.Mock; + sendMessage: jest.Mock; + }; + windows: {create: jest.Mock}; +}; + +const launch = { + elementType: 'ZestClientLaunch', + url: 'https://example.com/', +}; +const click = {elementType: 'ZestClientElementClick', type: 'id', element: 'a'}; +const comment = {elementType: 'ZestComment', comment: 'hi'}; +const close = {elementType: 'ZestClientWindowClose'}; + +type State = [number, string, string | undefined]; + +function run( + player: ScriptPlayer, + statements: Record[], + delayMsec = 0 +): {result: Promise; states: State[]} { + const states: State[] = []; + const result = player.play( + statements, + delayMsec, + (position, state, message) => states.push([position, state, message]) + ); + return {result, states}; +} + +function messagesOfType(type: string): {type: string; [k: string]: unknown}[] { + return mocks.tabs.sendMessage.mock.calls + .map((c) => c[1]) + .filter((m) => m.type === type); +} + +beforeEach(() => { + jest.clearAllMocks(); + mocks.tabs.get.mockResolvedValue({status: 'complete'}); + mocks.tabs.create.mockResolvedValue({id: 7}); + mocks.tabs.update.mockResolvedValue({}); + mocks.windows.create.mockResolvedValue({tabs: [{id: 9}]}); + mocks.tabs.sendMessage.mockImplementation((_id, message) => + Promise.resolve(message.type === 'replayStatement' ? {ok: true} : true) + ); +}); + +test('runs the statements in a new window', async () => { + const {result, states} = run(new ScriptPlayer('window'), [ + comment, + launch, + click, + close, + ]); + + expect(await result).toBe(true); + expect(mocks.windows.create).toHaveBeenCalledWith({ + url: 'https://example.com/', + focused: true, + }); + expect(mocks.tabs.create).not.toHaveBeenCalled(); + expect(states).toEqual([ + [0, 'skipped', undefined], + [1, 'running', undefined], + [1, 'ok', undefined], + [2, 'running', undefined], + [2, 'ok', undefined], + [3, 'skipped', undefined], + ]); + const sent = messagesOfType('replayStatement'); + expect(sent).toHaveLength(1); + expect(sent[0]).toMatchObject({ + data: click, + display: { + title: 'ZAP Replay', + heading: 'Step 3 of 4', + text: 'Click element id: a', + }, + }); + expect(mocks.tabs.sendMessage.mock.calls[0][0]).toBe(9); + expect(messagesOfType('replayFinished')[0]).toMatchObject({ + data: {ok: true}, + }); +}); + +test('runs the statements in a new tab', async () => { + const {result} = run(new ScriptPlayer('tab'), [launch, click]); + + expect(await result).toBe(true); + expect(mocks.tabs.create).toHaveBeenCalledWith({ + url: 'https://example.com/', + active: true, + }); + expect(mocks.windows.create).not.toHaveBeenCalled(); + expect(mocks.tabs.sendMessage.mock.calls[0][0]).toBe(7); +}); + +test('a second launch reuses the tab', async () => { + const {result} = run(new ScriptPlayer('tab'), [launch, launch]); + + expect(await result).toBe(true); + expect(mocks.tabs.create).toHaveBeenCalledTimes(1); + expect(mocks.tabs.update).toHaveBeenCalledWith(7, { + url: 'https://example.com/', + }); +}); + +test('skips disabled statements', async () => { + const {result, states} = run(new ScriptPlayer('tab'), [ + launch, + {...click, enabled: false}, + ]); + + expect(await result).toBe(true); + expect(states[states.length - 1]).toEqual([1, 'skipped', undefined]); + expect(messagesOfType('replayStatement')).toHaveLength(0); +}); + +test('fails if the script does not start with a launch', async () => { + const {result, states} = run(new ScriptPlayer('tab'), [click]); + + expect(await result).toBe(false); + expect(states[1]).toEqual([ + 0, + 'failed', + 'The script must start with a launch statement', + ]); + expect(mocks.tabs.create).not.toHaveBeenCalled(); + expect(mocks.tabs.sendMessage).not.toHaveBeenCalled(); +}); + +test('only launches http and https URLs', async () => { + const {result, states} = run(new ScriptPlayer('window'), [ + {...launch, url: 'file:///etc/passwd'}, + ]); + + expect(await result).toBe(false); + expect(states[1][2]).toContain('Only http and https URLs can be launched'); + expect(mocks.windows.create).not.toHaveBeenCalled(); + expect(mocks.tabs.create).not.toHaveBeenCalled(); +}); + +test('stops at the first failure and tells the page', async () => { + mocks.tabs.sendMessage.mockImplementation((_id, message) => + Promise.resolve( + message.type === 'replayStatement' + ? { + ok: false, + errorKey: 'script.error.unexpected', + errorParams: {message: 'boom'}, + } + : true + ) + ); + const {result, states} = run(new ScriptPlayer('tab'), [launch, click, click]); + + expect(await result).toBe(false); + expect(states).toContainEqual([1, 'failed', 'boom']); + expect(states.some(([position]) => position === 2)).toBe(false); + expect(messagesOfType('replayStatement')).toHaveLength(1); + expect(messagesOfType('replayFinished')[0]).toMatchObject({ + data: {ok: false, message: 'boom'}, + }); +}); + +test('shows the error from the page in the language of the viewer', async () => { + mocks.tabs.sendMessage.mockImplementation((_id, message) => + Promise.resolve( + message.type === 'replayStatement' + ? { + ok: false, + errorKey: 'script.error.elementNotFound', + errorParams: {type: 'id', element: 'a'}, + } + : true + ) + ); + const {result, states} = run(new ScriptPlayer('tab'), [launch, click]); + + expect(await result).toBe(false); + expect(states).toContainEqual([1, 'failed', 'Element not found: id: a']); + expect(messagesOfType('replayFinished')[0]).toMatchObject({ + data: { + ok: false, + title: 'ZAP Replay', + heading: 'Failed', + message: 'Element not found: id: a', + }, + }); +}); + +test('tells the page when the script completes', async () => { + const {result} = run(new ScriptPlayer('tab'), [launch]); + + expect(await result).toBe(true); + expect(messagesOfType('replayFinished')[0]).toMatchObject({ + data: { + ok: true, + title: 'ZAP Replay', + heading: 'Complete', + message: 'Script completed successfully', + }, + }); +}); + +test('fails if the window cannot be opened', async () => { + mocks.windows.create.mockResolvedValue({tabs: []}); + const {result, states} = run(new ScriptPlayer('window'), [launch]); + + expect(await result).toBe(false); + expect(states[1]).toEqual([0, 'failed', 'Failed to open the page']); +}); + +describe('step through', () => { + // Resolves when the player next reports that it is waiting + function waitForPause(player: ScriptPlayer): Promise { + return new Promise((resolve) => { + // eslint-disable-next-line no-param-reassign + player.onPauseChange = (paused, statement): void => { + if (paused) { + resolve(statement); + } + }; + }); + } + + test('waits for next before each later statement', async () => { + const player = new ScriptPlayer('tab', true); + const firstPause = waitForPause(player); + const {result} = run(player, [launch, click, click]); + + expect(await firstPause).toBe('Click element id: a'); + expect(messagesOfType('replayStatement')).toHaveLength(0); + // The page is asked to show the buttons + await new Promise((resolve) => { + setTimeout(resolve, 20); + }); + expect(messagesOfType('replayPause')[0]).toMatchObject({ + data: { + title: 'ZAP Replay', + heading: 'Step 2 of 3 - paused', + text: 'Next: Click element id: a', + nextLabel: 'Next', + cancelLabel: 'Cancel', + }, + }); + + const secondPause = waitForPause(player); + player.next(); + await secondPause; + expect(messagesOfType('replayStatement')).toHaveLength(1); + + player.next(); + expect(await result).toBe(true); + expect(messagesOfType('replayStatement')).toHaveLength(2); + }); + + test('stop cancels while paused', async () => { + const player = new ScriptPlayer('tab', true); + const pause = waitForPause(player); + const {result, states} = run(player, [launch, click]); + + await pause; + player.stop(); + + expect(await result).toBe(false); + expect(states).toContainEqual([1, 'failed', 'Stopped']); + expect(messagesOfType('replayStatement')).toHaveLength(0); + expect(messagesOfType('replayFinished')[0]).toMatchObject({ + data: {ok: false, message: 'Stopped'}, + }); + }); + + test('does not show the buttons if next was already pressed', async () => { + let finishLoad: () => void = () => {}; + const player = new ScriptPlayer('tab', true); + const pause = waitForPause(player); + const {result} = run(player, [launch, click]); + await pause; + // The page is still loading when the user presses next + mocks.tabs.get.mockReturnValue( + new Promise((resolve) => { + finishLoad = (): void => resolve({status: 'complete'}); + }) + ); + player.next(); + finishLoad(); + await result; + + expect(messagesOfType('replayPause')).toHaveLength(0); + }); +}); + +describe('stop', () => { + const wait = (msec: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, msec); + }); + + test('during the statement delay stops a later launch', async () => { + const player = new ScriptPlayer('tab'); + const {result, states} = run(player, [launch, launch], 300); + await wait(100); + + player.stop(); + + expect(await result).toBe(false); + expect(mocks.tabs.update).not.toHaveBeenCalled(); + expect(states).toContainEqual([1, 'failed', 'Stopped']); + }); + + test('tells the page to stop what it is doing', async () => { + const player = new ScriptPlayer('tab', true); + const {result} = run(player, [launch, click]); + await wait(100); + + player.stop(); + await result; + + expect( + mocks.tabs.sendMessage.mock.calls.filter( + (c) => c[0] === 7 && c[1].type === 'replayAbort' + ) + ).toHaveLength(1); + }); + + test('does not message a page that has not been opened', async () => { + const player = new ScriptPlayer('tab'); + player.stop(); + expect(mocks.tabs.sendMessage).not.toHaveBeenCalled(); + }); + + test('a statement that finishes after stop is not a success', async () => { + let finish: (value: unknown) => void = () => {}; + const player = new ScriptPlayer('tab'); + mocks.tabs.sendMessage.mockImplementation((_id, message) => + message.type === 'replayStatement' + ? new Promise((resolve) => { + finish = resolve; + }) + : Promise.resolve(true) + ); + const {result, states} = run(player, [launch, click]); + await wait(100); + + player.stop(); + finish({ok: true}); + + expect(await result).toBe(false); + expect(states).toContainEqual([1, 'failed', 'Stopped']); + expect(states).not.toContainEqual([1, 'ok', undefined]); + }); +}); + +describe('waits that can be interrupted', () => { + const wait = (msec: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, msec); + }); + + test('stop ends the wait for a page that keeps loading', async () => { + // The launch finishes loading, then the next page never does + mocks.tabs.get + .mockResolvedValueOnce({status: 'complete'}) + .mockResolvedValue({status: 'loading'}); + const player = new ScriptPlayer('tab'); + const {result, states} = run(player, [launch, click]); + await wait(100); + const started = Date.now(); + + player.stop(); + + expect(await result).toBe(false); + expect(Date.now() - started).toBeLessThan(2000); + // It is reported as stopped, not as the page taking too long + expect(states).toContainEqual([1, 'failed', 'Stopped']); + // and nothing is left listening for the page + expect(mocks.tabs.onUpdated.removeListener).toHaveBeenCalledTimes( + mocks.tabs.onUpdated.addListener.mock.calls.length + ); + }); + + test('stop ends the delay between statements at once', async () => { + const player = new ScriptPlayer('tab'); + const {result} = run(player, [launch, launch], 5000); + await wait(100); + const started = Date.now(); + + player.stop(); + + expect(await result).toBe(false); + expect(Date.now() - started).toBeLessThan(2000); + }); + + test('stop does not wait for a page before telling it', async () => { + mocks.tabs.get + .mockResolvedValueOnce({status: 'complete'}) + .mockResolvedValue({status: 'loading'}); + const player = new ScriptPlayer('tab'); + const {result} = run(player, [launch, click]); + await wait(100); + + player.stop(); + await result; + + expect(messagesOfType('replayFinished')[0]).toMatchObject({ + data: {ok: false, message: 'Stopped'}, + }); + }); + + test('showing how the script finished does not wait long for a page', async () => { + mocks.tabs.sendMessage.mockImplementation((_id, message) => + Promise.resolve( + message.type === 'replayStatement' + ? { + ok: false, + errorKey: 'script.error.unexpected', + errorParams: {message: 'boom'}, + } + : true + ) + ); + // The launch and the wait before the click are fine, then it is loading + mocks.tabs.get + .mockResolvedValueOnce({status: 'complete'}) + .mockResolvedValueOnce({status: 'complete'}) + .mockResolvedValue({status: 'loading'}); + const started = Date.now(); + + const {result} = run(new ScriptPlayer('tab'), [launch, click]); + + expect(await result).toBe(false); + // Well under the 30 seconds a page can be given to load + expect(Date.now() - started).toBeLessThan(5000); + expect(messagesOfType('replayFinished')).toHaveLength(1); + }); + + test('stop while paused removes the wait for the buttons', async () => { + mocks.tabs.get + .mockResolvedValueOnce({status: 'complete'}) + .mockResolvedValue({status: 'loading'}); + const player = new ScriptPlayer('tab', true); + const {result} = run(player, [launch, click]); + await wait(100); + + player.stop(); + await result; + + expect(mocks.tabs.onUpdated.removeListener).toHaveBeenCalledTimes( + mocks.tabs.onUpdated.addListener.mock.calls.length + ); + }); +}); + +describe('sending a statement to the page', () => { + const wait = (msec: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, msec); + }); + + const sent = (type: string): number => messagesOfType(type).length; + + test('checks that the page is ready before sending anything', async () => { + const {result} = run(new ScriptPlayer('tab'), [launch, click]); + + expect(await result).toBe(true); + const types = mocks.tabs.sendMessage.mock.calls.map((c) => c[1].type); + expect(types.indexOf('replayPing')).toBeLessThan( + types.indexOf('replayStatement') + ); + }); + + test('keeps asking until the page is ready, and sends the statement once', async () => { + let attempts = 0; + mocks.tabs.sendMessage.mockImplementation((_id, message) => { + if (message.type === 'replayPing') { + attempts += 1; + return attempts < 3 + ? Promise.reject(new Error('Receiving end does not exist')) + : Promise.resolve(true); + } + return Promise.resolve( + message.type === 'replayStatement' ? {ok: true} : true + ); + }); + + const {result} = run(new ScriptPlayer('tab'), [launch, click]); + + expect(await result).toBe(true); + expect(attempts).toBe(3); + expect(sent('replayStatement')).toBe(1); + }); + + test('gives up if the page never answers', async () => { + mocks.tabs.sendMessage.mockImplementation((_id, message) => + message.type === 'replayPing' + ? Promise.reject(new Error('Receiving end does not exist')) + : Promise.resolve(true) + ); + const player = new ScriptPlayer('tab'); + const {result, states} = run(player, [launch, click]); + await wait(300); + + player.stop(); + + expect(await result).toBe(false); + expect(sent('replayStatement')).toBe(0); + expect(states).toContainEqual([1, 'failed', 'Stopped']); + }); + + test('a statement is not sent again if the page was replaced by it', async () => { + mocks.tabs.sendMessage.mockImplementation((_id, message) => { + if (message.type === 'replayStatement') { + // As when a click loads another page before the answer is sent + return Promise.reject( + new Error('The message port closed before a response was received.') + ); + } + return Promise.resolve(true); + }); + + const {result, states} = run(new ScriptPlayer('tab'), [ + launch, + click, + click, + ]); + + expect(await result).toBe(true); + // Once for each click, never repeated + expect(sent('replayStatement')).toBe(2); + expect(states.filter(([, state]) => state === 'ok')).toHaveLength(3); + }); + + test('fails if the page has gone, rather than assuming it worked', async () => { + mocks.tabs.sendMessage.mockImplementation((_id, message) => + message.type === 'replayStatement' + ? Promise.reject(new Error('No tab with id: 7')) + : Promise.resolve(true) + ); + // The tab is there for the launch, and for the wait before the click, then it is gone + mocks.tabs.get + .mockResolvedValueOnce({status: 'complete'}) + .mockResolvedValueOnce({status: 'complete'}) + .mockRejectedValue(new Error('No tab with id: 7')); + + const {result, states} = run(new ScriptPlayer('tab'), [launch, click]); + + expect(await result).toBe(false); + expect(states).toContainEqual([ + 1, + 'failed', + 'Could not talk to the page: Error: No tab with id: 7', + ]); + expect(sent('replayStatement')).toBe(1); + }); +}); + +describe('which tab belongs to the player', () => { + test('is only the tab it opened', async () => { + const player = new ScriptPlayer('tab'); + expect(player.ownsTab(7)).toBe(false); + expect(player.ownsTab(undefined)).toBe(false); + + const {result} = run(player, [launch]); + await result; + + expect(player.ownsTab(7)).toBe(true); + expect(player.ownsTab(8)).toBe(false); + expect(player.ownsTab(undefined)).toBe(false); + }); +}); diff --git a/test/Background/popup.test.ts b/test/Background/popup.test.ts new file mode 100644 index 00000000..4e0ccf12 --- /dev/null +++ b/test/Background/popup.test.ts @@ -0,0 +1,196 @@ +/** + * @jest-environment jsdom + */ +/* + * Zed Attack Proxy (ZAP) and its related source files. + * + * ZAP is an HTTP/HTTPS proxy for assessing web application security. + * + * Copyright 2026 The ZAP Development Team + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import * as fs from 'fs'; +import * as path from 'path'; +import Browser from 'webextension-polyfill'; + +jest.mock('../../source/Popup/styles.scss', () => ({})); + +jest.mock('webextension-polyfill', () => ({ + __esModule: true, + default: { + runtime: { + sendMessage: jest.fn().mockResolvedValue(undefined), + getURL: (page: string): string => `ext://id/${page}`, + }, + tabs: { + query: jest.fn(), + create: jest.fn(), + update: jest.fn(), + sendMessage: jest.fn(), + }, + storage: {sync: {get: jest.fn(), set: jest.fn()}}, + }, +})); + +const mocks = Browser as unknown as { + runtime: {sendMessage: jest.Mock}; + tabs: { + query: jest.Mock; + create: jest.Mock; + update: jest.Mock; + sendMessage: jest.Mock; + }; + storage: {sync: {get: jest.Mock; set: jest.Mock}}; +}; + +const VIEWER_TAB = {id: 50, windowId: 1}; +const CURRENT_TAB = {id: 1, windowId: 1}; + +let viewerOpen = false; +let recordingActive = false; + +function flush(): Promise { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + +const created = (): {url: string; active: boolean}[] => + mocks.tabs.create.mock.calls.map((c) => c[0]); + +async function clickRecord(loginUrl = ''): Promise { + (document.getElementById('login-url-input') as HTMLInputElement).value = + loginUrl; + (document.getElementById('record-btn') as HTMLElement).click(); + await flush(); + await flush(); +} + +beforeAll(async () => { + document.body.innerHTML = fs.readFileSync( + path.join(__dirname, '..', '..', 'views', 'popup.html'), + 'utf8' + ); + window.matchMedia = jest.fn().mockReturnValue({ + matches: false, + addEventListener: jest.fn(), + }); + window.close = jest.fn(); + // eslint-disable-next-line node/no-unsupported-features/es-syntax + await import('../../source/Popup/index'); +}); + +beforeEach(() => { + jest.clearAllMocks(); + viewerOpen = false; + recordingActive = false; + mocks.storage.sync.get.mockImplementation(() => + Promise.resolve({zaprecordingactive: recordingActive}) + ); + mocks.tabs.query.mockImplementation((query: {url?: string}) => { + if (!query.url) { + return Promise.resolve([CURRENT_TAB]); + } + return Promise.resolve(viewerOpen ? [VIEWER_TAB] : []); + }); + mocks.tabs.create.mockImplementation((options: {url: string}) => + Promise.resolve({id: options.url === 'script.html' ? 2 : 3}) + ); + mocks.tabs.update.mockResolvedValue({}); + mocks.tabs.sendMessage.mockResolvedValue(undefined); +}); + +describe('starting a recording', () => { + test('opens the script viewer behind the current tab', async () => { + await clickRecord(); + + expect(created()).toEqual([{url: 'script.html', active: false}]); + }); + + test('does not switch to any tab', async () => { + await clickRecord(); + + expect(mocks.tabs.update).not.toHaveBeenCalled(); + }); + + test('records in the tab that was current', async () => { + await clickRecord(); + + expect(mocks.tabs.sendMessage).toHaveBeenCalledWith(1, { + type: 'zapStartRecording', + data: '', + }); + expect(mocks.runtime.sendMessage).toHaveBeenCalledWith({ + type: 'resetZestScript', + }); + expect(mocks.storage.sync.set).toHaveBeenCalledWith({ + zaprecordingactive: true, + }); + }); + + test('does not open another if the script viewer is already open', async () => { + viewerOpen = true; + + await clickRecord(); + + expect(mocks.tabs.create).not.toHaveBeenCalled(); + expect(mocks.tabs.update).not.toHaveBeenCalled(); + }); + + test('still records if the script viewer cannot be opened', async () => { + mocks.tabs.create.mockRejectedValue(new Error('No window')); + jest.spyOn(console, 'log').mockImplementation(() => {}); + + await clickRecord(); + + expect(mocks.storage.sync.set).toHaveBeenCalledWith({ + zaprecordingactive: true, + }); + jest.restoreAllMocks(); + }); + + test('with a login url, only the login page is switched to', async () => { + await clickRecord('https://example.com/login'); + + expect(created()).toEqual([ + {url: 'https://example.com/login', active: false}, + {url: 'script.html', active: false}, + ]); + // The login tab, which is created with the id 3 + expect(mocks.tabs.update).toHaveBeenCalledTimes(1); + expect(mocks.tabs.update).toHaveBeenCalledWith(3, {active: true}); + }); + + test('with a login url, it records in the login tab', async () => { + await clickRecord('https://example.com/login'); + + expect(mocks.tabs.sendMessage).toHaveBeenCalledWith(3, { + type: 'zapStartRecording', + data: '', + }); + }); +}); + +describe('stopping a recording', () => { + test('does not open the script viewer', async () => { + recordingActive = true; + + await clickRecord(); + + expect(mocks.tabs.create).not.toHaveBeenCalled(); + expect(mocks.storage.sync.set).toHaveBeenCalledWith({ + zaprecordingactive: false, + }); + }); +}); diff --git a/test/Background/popupHtml.test.ts b/test/Background/popupHtml.test.ts new file mode 100644 index 00000000..47f60dcb --- /dev/null +++ b/test/Background/popupHtml.test.ts @@ -0,0 +1,74 @@ +/** + * @jest-environment jsdom + */ +/* + * Zed Attack Proxy (ZAP) and its related source files. + * + * ZAP is an HTTP/HTTPS proxy for assessing web application security. + * + * Copyright 2026 The ZAP Development Team + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import * as fs from 'fs'; +import * as path from 'path'; + +beforeAll(() => { + document.body.innerHTML = fs.readFileSync( + path.join(__dirname, '..', '..', 'views', 'popup.html'), + 'utf8' + ); +}); + +test('the view script control can be used from the keyboard', () => { + const control = document.getElementById('view-btn') as HTMLElement; + + // A button can be focused and is activated by Enter and Space + expect(control.tagName).toBe('BUTTON'); + expect(control.getAttribute('type')).toBe('button'); + expect(control.querySelector('img.view')).not.toBeNull(); + // The name comes from the button, which is given one when the popup is shown + expect(control.querySelector('img.view')?.getAttribute('alt')).toBe(''); +}); + +describe('where the buttons are', () => { + const scss = fs.readFileSync( + path.join(__dirname, '..', '..', 'source', 'Popup', 'styles.scss'), + 'utf8' + ); + + // The row and column that a button is in, from the styles of its class + function place(selector: string): {row: number; column: number} { + const start = scss.indexOf(`\n${selector} {`); + const block = scss.slice(start, scss.indexOf('&__settings', start)); + return { + row: Number(/grid-row:\s*(\d+)/.exec(block)?.[1]), + column: Number(/grid-column:\s*(\d+)/.exec(block)?.[1]), + }; + } + + test('the config button is at the top left, on the same level as help', () => { + expect(place('.setting-icon')).toEqual({row: 1, column: 1}); + expect(place('.help-icon').row).toBe(place('.setting-icon').row); + }); + + test('the view script button is below the config button', () => { + expect(place('.view-icon')).toEqual({row: 2, column: 1}); + }); + + test('the buttons are all in the page', () => { + ['configure-btn', 'view-btn', 'help-btn', 'record-btn'].forEach((id) => + expect(document.getElementById(id)).not.toBeNull() + ); + }); +}); diff --git a/test/Background/popupRecorder.test.ts b/test/Background/popupRecorder.test.ts new file mode 100644 index 00000000..ec45368c --- /dev/null +++ b/test/Background/popupRecorder.test.ts @@ -0,0 +1,98 @@ +/** + * @jest-environment jsdom + */ +/* + * Zed Attack Proxy (ZAP) and its related source files. + * + * ZAP is an HTTP/HTTPS proxy for assessing web application security. + * + * Copyright 2026 The ZAP Development Team + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import * as fs from 'fs'; +import * as path from 'path'; +import Browser from 'webextension-polyfill'; + +jest.mock('../../source/Popup/styles.scss', () => ({})); + +jest.mock('webextension-polyfill', () => ({ + __esModule: true, + default: { + runtime: { + sendMessage: jest.fn().mockResolvedValue(undefined), + getURL: (page: string): string => `ext://id/${page}`, + // The name of the recorder, so this is not the full extension + getManifest: (): {name: string} => ({name: 'ZAP by Checkmarx Recorder'}), + }, + tabs: { + query: jest.fn().mockResolvedValue([]), + create: jest.fn().mockResolvedValue({id: 2}), + update: jest.fn(), + sendMessage: jest.fn(), + }, + storage: { + sync: { + get: jest.fn().mockResolvedValue({zaprecordingactive: false}), + set: jest.fn(), + }, + }, + }, +})); + +const mocks = Browser as unknown as { + tabs: {create: jest.Mock}; +}; + +beforeAll(async () => { + document.body.innerHTML = fs.readFileSync( + path.join(__dirname, '..', '..', 'views', 'popup.html'), + 'utf8' + ); + window.matchMedia = jest.fn().mockReturnValue({ + matches: false, + addEventListener: jest.fn(), + }); + window.close = jest.fn(); + // eslint-disable-next-line node/no-unsupported-features/es-syntax + await import('../../source/Popup/index'); +}); + +beforeEach(() => { + mocks.tabs.create.mockClear(); +}); + +const configureButton = (): HTMLElement => + document.getElementById('configure-btn') as HTMLElement; + +test('the recorder has the config button, and shows it', () => { + expect(configureButton()).not.toBeNull(); + expect(configureButton().style.visibility).not.toBe('hidden'); + expect(configureButton().hidden).toBe(false); +}); + +test('the config button opens the options page in the recorder', () => { + configureButton().click(); + + expect(mocks.tabs.create).toHaveBeenCalledWith({ + url: 'options.html', + active: true, + }); +}); + +test('the view script button is there as well', () => { + const view = document.getElementById('view-btn') as HTMLElement; + + expect(view).not.toBeNull(); + expect(view.style.visibility).not.toBe('hidden'); +}); diff --git a/test/Background/replayOverlay.test.ts b/test/Background/replayOverlay.test.ts new file mode 100644 index 00000000..2242c48e --- /dev/null +++ b/test/Background/replayOverlay.test.ts @@ -0,0 +1,223 @@ +/** + * @jest-environment jsdom + */ +/* + * Zed Attack Proxy (ZAP) and its related source files. + * + * ZAP is an HTTP/HTTPS proxy for assessing web application security. + * + * Copyright 2026 The ZAP Development Team + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Browser from 'webextension-polyfill'; +import ReplayOverlay from '../../source/ContentScript/replayOverlay'; + +jest.mock('webextension-polyfill', () => ({ + __esModule: true, + default: { + storage: { + local: { + get: jest.fn().mockResolvedValue({}), + set: jest.fn().mockResolvedValue(undefined), + }, + }, + }, +})); + +// The shadow root is closed, so capture it to be able to look inside +let root: ShadowRoot; +const {attachShadow} = Element.prototype; + +beforeEach(() => { + document.documentElement + .querySelectorAll('zap-replay-overlay') + .forEach((el) => el.remove()); + jest.spyOn(Element.prototype, 'attachShadow').mockImplementation(function spy( + this: Element + ) { + root = attachShadow.call(this, {mode: 'open'}); + return root; + }); + (Browser.storage.local.get as jest.Mock).mockResolvedValue({}); +}); + +afterEach(() => { + jest.restoreAllMocks(); + jest.useRealTimers(); +}); + +function text(selector: string): string { + return root.querySelector(selector)?.textContent ?? ''; +} + +function host(): HTMLElement { + return document.querySelector('zap-replay-overlay') as HTMLElement; +} + +test('shows the status on the html element, not the body', () => { + new ReplayOverlay().show({ + title: 'ZAP Replay', + heading: 'Step 1 of 2', + text: 'Click', + state: 'running', + }); + + expect(document.documentElement.contains(host())).toBe(true); + expect(document.body.contains(host())).toBe(false); + expect(text('.title')).toBe('ZAP Replay'); + expect(text('.heading')).toBe('Step 1 of 2'); + expect(text('.text')).toBe('Click'); + expect(host().style.display).toBe('block'); +}); + +test('reuses the box and updates it', () => { + const overlay = new ReplayOverlay(); + overlay.show({ + title: 'ZAP Replay', + heading: 'a', + text: 'b', + state: 'running', + }); + overlay.show({title: 'ZAP Replay', heading: 'c', text: 'd', state: 'failed'}); + + expect(document.querySelectorAll('zap-replay-overlay')).toHaveLength(1); + expect(text('.heading')).toBe('c'); + expect( + (root.querySelector('.box') as HTMLElement).style.borderColor + ).not.toBe(''); +}); + +test('is recreated if the page removes it', () => { + const overlay = new ReplayOverlay(); + overlay.show({ + title: 'ZAP Replay', + heading: 'a', + text: 'b', + state: 'running', + }); + host().remove(); + overlay.show({ + title: 'ZAP Replay', + heading: 'a', + text: 'b', + state: 'running', + }); + + expect(document.querySelectorAll('zap-replay-overlay')).toHaveLength(1); +}); + +test('hides itself after the given time', () => { + jest.useFakeTimers(); + new ReplayOverlay().show( + {title: 'ZAP Replay', heading: 'a', text: 'b', state: 'ok'}, + 1000 + ); + expect(host().style.display).toBe('block'); + + jest.advanceTimersByTime(1000); + + expect(host().style.display).toBe('none'); +}); + +test('a later status cancels a pending hide', () => { + jest.useFakeTimers(); + const overlay = new ReplayOverlay(); + overlay.show( + {title: 'ZAP Replay', heading: 'a', text: 'b', state: 'ok'}, + 1000 + ); + overlay.show({ + title: 'ZAP Replay', + heading: 'c', + text: 'd', + state: 'running', + }); + + jest.advanceTimersByTime(2000); + + expect(host().style.display).toBe('block'); +}); + +test('action buttons call back once and are then disabled', () => { + const next = jest.fn(); + const cancel = jest.fn(); + new ReplayOverlay().show( + {title: 'ZAP Replay', heading: 'a', text: 'b', state: 'running'}, + 0, + [ + {label: 'Next', onClick: next}, + {label: 'Cancel', onClick: cancel}, + ] + ); + const buttons = Array.from(root.querySelectorAll('button')); + expect(buttons.map((b) => b.textContent)).toEqual(['Next', 'Cancel']); + + buttons[0].click(); + + expect(next).toHaveBeenCalledTimes(1); + expect(buttons.every((b) => b.disabled)).toBe(true); + buttons[1].click(); + expect(cancel).not.toHaveBeenCalled(); +}); + +test('actions are cleared by the next status', () => { + const overlay = new ReplayOverlay(); + overlay.show( + {title: 'ZAP Replay', heading: 'a', text: 'b', state: 'running'}, + 0, + [{label: 'Next', onClick: jest.fn()}] + ); + overlay.show({ + title: 'ZAP Replay', + heading: 'c', + text: 'd', + state: 'running', + }); + + expect(root.querySelectorAll('button')).toHaveLength(0); +}); + +test('restores the saved position', async () => { + (Browser.storage.local.get as jest.Mock).mockResolvedValue({ + zapreplayoverlaypos: {left: 0.5, top: 0.25}, + }); + new ReplayOverlay().show({ + title: 'ZAP Replay', + heading: 'a', + text: 'b', + state: 'running', + }); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + expect(host().style.left).toBe(`${0.5 * window.innerWidth}px`); + expect(host().style.top).toBe(`${0.25 * window.innerHeight}px`); +}); + +test('does not throw if it cannot be created', () => { + // The overlay logs the problem it is given, which is expected here + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(document, 'createElement').mockImplementation(() => { + throw new Error('blocked'); + }); + expect(() => + new ReplayOverlay().show({ + title: 'ZAP Replay', + heading: 'a', + text: 'b', + state: 'running', + }) + ).not.toThrow(); +}); diff --git a/test/Background/replayer.test.ts b/test/Background/replayer.test.ts new file mode 100644 index 00000000..52e88ce4 --- /dev/null +++ b/test/Background/replayer.test.ts @@ -0,0 +1,864 @@ +/** + * @jest-environment jsdom + */ +/* + * Zed Attack Proxy (ZAP) and its related source files. + * + * ZAP is an HTTP/HTTPS proxy for assessing web application security. + * + * Copyright 2026 The ZAP Development Team + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Replayer from '../../source/ContentScript/replayer'; + +jest.mock('webextension-polyfill', () => ({ + __esModule: true, + default: { + runtime: {sendMessage: jest.fn()}, + storage: { + local: { + get: jest.fn().mockResolvedValue({}), + set: jest.fn().mockResolvedValue(undefined), + }, + }, + }, +})); + +function stmt( + elementType: string, + extra: Record = {} +): Record { + return {elementType, type: 'id', element: 'target', ...extra}; +} + +// jsdom does not implement scrolling +function stubScrolling(win: Window): void { + // eslint-disable-next-line no-param-reassign + (win as Window & typeof globalThis).Element.prototype.scrollIntoView = + jest.fn(); +} + +let replayer: Replayer; + +beforeAll(() => stubScrolling(window)); + +beforeEach(() => { + document.body.innerHTML = ''; + // Short, so that the tests for missing elements are quick + replayer = new Replayer(50); +}); + +test('click sends the events of a real click, in order', async () => { + document.body.innerHTML = ''; + const seen: string[] = []; + const button = document.getElementById('target') as HTMLElement; + ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click'].forEach((n) => + button.addEventListener(n, () => seen.push(n)) + ); + + const result = await replayer.execute(stmt('ZestClientElementClick')); + + expect(result).toEqual({ok: true}); + expect(seen).toEqual([ + 'pointerdown', + 'mousedown', + 'pointerup', + 'mouseup', + 'click', + ]); + expect(document.activeElement).toBe(button); +}); + +test('send keys appends and fires input and change events', async () => { + document.body.innerHTML = ''; + const input = document.getElementById('target') as HTMLInputElement; + const seen: string[] = []; + ['input', 'change'].forEach((n) => + input.addEventListener(n, () => seen.push(n)) + ); + + await replayer.execute(stmt('ZestClientElementSendKeys', {value: 'cd'})); + + expect(input.value).toBe('abcd'); + expect(seen).toEqual(['input', 'change']); +}); + +test('clear empties the field', async () => { + document.body.innerHTML = ''; + + await replayer.execute(stmt('ZestClientElementClear')); + + expect((document.getElementById('target') as HTMLInputElement).value).toBe( + '' + ); +}); + +test('submit submits the form containing the element', async () => { + document.body.innerHTML = '
'; + const submitted = jest.fn((e: Event) => e.preventDefault()); + (document.getElementById('f') as HTMLElement).addEventListener( + 'submit', + submitted + ); + + const result = await replayer.execute(stmt('ZestClientElementSubmit')); + + expect(result.ok).toBe(true); + expect(submitted).toHaveBeenCalledTimes(1); +}); + +test.each([ + ['id', 'target'], + ['className', 'thing'], + ['name', 'nm'], + ['tagName', 'article'], + ['cssSelector', 'div > .thing'], + ['xpath', '//article[@name="nm"]'], + ['linkText', 'Click me'], + ['partialLinkText', 'lick m'], +])('finds elements by %s', async (type, element) => { + document.body.innerHTML = + '
' + + 'Click me
'; + const result = await replayer.execute( + stmt('ZestClientElementScrollTo', {type, element}) + ); + expect(result).toEqual({ok: true}); +}); + +test('reports an element that is not found', async () => { + const result = await replayer.execute(stmt('ZestClientElementClick')); + expect(result).toEqual({ + ok: false, + errorKey: 'script.error.elementNotFound', + errorParams: {type: 'id', element: 'target'}, + }); +}); + +test('waits for an element that appears later', async () => { + setTimeout(() => { + document.body.innerHTML = ''; + }, 20); + const result = await replayer.execute(stmt('ZestClientElementClick')); + expect(result.ok).toBe(true); +}); + +test('reports unsupported locators and statements', async () => { + document.body.innerHTML = '
'; + expect( + await replayer.execute(stmt('ZestClientElementClick', {type: 'bad'})) + ).toEqual({ + ok: false, + errorKey: 'script.error.badLocator', + errorParams: {type: 'bad'}, + }); + expect(await replayer.execute(stmt('ZestSomethingElse'))).toEqual({ + ok: false, + errorKey: 'script.error.badStatement', + errorParams: {type: 'ZestSomethingElse'}, + }); +}); + +test('reports other problems as unexpected errors', async () => { + document.body.innerHTML = '
'; + const result = await replayer.execute( + stmt('ZestClientElementClick', {type: 'cssSelector', element: '###'}) + ); + expect(result.ok).toBe(false); + expect(result.errorKey).toBe('script.error.unexpected'); + expect(typeof result.errorParams?.message).toBe('string'); +}); + +test('submit needs a form', async () => { + document.body.innerHTML = ''; + expect(await replayer.execute(stmt('ZestClientElementSubmit'))).toEqual({ + ok: false, + errorKey: 'script.error.notInForm', + errorParams: {}, + }); +}); + +test('comments do nothing', async () => { + expect( + await replayer.execute({elementType: 'ZestComment', comment: 'hi'}) + ).toEqual({ok: true}); +}); + +test('switches into a frame and back to the parent', async () => { + document.body.innerHTML = ''; + const frameWindow = (document.getElementById('frame') as HTMLIFrameElement) + .contentWindow as Window; + stubScrolling(frameWindow); + frameWindow.document.body.innerHTML = ''; + const clicked = jest.fn(); + ( + frameWindow.document.getElementById('inner') as HTMLElement + ).addEventListener('click', clicked); + document.body.insertAdjacentHTML('beforeend', ''); + + expect( + ( + await replayer.execute({ + elementType: 'ZestClientSwitchToFrame', + frameIndex: 0, + parent: false, + }) + ).ok + ).toBe(true); + // Now looks in the frame, not the top document + expect( + (await replayer.execute(stmt('ZestClientElementClick', {element: 'outer'}))) + .ok + ).toBe(false); + expect( + (await replayer.execute(stmt('ZestClientElementClick', {element: 'inner'}))) + .ok + ).toBe(true); + expect(clicked).toHaveBeenCalledTimes(1); + + await replayer.execute({ + elementType: 'ZestClientSwitchToFrame', + frameIndex: -1, + parent: true, + }); + expect( + (await replayer.execute(stmt('ZestClientElementClick', {element: 'outer'}))) + .ok + ).toBe(true); +}); + +test('reports bad frame switches', async () => { + expect( + await replayer.execute({ + elementType: 'ZestClientSwitchToFrame', + frameIndex: 3, + parent: false, + }) + ).toEqual({ + ok: false, + errorKey: 'script.error.frameNotFound', + errorParams: {frame: 3}, + }); + expect( + await replayer.execute({ + elementType: 'ZestClientSwitchToFrame', + frameIndex: -1, + parent: true, + }) + ).toEqual({ + ok: false, + errorKey: 'script.error.topWindow', + errorParams: {}, + }); +}); + +test('overlay problems do not fail a statement', async () => { + // The overlay logs the problem it is given, which is expected here + jest.spyOn(console, 'log').mockImplementation(() => {}); + document.body.innerHTML = ''; + const original = document.createElement.bind(document); + jest.spyOn(document, 'createElement').mockImplementation((tag: string) => { + if (tag === 'zap-replay-overlay') { + throw new Error('blocked'); + } + return original(tag); + }); + const result = await replayer.execute(stmt('ZestClientElementClick'), { + title: 'ZAP Replay', + heading: 'Step 1 of 1', + text: 'Click', + }); + jest.restoreAllMocks(); + expect(result.ok).toBe(true); +}); + +describe('select, checkbox and radio', () => { + const options = + ''; + + test('a select is set by the text of the option, as recorded', async () => { + document.body.innerHTML = options; + const select = document.getElementById('target') as HTMLSelectElement; + const seen: string[] = []; + ['input', 'change'].forEach((n) => + select.addEventListener(n, () => seen.push(n)) + ); + + const result = await replayer.execute( + stmt('ZestClientElementSendKeys', {value: 'Three'}) + ); + + expect(result.ok).toBe(true); + expect(select.value).toBe('3'); + expect(seen).toEqual(['input', 'change']); + }); + + test('a select falls back to the value of the option', async () => { + document.body.innerHTML = options; + + await replayer.execute(stmt('ZestClientElementSendKeys', {value: '2'})); + + expect((document.getElementById('target') as HTMLSelectElement).value).toBe( + '2' + ); + }); + + test('options that share a value are told apart by their text', async () => { + document.body.innerHTML = + ''; + + await replayer.execute( + stmt('ZestClientElementSendKeys', {value: 'Second'}) + ); + + expect( + (document.getElementById('target') as HTMLSelectElement).selectedIndex + ).toBe(1); + }); + + test('selecting twice does not append to the previous choice', async () => { + document.body.innerHTML = options; + + await replayer.execute(stmt('ZestClientElementSendKeys', {value: 'Two'})); + const result = await replayer.execute( + stmt('ZestClientElementSendKeys', {value: 'One'}) + ); + + expect(result.ok).toBe(true); + expect((document.getElementById('target') as HTMLSelectElement).value).toBe( + '1' + ); + }); + + test('reports an option that does not exist', async () => { + document.body.innerHTML = options; + + const result = await replayer.execute( + stmt('ZestClientElementSendKeys', {value: 'Four'}) + ); + + expect(result).toEqual({ + ok: false, + errorKey: 'script.error.optionNotFound', + errorParams: {option: 'Four'}, + }); + }); + + test.each(['checkbox', 'radio'])( + 'send keys leaves a %s alone', + async (type) => { + document.body.innerHTML = ``; + const input = document.getElementById('target') as HTMLInputElement; + const seen: string[] = []; + ['input', 'change'].forEach((n) => + input.addEventListener(n, () => seen.push(n)) + ); + + const result = await replayer.execute( + stmt('ZestClientElementSendKeys', {value: 'on'}) + ); + + expect(result.ok).toBe(true); + expect(input.value).toBe('on'); + expect(input.checked).toBe(false); + expect(seen).toEqual([]); + } + ); + + test('clicking a checkbox still toggles it', async () => { + document.body.innerHTML = ''; + + await replayer.execute(stmt('ZestClientElementClick')); + + expect( + (document.getElementById('target') as HTMLInputElement).checked + ).toBe(true); + }); +}); + +test('clicking an svg element works, as it has no click method', async () => { + document.body.innerHTML = + ''; + const clicked = jest.fn(); + (document.getElementById('target') as unknown as Element).addEventListener( + 'click', + clicked + ); + + const result = await replayer.execute(stmt('ZestClientElementClick')); + + expect(result).toEqual({ok: true}); + expect(clicked).toHaveBeenCalledTimes(1); +}); + +describe('named frames', () => { + function frameDoc(id: string): Document { + return (document.getElementById(id) as HTMLIFrameElement).contentWindow + ?.document as Document; + } + + beforeEach(() => { + document.body.innerHTML = + '' + + ''; + stubScrolling( + (document.getElementById('first') as HTMLIFrameElement) + .contentWindow as Window + ); + stubScrolling( + (document.getElementById('second') as HTMLIFrameElement) + .contentWindow as Window + ); + frameDoc('first').body.innerHTML = ''; + frameDoc('second').body.innerHTML = ''; + }); + + const toFrame = ( + extra: Record + ): Record => ({ + elementType: 'ZestClientSwitchToFrame', + parent: false, + ...extra, + }); + + test('a name is used in preference to the index', async () => { + // The index says the first frame, the name says the second + expect( + (await replayer.execute(toFrame({frameIndex: 0, frameName: 'two'}))).ok + ).toBe(true); + + expect( + ( + await replayer.execute( + stmt('ZestClientElementClick', {element: 'in-second'}) + ) + ).ok + ).toBe(true); + }); + + test('a frame can also be found by its id', async () => { + await replayer.execute(toFrame({frameIndex: 0, frameName: 'second'})); + + expect( + ( + await replayer.execute( + stmt('ZestClientElementClick', {element: 'in-second'}) + ) + ).ok + ).toBe(true); + }); + + test('the index is used if there is no name', async () => { + await replayer.execute(toFrame({frameIndex: 1, frameName: ''})); + + expect( + ( + await replayer.execute( + stmt('ZestClientElementClick', {element: 'in-second'}) + ) + ).ok + ).toBe(true); + }); + + test('a name that does not exist is an error, not the index', async () => { + expect( + await replayer.execute(toFrame({frameIndex: 0, frameName: 'nope'})) + ).toEqual({ + ok: false, + errorKey: 'script.error.frameNotFound', + errorParams: {frame: 'nope'}, + }); + }); +}); + +describe('abort', () => { + test('stops a statement that is waiting for its element', async () => { + const waiting = new Replayer(5000); + const result = waiting.execute(stmt('ZestClientElementClick')); + setTimeout(() => waiting.abort(), 50); + const started = Date.now(); + + expect(await result).toEqual({ + ok: false, + errorKey: 'script.error.stopped', + errorParams: {}, + }); + expect(Date.now() - started).toBeLessThan(1000); + }); + + test('an element that appears after the abort is not clicked', async () => { + const waiting = new Replayer(5000); + const clicked = jest.fn(); + const result = waiting.execute(stmt('ZestClientElementClick')); + waiting.abort(); + document.body.innerHTML = ''; + (document.getElementById('target') as HTMLElement).addEventListener( + 'click', + clicked + ); + + expect((await result).errorKey).toBe('script.error.stopped'); + expect(clicked).not.toHaveBeenCalled(); + }); + + test('the next statement runs normally', async () => { + replayer.abort(); + document.body.innerHTML = ''; + + expect(await replayer.execute(stmt('ZestClientElementClick'))).toEqual({ + ok: true, + }); + }); +}); + +describe('mouse over', () => { + const names = [ + 'pointerover', + 'pointerenter', + 'mouseover', + 'mouseenter', + 'mousemove', + ]; + + test('sends mouse events, as a mouse would, in order', async () => { + document.body.innerHTML = '
'; + const target = document.getElementById('target') as HTMLElement; + const seen: Event[] = []; + names.forEach((n) => target.addEventListener(n, (e) => seen.push(e))); + const parentSaw: string[] = []; + names.forEach((n) => + (document.getElementById('parent') as HTMLElement).addEventListener( + n, + (e) => parentSaw.push(e.type) + ) + ); + + const result = await replayer.execute(stmt('ZestClientElementMouseOver')); + + expect(result.ok).toBe(true); + expect(seen.map((e) => e.type)).toEqual(names); + // They are mouse events, not plain ones, so handlers can use them as such + seen.forEach((e) => { + expect(e).toBeInstanceOf(window.MouseEvent); + expect(e.bubbles).toBe(!e.type.endsWith('enter')); + }); + // and the enter events do not bubble, as in a browser + expect(parentSaw).toEqual(['pointerover', 'mouseover', 'mousemove']); + }); + + test('uses the mouse events of the frame for an element in a frame', async () => { + document.body.innerHTML = ''; + const frameWindow = (document.getElementById('frame') as HTMLIFrameElement) + .contentWindow as Window & typeof globalThis; + frameWindow.document.body.innerHTML = '
'; + const seen: Event[] = []; + ( + frameWindow.document.getElementById('inner') as HTMLElement + ).addEventListener('mouseover', (e) => seen.push(e)); + await replayer.execute({ + elementType: 'ZestClientSwitchToFrame', + frameIndex: 0, + parent: false, + }); + + await replayer.execute( + stmt('ZestClientElementMouseOver', {element: 'inner'}) + ); + + expect(seen).toHaveLength(1); + expect(seen[0]).toBeInstanceOf(frameWindow.MouseEvent); + }); +}); + +describe('disabled elements', () => { + const enableSoon = (msec: number): void => { + setTimeout(() => { + (document.getElementById('target') as HTMLButtonElement).disabled = false; + }, msec); + }; + + test('a click waits until the element is enabled', async () => { + document.body.innerHTML = ''; + const clicked = jest.fn(); + (document.getElementById('target') as HTMLElement).addEventListener( + 'click', + clicked + ); + enableSoon(20); + + const result = await replayer.execute(stmt('ZestClientElementClick')); + + expect(result).toEqual({ok: true}); + expect(clicked).toHaveBeenCalledTimes(1); + }); + + test('a click on an element that stays disabled fails', async () => { + document.body.innerHTML = ''; + const clicked = jest.fn(); + (document.getElementById('target') as HTMLElement).addEventListener( + 'click', + clicked + ); + + const result = await replayer.execute(stmt('ZestClientElementClick')); + + expect(result).toEqual({ + ok: false, + errorKey: 'script.error.elementDisabled', + errorParams: {type: 'id', element: 'target'}, + }); + expect(clicked).not.toHaveBeenCalled(); + }); + + test('an element in a disabled fieldset is disabled too', async () => { + document.body.innerHTML = + '
'; + + const result = await replayer.execute(stmt('ZestClientElementClick')); + + expect(result.errorKey).toBe('script.error.elementDisabled'); + }); + + test.each(['ZestClientElementSendKeys', 'ZestClientElementClear'])( + '%s waits for a disabled field to be enabled', + async (elementType) => { + document.body.innerHTML = ''; + const input = document.getElementById('target') as HTMLInputElement; + setTimeout(() => { + input.disabled = false; + }, 20); + + const result = await replayer.execute(stmt(elementType, {value: 'new'})); + + expect(result.ok).toBe(true); + expect(input.value).toBe(elementType.endsWith('Clear') ? '' : 'oldnew'); + } + ); + + test('scrolling to a disabled element is fine', async () => { + document.body.innerHTML = ''; + + const result = await replayer.execute(stmt('ZestClientElementScrollTo')); + + expect(result).toEqual({ok: true}); + }); + + test('a missing element is still reported as not found', async () => { + const result = await replayer.execute(stmt('ZestClientElementClick')); + + expect(result.errorKey).toBe('script.error.elementNotFound'); + }); +}); + +describe('scrolling', () => { + const scroll = (): jest.Mock => + window.Element.prototype.scrollIntoView as jest.Mock; + + beforeEach(() => { + document.body.innerHTML = + '
'; + scroll().mockClear(); + }); + + test('a click does not scroll to the element, as a scroll to statement does that', async () => { + const result = await replayer.execute(stmt('ZestClientElementClick')); + + expect(result.ok).toBe(true); + expect(scroll()).not.toHaveBeenCalled(); + }); + + test('a scroll to statement scrolls to the element', async () => { + const result = await replayer.execute(stmt('ZestClientElementScrollTo')); + + expect(result.ok).toBe(true); + expect(scroll()).toHaveBeenCalledTimes(1); + expect(scroll().mock.contexts[0]).toBe(document.getElementById('target')); + expect(scroll().mock.calls[0][0]).toEqual({block: 'center'}); + }); + + test('a scroll to followed by a click scrolls once, for the scroll to', async () => { + await replayer.execute(stmt('ZestClientElementScrollTo')); + await replayer.execute(stmt('ZestClientElementClick')); + + expect(scroll()).toHaveBeenCalledTimes(1); + }); + + test.each([ + ['ZestClientElementSendKeys', {element: 'field', value: 'x'}], + ['ZestClientElementClear', {element: 'field'}], + ['ZestClientElementMouseOver', {}], + ['ZestClientElementSubmit', {element: 'form'}], + ])('%s does not scroll either', async (type, extra) => { + document + .getElementById('form') + ?.addEventListener('submit', (e) => e.preventDefault()); + + const result = await replayer.execute(stmt(type, extra)); + + expect(result.ok).toBe(true); + expect(scroll()).not.toHaveBeenCalled(); + }); + + test('a click still clicks the element, without scrolling', async () => { + const clicked = jest.fn(); + (document.getElementById('target') as HTMLElement).addEventListener( + 'click', + clicked + ); + + await replayer.execute(stmt('ZestClientElementClick')); + + expect(clicked).toHaveBeenCalledTimes(1); + expect(scroll()).not.toHaveBeenCalled(); + }); +}); + +describe('finding a link by part of its text', () => { + const clicks: string[] = []; + + beforeEach(() => { + clicks.length = 0; + document.body.innerHTML = + 'Sign in with Google' + + 'Sign in with email' + + '\n Create an account\n' + + '

Sign in with text, which is not a link

'; + ['first', 'second', 'third'].forEach((id) => + (document.getElementById(id) as HTMLElement).addEventListener( + 'click', + (e) => { + e.preventDefault(); + clicks.push(id); + } + ) + ); + }); + + const click = (element: string): Promise<{ok: boolean; errorKey?: string}> => + replayer.execute( + stmt('ZestClientElementClick', {type: 'partialLinkText', element}) + ); + + test('finds a link by a part of its text', async () => { + expect((await click('with email')).ok).toBe(true); + + expect(clicks).toEqual(['second']); + }); + + test('finds the first of the links that have the text', async () => { + expect((await click('Sign in')).ok).toBe(true); + + expect(clicks).toEqual(['first']); + }); + + test('finds a link by the start, middle or end of its text', async () => { + await click('Sign'); + await click('in with e'); + await click('email'); + + expect(clicks).toEqual(['first', 'second', 'second']); + }); + + test('finds a link whose text has white space around it', async () => { + expect((await click('account')).ok).toBe(true); + + expect(clicks).toEqual(['third']); + }); + + test('all of the text finds the link too, as it is part of it', async () => { + expect((await click('Sign in with Google')).ok).toBe(true); + + expect(clicks).toEqual(['first']); + }); + + test('is case sensitive', async () => { + expect((await click('sign in')).errorKey).toBe( + 'script.error.elementNotFound' + ); + + expect(clicks).toEqual([]); + }); + + test('does not find text that is not in a link', async () => { + document.getElementById('first')?.remove(); + document.getElementById('second')?.remove(); + + expect((await click('which is not a link')).errorKey).toBe( + 'script.error.elementNotFound' + ); + expect(clicks).toEqual([]); + }); + + test('reports text that is not in any link, with the type and the text', async () => { + expect(await click('Sign out')).toEqual({ + ok: false, + errorKey: 'script.error.elementNotFound', + errorParams: {type: 'partialLinkText', element: 'Sign out'}, + }); + }); + + test('finds nothing for no text, rather than the first link', async () => { + expect((await click('')).errorKey).toBe('script.error.elementNotFound'); + + expect(clicks).toEqual([]); + }); + + test('waits for a link to appear that has the text', async () => { + setTimeout(() => { + document.body.insertAdjacentHTML( + 'beforeend', + 'Welcome back' + ); + (document.getElementById('late') as HTMLElement).addEventListener( + 'click', + (e) => { + e.preventDefault(); + clicks.push('late'); + } + ); + }, 20); + + expect((await click('Welcome')).ok).toBe(true); + + expect(clicks).toEqual(['late']); + }); + + test('does not find a link that only the whole text finds, using linkText', async () => { + const exact = await replayer.execute( + stmt('ZestClientElementClick', {type: 'linkText', element: 'with email'}) + ); + + // linkText needs all of the text, which is the difference between them + expect(exact.errorKey).toBe('script.error.elementNotFound'); + expect(clicks).toEqual([]); + }); + + test('works for the other statements that find an element too', async () => { + document.body.insertAdjacentHTML( + 'beforeend', + 'Continue to checkout' + ); + + const result = await replayer.execute( + stmt('ZestClientElementMouseOver', { + type: 'partialLinkText', + element: 'checkout', + }) + ); + + expect(result).toEqual({ok: true}); + }); +}); diff --git a/test/Background/scriptView.test.ts b/test/Background/scriptView.test.ts new file mode 100644 index 00000000..b0d41690 --- /dev/null +++ b/test/Background/scriptView.test.ts @@ -0,0 +1,2023 @@ +/** + * @jest-environment jsdom + */ +/* + * Zed Attack Proxy (ZAP) and its related source files. + * + * ZAP is an HTTP/HTTPS proxy for assessing web application security. + * + * Copyright 2026 The ZAP Development Team + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import * as fs from 'fs'; +import Browser from 'webextension-polyfill'; +import * as path from 'path'; + +const script = (titles: string[]): string => + JSON.stringify({ + title: 'My script', + statements: titles.map((comment, i) => ({ + index: i + 1, + enabled: true, + // A comment that starts with a question mark is a type that cannot be edited + elementType: comment.startsWith('?') + ? 'ZestSomethingElse' + : 'ZestComment', + comment, + })), + }); + +const mockSyncStore: Record = {}; +let stored: string[] = ['first', 'second']; +let failSave = false; +let holdSave: Promise | undefined; +// Each request for the script waits for the next of these, if there are any +const holdLoads: Promise[] = []; +// The number of the next requests for the script that fail +let failLoads = 0; + +const sendMessage = jest.fn((request: {type: string; data?: unknown[]}) => { + if (request.type === 'getZestScript') { + // What is stored when the request is made, as a late reply would have + const reply = { + script: script(stored), + title: 'My script', + statementCount: stored.length, + }; + if (failLoads > 0) { + failLoads -= 1; + return Promise.reject(new Error('background unavailable')); + } + const gate = holdLoads.shift(); + return gate ? gate.then(() => reply) : Promise.resolve(reply); + } + if (request.type === 'setZestStatements') { + if (failSave) { + return Promise.reject(new Error('QUOTA_BYTES quota exceeded')); + } + const apply = (): void => { + stored = (request.data as {comment: string}[]).map((s) => s.comment); + }; + if (holdSave) { + return holdSave.then(apply).then(() => 2); + } + apply(); + } + return Promise.resolve(2); +}); + +const mockPlay = jest.fn(); +const mockPlayer = { + play: (...args: unknown[]): unknown => mockPlay(...args), + stop: jest.fn(), + next: jest.fn(), + ownsTab: jest.fn(), +}; + +jest.mock('../../source/ScriptView/player', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => mockPlayer), +})); + +jest.mock('webextension-polyfill', () => ({ + __esModule: true, + default: { + runtime: { + sendMessage: (m: unknown): Promise => sendMessage(m as never), + onMessage: {addListener: jest.fn()}, + getManifest: undefined, + }, + storage: { + sync: { + get: (defaults: Record): Promise => + Promise.resolve({ + zaprecordingactive: false, + ...defaults, + ...mockSyncStore, + }), + set: (items: Record): Promise => { + Object.assign(mockSyncStore, items); + return Promise.resolve(); + }, + }, + onChanged: {addListener: jest.fn()}, + }, + }, +})); + +function flush(): Promise { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + +function rowTexts(): string[] { + return Array.from(document.querySelectorAll('#script-body tr')).map( + (r): string => (r as HTMLTableRowElement).cells[1].textContent ?? '' + ); +} + +function message(): string { + return document.getElementById('script-message')?.textContent ?? ''; +} + +function clickRowButton(row: number, title: string): void { + const rows = document.querySelectorAll('#script-body tr'); + ( + rows[row].querySelector(`button[title="${title}"]`) as HTMLButtonElement + ).click(); +} + +// jsdom does not implement modal dialogs +beforeAll(() => { + HTMLDialogElement.prototype.showModal = function showModal(): void { + this.setAttribute('open', ''); + }; + HTMLDialogElement.prototype.close = function close(): void { + this.removeAttribute('open'); + }; +}); + +beforeAll(async () => { + const html = fs.readFileSync( + path.join(__dirname, '..', '..', 'views', 'script.html'), + 'utf8' + ); + document.documentElement.innerHTML = html; + // eslint-disable-next-line node/no-unsupported-features/es-syntax + await import('../../source/ScriptView/index'); + document.dispatchEvent(new Event('DOMContentLoaded')); + await flush(); +}); + +test('shows the stored statements', () => { + expect(rowTexts()).toEqual(['Comment: first', 'Comment: second']); +}); + +test('a change is saved and shown', async () => { + clickRowButton(0, 'Move down'); + await flush(); + + expect(stored).toEqual(['second', 'first']); + expect(rowTexts()).toEqual(['Comment: second', 'Comment: first']); +}); + +test('a failed save is reported and nothing changes', async () => { + failSave = true; + clickRowButton(0, 'Move down'); + await flush(); + failSave = false; + + expect(message()).toBe( + 'Failed to save the changes: QUOTA_BYTES quota exceeded' + ); + // Still shows what is really stored + expect(rowTexts()).toEqual(['Comment: second', 'Comment: first']); +}); + +test('a failed save leaves no undo step behind', async () => { + const undo = document.getElementById('undo-btn') as HTMLButtonElement; + // Only the earlier, successful change can be undone + expect(undo.disabled).toBe(false); + undo.click(); + await flush(); + + expect(stored).toEqual(['first', 'second']); + expect(undo.disabled).toBe(true); +}); + +test('the controls are usable again after a failed save', async () => { + expect( + (document.getElementById('add-btn') as HTMLButtonElement).disabled + ).toBe(false); + clickRowButton(1, 'Move up'); + await flush(); + + expect(stored).toEqual(['second', 'first']); + expect(message()).toBe('2 statement(s)'); +}); + +test('the script cannot be run or refreshed while a change is being saved', async () => { + let release: () => void = () => {}; + holdSave = new Promise((resolve) => { + release = resolve; + }); + const run = document.getElementById('run-btn') as HTMLButtonElement; + const refresh = document.getElementById('refresh-btn') as HTMLButtonElement; + expect(run.disabled).toBe(false); + + clickRowButton(0, 'Move down'); + await flush(); + expect(run.disabled).toBe(true); + expect(refresh.disabled).toBe(true); + + holdSave = undefined; + release(); + await flush(); + expect(run.disabled).toBe(false); + expect(refresh.disabled).toBe(false); +}); + +const undoButton = (): HTMLButtonElement => + document.getElementById('undo-btn') as HTMLButtonElement; + +// Earlier tests leave undo steps behind +async function drainUndo(): Promise { + while (!undoButton().disabled) { + undoButton().click(); + // eslint-disable-next-line no-await-in-loop + await flush(); + } +} + +function getScriptRequests(): number { + return sendMessage.mock.calls.filter( + (c) => (c[0] as {type: string}).type === 'getZestScript' + ).length; +} + +describe('refreshing from the popup', () => { + const onMessage = (): ((message: unknown) => void) => + (Browser.runtime.onMessage.addListener as jest.Mock).mock.calls[0][0]; + + test('reloads the script', async () => { + const before = getScriptRequests(); + + onMessage()({type: 'refreshScriptView'}); + await flush(); + + expect(getScriptRequests()).toBe(before + 1); + }); + + test('is ignored while a change is being saved', async () => { + let release: () => void = () => {}; + holdSave = new Promise((resolve) => { + release = resolve; + }); + clickRowButton(0, 'Move down'); + await flush(); + const before = getScriptRequests(); + + onMessage()({type: 'refreshScriptView'}); + await flush(); + expect(getScriptRequests()).toBe(before); + + holdSave = undefined; + release(); + await flush(); + }); +}); + +describe('undo', () => { + test('keeps the step if undoing fails, so it can be tried again', async () => { + await drainUndo(); + const original = [...stored]; + clickRowButton(0, 'Move down'); + await flush(); + const changed = [...stored]; + expect(changed).not.toEqual(original); + + failSave = true; + undoButton().click(); + await flush(); + failSave = false; + + expect(message()).toContain('Failed to save the changes'); + expect(undoButton().disabled).toBe(false); + + undoButton().click(); + await flush(); + + expect(stored).toEqual(original); + expect(undoButton().disabled).toBe(true); + }); +}); + +describe('loading a file', () => { + const fileInput = (): HTMLInputElement => + document.getElementById('load-file') as HTMLInputElement; + + async function load(content: object): Promise { + const json = JSON.stringify(content); + const file = new File([json], 'loaded.zst'); + // jsdom does not implement Blob.text + Object.defineProperty(file, 'text', {value: () => Promise.resolve(json)}); + Object.defineProperty(fileInput(), 'files', { + value: [file], + configurable: true, + }); + fileInput().dispatchEvent(new Event('change')); + await flush(); + await flush(); + } + + const statements = [{elementType: 'ZestComment', comment: 'loaded'}]; + + test('uses the name in the file', async () => { + await load({title: 'From file', statements}); + + expect(mockSyncStore.zapscriptname).toBe('From file'); + expect(stored).toEqual(['loaded']); + expect(message()).toBe('Loaded 1 statement(s) from loaded.zst'); + }); + + test('clears the old name if the file has none', async () => { + mockSyncStore.zapscriptname = 'Previous script'; + + await load({statements}); + + expect(mockSyncStore.zapscriptname).toBe(''); + }); + + test('puts back the old name if the file could not be saved', async () => { + mockSyncStore.zapscriptname = 'Previous script'; + failSave = true; + + await load({title: 'Not saved', statements}); + failSave = false; + + expect(mockSyncStore.zapscriptname).toBe('Previous script'); + expect(message()).toContain('Failed to save the changes'); + }); + + test('rejects a file with settings that cannot be kept', async () => { + mockSyncStore.zapscriptname = 'Previous script'; + const before = [...stored]; + + await load({ + title: 'Has tokens', + statements, + parameters: {tokenStart: '{{', tokenEnd: '}}', tokens: {a: 'b'}}, + }); + + expect(message()).toBe( + 'Failed to load loaded.zst: The script uses features that cannot be preserved (parameters)' + ); + expect(stored).toEqual(before); + expect(mockSyncStore.zapscriptname).toBe('Previous script'); + }); +}); + +describe('the script name with undo', () => { + const fileInput = (): HTMLInputElement => + document.getElementById('load-file') as HTMLInputElement; + + async function loadFile(content: object): Promise { + const json = JSON.stringify(content); + const file = new File([json], 'loaded.zst'); + Object.defineProperty(file, 'text', {value: () => Promise.resolve(json)}); + Object.defineProperty(fileInput(), 'files', { + value: [file], + configurable: true, + }); + fileInput().dispatchEvent(new Event('change')); + await flush(); + await flush(); + } + + async function startNew(): Promise { + (document.getElementById('new-btn') as HTMLButtonElement).click(); + (document.getElementById('confirm-ok') as HTMLButtonElement).click(); + await flush(); + await flush(); + } + + beforeEach(async () => { + await drainUndo(); + stored = ['first', 'second']; + mockSyncStore.zapscriptname = 'Script A'; + }); + + test('undoing a load puts back the old name as well as the statements', async () => { + await loadFile({ + title: 'Script B', + statements: [{elementType: 'ZestComment', comment: 'from B'}], + }); + expect(mockSyncStore.zapscriptname).toBe('Script B'); + expect(stored).toEqual(['from B']); + + undoButton().click(); + await flush(); + + expect(mockSyncStore.zapscriptname).toBe('Script A'); + expect(stored).toEqual(['first', 'second']); + }); + + test('undoing a new script puts back the old name as well', async () => { + await startNew(); + expect(mockSyncStore.zapscriptname).toBe(''); + expect(stored).toEqual([]); + + undoButton().click(); + await flush(); + + expect(mockSyncStore.zapscriptname).toBe('Script A'); + expect(stored).toEqual(['first', 'second']); + }); + + test('a new script that cannot be saved keeps the old name', async () => { + failSave = true; + + await startNew(); + failSave = false; + + expect(mockSyncStore.zapscriptname).toBe('Script A'); + expect(stored).toEqual(['first', 'second']); + expect(message()).toContain('Failed to save the changes'); + // and nothing was left to undo + expect(undoButton().disabled).toBe(true); + }); + + test('a change that does not rename the script leaves the name alone', async () => { + clickRowButton(0, 'Move down'); + await flush(); + expect(mockSyncStore.zapscriptname).toBe('Script A'); + + undoButton().click(); + await flush(); + + expect(mockSyncStore.zapscriptname).toBe('Script A'); + expect(stored).toEqual(['first', 'second']); + }); +}); + +describe('editing statements with line breaks', () => { + const requests = (): {data: {comment: string; enabled: boolean}[]}[] => + sendMessage.mock.calls + .map((c) => c[0] as {type: string; data: never}) + .filter((m) => m.type === 'setZestStatements') as never; + + test('the line breaks are kept when only another field is changed', async () => { + stored = ['line one\nline two', 'other']; + (Browser.runtime.onMessage.addListener as jest.Mock).mock.calls[0][0]({ + type: 'refreshScriptView', + }); + await flush(); + const rows = document.querySelectorAll('#script-body tr'); + rows[0].dispatchEvent(new MouseEvent('dblclick', {bubbles: true})); + + const comment = document.getElementById( + 'field-comment' + ) as HTMLTextAreaElement; + expect(comment.tagName).toBe('TEXTAREA'); + expect(comment.value).toBe('line one\nline two'); + (document.getElementById('field-enabled') as HTMLInputElement).checked = + false; + (document.getElementById('edit-form') as HTMLFormElement).dispatchEvent( + new Event('submit', {cancelable: true}) + ); + await flush(); + + const saved = requests()[requests().length - 1].data; + expect(saved[0].comment).toBe('line one\nline two'); + expect(saved[0].enabled).toBe(false); + expect(stored[0]).toBe('line one\nline two'); + }); +}); + +describe('while the script is being loaded', () => { + const refresh = (): void => + (Browser.runtime.onMessage.addListener as jest.Mock).mock.calls[0][0]({ + type: 'refreshScriptView', + }); + const runButton = (): HTMLButtonElement => + document.getElementById('run-btn') as HTMLButtonElement; + + // Makes the next request for the script wait until the returned function is called + function holdNextLoad(): () => void { + let release: () => void = () => {}; + holdLoads.push( + new Promise((resolve) => { + release = resolve; + }) + ); + return () => release(); + } + + beforeEach(async () => { + await drainUndo(); + stored = ['first', 'second']; + refresh(); + await flush(); + }); + + test('the script cannot be run or changed until it has arrived', async () => { + const release = holdNextLoad(); + refresh(); + await flush(); + const savesBefore = sendMessage.mock.calls.filter( + (c) => (c[0] as {type: string}).type === 'setZestStatements' + ).length; + + expect(runButton().disabled).toBe(true); + expect( + (document.getElementById('add-btn') as HTMLButtonElement).disabled + ).toBe(true); + clickRowButton(0, 'Move down'); + await flush(); + expect( + sendMessage.mock.calls.filter( + (c) => (c[0] as {type: string}).type === 'setZestStatements' + ).length + ).toBe(savesBefore); + + release(); + await flush(); + + expect(runButton().disabled).toBe(false); + expect( + (document.getElementById('add-btn') as HTMLButtonElement).disabled + ).toBe(false); + }); + + test('an out of date reply does not replace a newer one', async () => { + const releaseOld = holdNextLoad(); + refresh(); + await flush(); + stored = ['newer']; + refresh(); + await flush(); + expect(rowTexts()).toEqual(['Comment: newer']); + + releaseOld(); + await flush(); + + expect(rowTexts()).toEqual(['Comment: newer']); + expect(runButton().disabled).toBe(false); + }); +}); + +describe('running a script', () => { + const runButton = (): HTMLButtonElement => + document.getElementById('run-btn') as HTMLButtonElement; + + beforeEach(async () => { + mockPlay.mockReset(); + stored = ['first', 'second']; + (Browser.runtime.onMessage.addListener as jest.Mock).mock.calls[0][0]({ + type: 'refreshScriptView', + }); + await flush(); + }); + + test('the controls are released if the player fails', async () => { + mockPlay.mockRejectedValue(new Error('boom')); + + runButton().click(); + await flush(); + + expect(message()).toBe('Script failed: boom'); + expect(runButton().hidden).toBe(false); + expect(runButton().disabled).toBe(false); + expect( + (document.getElementById('add-btn') as HTMLButtonElement).disabled + ).toBe(false); + expect( + (document.getElementById('stop-btn') as HTMLButtonElement).hidden + ).toBe(true); + }); + + test('a result for a row that is not there does not stop the run', async () => { + mockPlay.mockImplementation( + async ( + _statements: unknown, + _delay: unknown, + onState: (position: number, state: string) => void + ) => { + onState(99, 'running'); + onState(0, 'ok'); + return true; + } + ); + + runButton().click(); + await flush(); + + expect(message()).toBe('Script completed successfully'); + expect( + (document.querySelector('#script-body tr') as HTMLTableRowElement) + .cells[3].textContent + ).toBe('✓'); + expect(runButton().hidden).toBe(false); + }); +}); + +describe('line endings when editing', () => { + const requests = (): {data: {comment: string; enabled: boolean}[]}[] => + sendMessage.mock.calls + .map((c) => c[0] as {type: string; data: never}) + .filter((m) => m.type === 'setZestStatements') as never; + + async function openFirstStatement(): Promise { + (Browser.runtime.onMessage.addListener as jest.Mock).mock.calls[0][0]({ + type: 'refreshScriptView', + }); + await flush(); + document + .querySelector('#script-body tr') + ?.dispatchEvent(new MouseEvent('dblclick', {bubbles: true})); + } + + async function submit(): Promise { + (document.getElementById('edit-form') as HTMLFormElement).dispatchEvent( + new Event('submit', {cancelable: true}) + ); + await flush(); + return requests()[requests().length - 1].data[0].comment; + } + + test.each([ + ['CRLF', 'line 1\r\nline 2'], + ['a lone CR', 'line 1\rline 2'], + ['mixed', 'a\r\nb\nc\rd'], + ])('%s is kept if only another field is changed', async (_name, text) => { + stored = [text, 'other']; + await openFirstStatement(); + (document.getElementById('field-enabled') as HTMLInputElement).checked = + false; + + expect(await submit()).toBe(text); + }); + + test('an edited text is saved as it was entered', async () => { + stored = ['line 1\r\nline 2', 'other']; + await openFirstStatement(); + (document.getElementById('field-comment') as HTMLTextAreaElement).value = + 'changed\nlines'; + + expect(await submit()).toBe('changed\nlines'); + }); +}); + +const saveCount = (): number => + sendMessage.mock.calls.filter( + (c) => (c[0] as {type: string}).type === 'setZestStatements' + ).length; + +const refreshView = (): void => + (Browser.runtime.onMessage.addListener as jest.Mock).mock.calls[0][0]({ + type: 'refreshScriptView', + }); + +const dialogOpen = (id: string): boolean => + (document.getElementById(id) as HTMLElement).hasAttribute('open'); + +describe('when the script cannot be loaded', () => { + beforeEach(async () => { + stored = ['first', 'second']; + refreshView(); + await flush(); + }); + + test('nothing can be changed until it has been loaded again', async () => { + failLoads = 1; + refreshView(); + await flush(); + const before = saveCount(); + + expect(message()).toBe( + 'Failed to load script: Error: background unavailable' + ); + for (const id of [ + 'add-btn', + 'new-btn', + 'load-btn', + 'undo-btn', + 'run-btn', + ]) { + expect((document.getElementById(id) as HTMLButtonElement).disabled).toBe( + true + ); + } + // The statements still on the page are not what is stored, so changing them is refused + clickRowButton(0, 'Move down'); + clickRowButton(0, 'Delete'); + await flush(); + expect(saveCount()).toBe(before); + expect(dialogOpen('confirm-dialog')).toBe(false); + + refreshView(); + await flush(); + + expect( + (document.getElementById('add-btn') as HTMLButtonElement).disabled + ).toBe(false); + expect( + (document.getElementById('run-btn') as HTMLButtonElement).disabled + ).toBe(false); + clickRowButton(0, 'Move down'); + await flush(); + expect(saveCount()).toBe(before + 1); + }); + + test('an open dialog is closed when the script cannot be loaded', async () => { + document + .querySelector('#script-body tr') + ?.dispatchEvent(new MouseEvent('dblclick', {bubbles: true})); + expect(dialogOpen('edit-dialog')).toBe(true); + + failLoads = 1; + refreshView(); + await flush(); + + expect(dialogOpen('edit-dialog')).toBe(false); + // Recover for the other tests + refreshView(); + await flush(); + }); +}); + +describe('dialogs for a script that has been replaced', () => { + const submitEdit = (): void => { + (document.getElementById('edit-form') as HTMLFormElement).dispatchEvent( + new Event('submit', {cancelable: true}) + ); + }; + + beforeEach(async () => { + stored = ['first', 'second', 'third']; + refreshView(); + await flush(); + }); + + test('an edit dialog is closed and cannot change the new script', async () => { + document + .querySelectorAll('#script-body tr')[1] + .dispatchEvent(new MouseEvent('dblclick', {bubbles: true})); + expect(dialogOpen('edit-dialog')).toBe(true); + const before = saveCount(); + + stored = ['one', 'two', 'three']; + refreshView(); + await flush(); + expect(dialogOpen('edit-dialog')).toBe(false); + (document.getElementById('field-comment') as HTMLTextAreaElement).value = + 'changed'; + submitEdit(); + await flush(); + + expect(saveCount()).toBe(before); + expect(stored).toEqual(['one', 'two', 'three']); + }); + + test('a delete confirmation is closed and cannot delete from the new script', async () => { + clickRowButton(0, 'Delete'); + expect(dialogOpen('confirm-dialog')).toBe(true); + const before = saveCount(); + + stored = ['one', 'two']; + refreshView(); + await flush(); + expect(dialogOpen('confirm-dialog')).toBe(false); + (document.getElementById('confirm-ok') as HTMLButtonElement).click(); + await flush(); + + expect(saveCount()).toBe(before); + expect(stored).toEqual(['one', 'two']); + }); + + test('an add dialog cannot add to the new script', async () => { + (document.getElementById('add-btn') as HTMLButtonElement).click(); + expect(dialogOpen('edit-dialog')).toBe(true); + const before = saveCount(); + + stored = ['one']; + refreshView(); + await flush(); + submitEdit(); + await flush(); + + expect(saveCount()).toBe(before); + expect(stored).toEqual(['one']); + }); + + test('a new script confirmation is closed too', async () => { + (document.getElementById('new-btn') as HTMLButtonElement).click(); + expect(dialogOpen('confirm-dialog')).toBe(true); + const before = saveCount(); + + stored = ['one']; + refreshView(); + await flush(); + (document.getElementById('confirm-ok') as HTMLButtonElement).click(); + await flush(); + + expect(saveCount()).toBe(before); + expect(stored).toEqual(['one']); + }); + + test('starting a recording closes the dialogs', async () => { + const onChanged = (Browser.storage.onChanged.addListener as jest.Mock).mock + .calls[0][0]; + document + .querySelector('#script-body tr') + ?.dispatchEvent(new MouseEvent('dblclick', {bubbles: true})); + expect(dialogOpen('edit-dialog')).toBe(true); + + onChanged({zaprecordingactive: {newValue: true}}); + expect(dialogOpen('edit-dialog')).toBe(false); + + onChanged({zaprecordingactive: {newValue: false}}); + await flush(); + }); +}); + +describe('replay controls from the page', () => { + const onMessage = (): (( + message: unknown, + sender: {tab?: {id?: number}} + ) => void) => + (Browser.runtime.onMessage.addListener as jest.Mock).mock.calls[0][0]; + + let finish: (ok: boolean) => void = () => {}; + + beforeEach(async () => { + stored = ['first', 'second']; + refreshView(); + await flush(); + mockPlayer.next.mockClear(); + mockPlayer.stop.mockClear(); + mockPlayer.ownsTab.mockReset(); + mockPlayer.ownsTab.mockImplementation((id?: number) => id === 7); + mockPlay.mockReset(); + mockPlay.mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }) + ); + (document.getElementById('run-btn') as HTMLButtonElement).click(); + await flush(); + }); + + afterEach(async () => { + finish(true); + await flush(); + }); + + test('next and cancel from the replay tab are acted on', () => { + onMessage()({type: 'replayNext'}, {tab: {id: 7}}); + expect(mockPlayer.next).toHaveBeenCalledTimes(1); + + onMessage()({type: 'replayCancel'}, {tab: {id: 7}}); + expect(mockPlayer.stop).toHaveBeenCalledTimes(1); + }); + + test('next and cancel from another tab are ignored', () => { + onMessage()({type: 'replayNext'}, {tab: {id: 8}}); + onMessage()({type: 'replayCancel'}, {tab: {id: 8}}); + + expect(mockPlayer.next).not.toHaveBeenCalled(); + expect(mockPlayer.stop).not.toHaveBeenCalled(); + }); + + test('next and cancel with no tab are ignored', () => { + onMessage()({type: 'replayNext'}, {}); + onMessage()({type: 'replayCancel'}, {tab: {}}); + + expect(mockPlayer.next).not.toHaveBeenCalled(); + expect(mockPlayer.stop).not.toHaveBeenCalled(); + }); + + test('they are ignored when nothing is being replayed', async () => { + finish(true); + await flush(); + + onMessage()({type: 'replayNext'}, {tab: {id: 7}}); + onMessage()({type: 'replayCancel'}, {tab: {id: 7}}); + + expect(mockPlayer.next).not.toHaveBeenCalled(); + expect(mockPlayer.stop).not.toHaveBeenCalled(); + }); +}); + +describe('the move buttons', () => { + const button = (row: number, title: string): HTMLButtonElement => + document + .querySelectorAll('#script-body tr') + [row].querySelector(`button[title="${title}"]`) as HTMLButtonElement; + + async function show(statementsToShow: string[]): Promise { + stored = statementsToShow; + refreshView(); + await flush(); + } + + test('the first statement cannot move up and the last cannot move down', async () => { + await show(['one', 'two', 'three']); + + expect(button(0, 'Move up').disabled).toBe(true); + expect(button(0, 'Move down').disabled).toBe(false); + expect(button(1, 'Move up').disabled).toBe(false); + expect(button(1, 'Move down').disabled).toBe(false); + expect(button(2, 'Move up').disabled).toBe(false); + expect(button(2, 'Move down').disabled).toBe(true); + }); + + test('a single statement cannot move at all', async () => { + await show(['only']); + + expect(button(0, 'Move up').disabled).toBe(true); + expect(button(0, 'Move down').disabled).toBe(true); + }); + + test('the other buttons are not affected', async () => { + await show(['one', 'two']); + + for (const row of [0, 1]) { + expect(button(row, 'Edit').disabled).toBe(false); + expect(button(row, 'Delete').disabled).toBe(false); + } + }); + + test('they follow a statement that has moved', async () => { + await show(['one', 'two', 'three']); + + button(0, 'Move down').click(); + await flush(); + + expect(stored).toEqual(['two', 'one', 'three']); + expect(button(0, 'Move up').disabled).toBe(true); + expect(button(1, 'Move up').disabled).toBe(false); + expect(button(2, 'Move down').disabled).toBe(true); + }); + + test('a disabled button does nothing', async () => { + await show(['one', 'two']); + const before = saveCount(); + + button(0, 'Move up').click(); + button(1, 'Move down').click(); + await flush(); + + expect(saveCount()).toBe(before); + expect(stored).toEqual(['one', 'two']); + }); + + test('a script that loses statements gets the right buttons', async () => { + await show(['one', 'two', 'three']); + + button(2, 'Delete').click(); + (document.getElementById('confirm-ok') as HTMLButtonElement).click(); + await flush(); + + expect(stored).toEqual(['one', 'two']); + expect(button(1, 'Move down').disabled).toBe(true); + }); +}); + +describe('redo', () => { + const redoButton = (): HTMLButtonElement => + document.getElementById('redo-btn') as HTMLButtonElement; + const fileInput = (): HTMLInputElement => + document.getElementById('load-file') as HTMLInputElement; + + async function loadFile(content: object): Promise { + const json = JSON.stringify(content); + const file = new File([json], 'loaded.zst'); + Object.defineProperty(file, 'text', {value: () => Promise.resolve(json)}); + Object.defineProperty(fileInput(), 'files', { + value: [file], + configurable: true, + }); + fileInput().dispatchEvent(new Event('change')); + await flush(); + await flush(); + } + + const recording = (active: boolean): void => + (Browser.storage.onChanged.addListener as jest.Mock).mock.calls[0][0]({ + zaprecordingactive: {newValue: active}, + }); + + // Starts with a script and nothing to undo or redo + beforeEach(async () => { + stored = ['one', 'two', 'three']; + mockSyncStore.zapscriptname = 'Script A'; + recording(true); + recording(false); + await flush(); + await flush(); + }); + + const moveDown = async (row: number): Promise => { + clickRowButton(row, 'Move down'); + await flush(); + }; + + test('is on the same line as undo, and not with the other buttons', () => { + const line = undoButton().parentElement as HTMLElement; + + expect(redoButton().parentElement).toBe(line); + expect(line.contains(document.getElementById('add-btn'))).toBe(false); + expect(line.contains(document.getElementById('new-btn'))).toBe(false); + expect(line.contains(document.getElementById('run-btn'))).toBe(false); + // and undo comes first + const buttons = Array.from(line.querySelectorAll('button')); + expect(buttons.map((b) => b.id)).toEqual(['undo-btn', 'redo-btn']); + }); + + test('has nothing to do until something has been undone', async () => { + expect(redoButton().disabled).toBe(true); + + await moveDown(0); + + expect(undoButton().disabled).toBe(false); + expect(redoButton().disabled).toBe(true); + }); + + test('puts back what was undone', async () => { + await moveDown(0); + expect(stored).toEqual(['two', 'one', 'three']); + + undoButton().click(); + await flush(); + expect(stored).toEqual(['one', 'two', 'three']); + expect(redoButton().disabled).toBe(false); + + redoButton().click(); + await flush(); + + expect(stored).toEqual(['two', 'one', 'three']); + expect(redoButton().disabled).toBe(true); + expect(undoButton().disabled).toBe(false); + }); + + test('can be undone again after it has been redone', async () => { + await moveDown(0); + undoButton().click(); + await flush(); + redoButton().click(); + await flush(); + + undoButton().click(); + await flush(); + + expect(stored).toEqual(['one', 'two', 'three']); + expect(redoButton().disabled).toBe(false); + }); + + test('puts back several changes in order', async () => { + await moveDown(0); + await moveDown(1); + expect(stored).toEqual(['two', 'three', 'one']); + + undoButton().click(); + await flush(); + undoButton().click(); + await flush(); + expect(stored).toEqual(['one', 'two', 'three']); + + redoButton().click(); + await flush(); + expect(stored).toEqual(['two', 'one', 'three']); + redoButton().click(); + await flush(); + expect(stored).toEqual(['two', 'three', 'one']); + expect(redoButton().disabled).toBe(true); + }); + + test('is lost when another change is made', async () => { + await moveDown(0); + undoButton().click(); + await flush(); + expect(redoButton().disabled).toBe(false); + + await moveDown(1); + + expect(redoButton().disabled).toBe(true); + }); + + test('puts back the name of a script that was loaded', async () => { + await loadFile({ + title: 'Script B', + statements: [{elementType: 'ZestComment', comment: 'from B'}], + }); + undoButton().click(); + await flush(); + expect(mockSyncStore.zapscriptname).toBe('Script A'); + expect(stored).toEqual(['one', 'two', 'three']); + + redoButton().click(); + await flush(); + + expect(mockSyncStore.zapscriptname).toBe('Script B'); + expect(stored).toEqual(['from B']); + }); + + test('puts back an empty script after a new one was made and undone', async () => { + (document.getElementById('new-btn') as HTMLButtonElement).click(); + (document.getElementById('confirm-ok') as HTMLButtonElement).click(); + await flush(); + await flush(); + undoButton().click(); + await flush(); + expect(stored).toEqual(['one', 'two', 'three']); + + redoButton().click(); + await flush(); + + expect(stored).toEqual([]); + expect(mockSyncStore.zapscriptname).toBe(''); + }); + + test('a load ends any redo', async () => { + await moveDown(0); + undoButton().click(); + await flush(); + + await loadFile({ + statements: [{elementType: 'ZestComment', comment: 'loaded'}], + }); + + expect(redoButton().disabled).toBe(true); + }); + + test('a redo that cannot be saved can be tried again', async () => { + await moveDown(0); + undoButton().click(); + await flush(); + + failSave = true; + redoButton().click(); + await flush(); + failSave = false; + + expect(message()).toContain('Failed to save the changes'); + expect(stored).toEqual(['one', 'two', 'three']); + expect(redoButton().disabled).toBe(false); + expect(undoButton().disabled).toBe(true); + + redoButton().click(); + await flush(); + + expect(stored).toEqual(['two', 'one', 'three']); + expect(redoButton().disabled).toBe(true); + expect(undoButton().disabled).toBe(false); + }); + + test('an undo that cannot be saved leaves nothing to redo', async () => { + await moveDown(0); + + failSave = true; + undoButton().click(); + await flush(); + failSave = false; + + expect(redoButton().disabled).toBe(true); + expect(undoButton().disabled).toBe(false); + }); + + test('a change that cannot be saved does not end a redo', async () => { + await moveDown(0); + undoButton().click(); + await flush(); + + failSave = true; + await moveDown(1); + failSave = false; + + expect(redoButton().disabled).toBe(false); + }); + + test('is not available while a change is being saved', async () => { + await moveDown(0); + undoButton().click(); + await flush(); + let release: () => void = () => {}; + holdSave = new Promise((resolve) => { + release = resolve; + }); + + clickRowButton(0, 'Move down'); + await flush(); + expect(redoButton().disabled).toBe(true); + + holdSave = undefined; + release(); + await flush(); + }); + + test('is cleared when a recording replaces the script', async () => { + await moveDown(0); + undoButton().click(); + await flush(); + expect(redoButton().disabled).toBe(false); + + recording(true); + expect(redoButton().disabled).toBe(true); + recording(false); + await flush(); + + expect(redoButton().disabled).toBe(true); + expect(undoButton().disabled).toBe(true); + }); +}); + +describe('while a script is being recorded', () => { + const listener = (): ((changes: unknown, area?: string) => void) => + (Browser.storage.onChanged.addListener as jest.Mock).mock.calls[0][0]; + + const recording = (active: boolean): void => + listener()({zaprecordingactive: {newValue: active}}, 'sync'); + + // The statements as the background stores them + const storedStatements = (comments: string[]): string[] => + comments.map((comment, i) => + JSON.stringify({ + index: i + 1, + enabled: true, + elementType: 'ZestComment', + comment, + }) + ); + + // As when the background stores what has been recorded + const record = (comments: string[]): void => { + stored = comments; + listener()( + {zestStatements: {newValue: storedStatements(comments)}}, + 'local' + ); + }; + + const wait = (msec: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, msec); + }); + // Longer than the viewer gathers changes for + const settle = (): Promise => wait(400); + + const scrollIntoView = jest.fn(); + const lastRow = (): Element | null => + document.querySelector('#script-body tr:last-child'); + + beforeAll(() => { + Element.prototype.scrollIntoView = scrollIntoView; + }); + + beforeEach(async () => { + stored = ['one']; + refreshView(); + await flush(); + scrollIntoView.mockClear(); + }); + + afterEach(async () => { + recording(false); + await settle(); + Object.defineProperty(document, 'hidden', { + configurable: true, + get: () => false, + }); + delete (document.documentElement as {scrollHeight?: number}).scrollHeight; + }); + + test('shows the statements as they are recorded', async () => { + recording(true); + + record(['one', 'two']); + await settle(); + + expect(rowTexts()).toEqual(['Comment: one', 'Comment: two']); + record(['one', 'two', 'three']); + await settle(); + expect(rowTexts()).toHaveLength(3); + }); + + test('says that it is recording, and how many there are', async () => { + recording(true); + + record(['one', 'two']); + await settle(); + + expect(message()).toBe('Recording... 2 statement(s)'); + }); + + test('says that it is recording before anything has been recorded', async () => { + recording(true); + + record([]); + await settle(); + + expect(rowTexts()).toEqual([]); + expect(message()).toBe('Recording... no statements yet'); + }); + + test('says so at once when a recording starts', () => { + recording(true); + + expect(message()).toBe('Recording... 1 statement(s)'); + }); + + test('stops saying so when the recording ends', async () => { + recording(true); + record(['one', 'two']); + await settle(); + + recording(false); + await flush(); + + expect(message()).toBe('2 statement(s)'); + }); + + test('gathers statements that arrive together into one refresh', async () => { + recording(true); + const before = getScriptRequests(); + + record(['one', 'two']); + record(['one', 'two', 'three']); + record(['one', 'two', 'three', 'four']); + await settle(); + + expect(getScriptRequests() - before).toBe(1); + expect(rowTexts()).toHaveLength(4); + }); + + test('keeps up with statements that arrive after a refresh', async () => { + recording(true); + record(['one', 'two']); + await settle(); + const before = getScriptRequests(); + + record(['one', 'two', 'three']); + await settle(); + + expect(getScriptRequests() - before).toBe(1); + expect(rowTexts()).toHaveLength(3); + }); + + test('does not refresh for its own saves', async () => { + // Two statements, so that the first can be moved down, which is a save + stored = ['one', 'two']; + refreshView(); + await flush(); + let release: () => void = () => {}; + holdSave = new Promise((resolve) => { + release = resolve; + }); + const before = getScriptRequests(); + const savesBefore = saveCount(); + + clickRowButton(0, 'Move down'); + await flush(); + // The change that the save makes, which is announced while it is being saved + record(['saved']); + holdSave = undefined; + release(); + await settle(); + + // Only the one that the save makes itself, and the save did happen + expect(getScriptRequests() - before).toBe(1); + expect(saveCount() - savesBefore).toBe(1); + }); + + test('does not refresh for what it is already showing', async () => { + const before = getScriptRequests(); + + record(['one']); + await settle(); + + expect(getScriptRequests()).toBe(before); + }); + + test('refreshes for a change made by something else, and drops the undo steps', async () => { + clickRowButton(0, 'Move down'); + await flush(); + stored = ['one']; + refreshView(); + await flush(); + // Something that can be undone + stored = ['one', 'two']; + refreshView(); + await flush(); + clickRowButton(0, 'Move down'); + await flush(); + expect(undoButton().disabled).toBe(false); + + record(['x', 'y', 'z']); + await settle(); + + expect(rowTexts()).toHaveLength(3); + expect(undoButton().disabled).toBe(true); + }); + + test('shows a statement stored after the recording has stopped', async () => { + recording(true); + recording(false); + await flush(); + + record(['one', 'two', 'three']); + await settle(); + + expect(rowTexts()).toHaveLength(3); + expect(message()).toBe('3 statement(s)'); + }); + + test('shows the script as soon as the recording stops', async () => { + recording(true); + record(['a', 'b']); + const before = getScriptRequests(); + + recording(false); + await flush(); + + expect(getScriptRequests() - before).toBe(1); + expect(rowTexts()).toHaveLength(2); + // and the refresh that was waiting is not done as well + await settle(); + expect(getScriptRequests() - before).toBe(1); + }); + + test('waits until the tab can be seen', async () => { + Object.defineProperty(document, 'hidden', { + configurable: true, + get: () => true, + }); + recording(true); + const before = getScriptRequests(); + + record(['one', 'two']); + await settle(); + + expect(getScriptRequests()).toBe(before); + expect(rowTexts()).toHaveLength(1); + + Object.defineProperty(document, 'hidden', { + configurable: true, + get: () => false, + }); + document.dispatchEvent(new Event('visibilitychange')); + await flush(); + + expect(rowTexts()).toHaveLength(2); + }); + + test('showing the tab again does nothing if nothing changed', async () => { + const before = getScriptRequests(); + + document.dispatchEvent(new Event('visibilitychange')); + await flush(); + + expect(getScriptRequests()).toBe(before); + }); + + test('scrolls to the newest statement', async () => { + recording(true); + + record(['one', 'two', 'three']); + await settle(); + + expect(scrollIntoView).toHaveBeenCalled(); + const lastCall = scrollIntoView.mock.calls.length - 1; + expect(scrollIntoView.mock.contexts[lastCall]).toBe(lastRow()); + expect(scrollIntoView.mock.calls[lastCall][0]).toEqual({block: 'nearest'}); + }); + + test('does not scroll if the page has been scrolled up', async () => { + Object.defineProperty(document.documentElement, 'scrollHeight', { + configurable: true, + value: 5000, + }); + recording(true); + + record(['one', 'two', 'three']); + await settle(); + + expect(scrollIntoView).not.toHaveBeenCalled(); + }); + + test('does not scroll when it is not recording', async () => { + record(['one', 'two', 'three']); + await settle(); + + expect(rowTexts()).toHaveLength(3); + expect(scrollIntoView).not.toHaveBeenCalled(); + }); + + test('editing is not possible while recording', async () => { + recording(true); + record(['one', 'two']); + await settle(); + + expect( + (document.getElementById('add-btn') as HTMLButtonElement).disabled + ).toBe(true); + expect( + (document.getElementById('run-btn') as HTMLButtonElement).disabled + ).toBe(true); + }); +}); + +describe('when the buttons of a row are shown', () => { + // The rules of the page's style sheet as selectors and declarations + function rules(): {selectors: string[]; declarations: string}[] { + const css = Array.from(document.querySelectorAll('style')) + .map((style) => style.textContent ?? '') + .join('\n') + // Not the rules inside a media query, which are not about the buttons + .replace(/@media[^{]*\{[^}]*\{[^}]*\}\s*\}/g, ''); + return css + .split('}') + .filter((rule) => rule.includes('{')) + .map((rule) => { + const [selectors, declarations] = rule.split('{'); + return { + selectors: selectors.split(',').map((sel) => sel.trim()), + declarations: declarations.trim(), + }; + }); + } + + const buttonRules = (): ReturnType => + rules().filter((rule) => + rule.selectors.some((sel) => sel.includes('td.actions button')) + ); + + beforeEach(async () => { + stored = ['one', 'two', 'three']; + refreshView(); + await flush(); + }); + + test('they are hidden unless a rule shows them', () => { + const hidden = buttonRules().filter( + (rule) => + rule.selectors.includes('td.actions button') && + /opacity:\s*0;/.test(rule.declarations) + ); + + expect(hidden).toHaveLength(1); + }); + + test('only hovering or focusing a row shows its buttons', () => { + const shown = buttonRules().filter( + (rule) => + !rule.selectors.some((sel) => sel.startsWith('#script-table.locked')) && + /opacity:\s*(1|0\.3);/.test(rule.declarations) + ); + + expect(shown.length).toBeGreaterThan(0); + shown.forEach((rule) => + rule.selectors.forEach((sel) => + expect(sel).toMatch(/^tr:(hover|focus-within) td\.actions button/) + ) + ); + }); + + test('being selected does not show the buttons of a row', () => { + buttonRules().forEach((rule) => + rule.selectors.forEach((sel) => expect(sel).not.toContain('selected')) + ); + }); + + test('a row that has been moved is selected, with its buttons there but hidden', async () => { + clickRowButton(0, 'Move down'); + await flush(); + + const moved = document.querySelectorAll('#script-body tr')[1]; + expect(moved.classList.contains('selected')).toBe(true); + expect(moved.querySelectorAll('td.actions button')).toHaveLength(4); + // Only the moved row is selected + expect(document.querySelectorAll('#script-body tr.selected')).toHaveLength( + 1 + ); + }); +}); + +describe('updating the table', () => { + const tableRows = (): HTMLTableRowElement[] => + Array.from(document.querySelectorAll('#script-body tr')); + const table = (): HTMLElement => + document.getElementById('script-table') as HTMLElement; + const buttonOf = ( + row: HTMLTableRowElement, + title: string + ): HTMLButtonElement => + row.querySelector(`button[title="${title}"]`) as HTMLButtonElement; + + async function show(comments: string[]): Promise { + stored = comments; + refreshView(); + await flush(); + } + + beforeEach(async () => { + await show(['one', 'two', 'three']); + }); + + test('keeps the same rows when statements move', async () => { + const before = tableRows(); + + clickRowButton(0, 'Move down'); + await flush(); + + // Replacing them would lose the hover of the row under the mouse + expect(rowTexts()).toEqual([ + 'Comment: two', + 'Comment: one', + 'Comment: three', + ]); + tableRows().forEach((row, i) => expect(row).toBe(before[i])); + }); + + test('keeps the same rows for a refresh with the same number of statements', async () => { + const before = tableRows(); + + await show(['a', 'b', 'c']); + + expect(rowTexts()).toEqual(['Comment: a', 'Comment: b', 'Comment: c']); + tableRows().forEach((row, i) => expect(row).toBe(before[i])); + }); + + test('removes the rows that are no longer needed, keeping the others', async () => { + const before = tableRows(); + + await show(['a']); + + expect(tableRows()).toHaveLength(1); + expect(tableRows()[0]).toBe(before[0]); + expect(rowTexts()).toEqual(['Comment: a']); + }); + + test('adds rows for more statements, keeping the ones there', async () => { + const before = tableRows(); + + await show(['a', 'b', 'c', 'd', 'e']); + + expect(tableRows()).toHaveLength(5); + before.forEach((row, i) => expect(tableRows()[i]).toBe(row)); + expect(rowTexts()).toHaveLength(5); + }); + + test('shows the numbers of the statements', async () => { + await show(['a', 'b']); + + expect(tableRows().map((row) => row.cells[0].textContent)).toEqual([ + '1', + '2', + ]); + }); + + test('empties the table, and brings it back, for a script with nothing in it', async () => { + await show([]); + expect(tableRows()).toHaveLength(0); + expect(table().hidden).toBe(true); + + await show(['a', 'b']); + + expect(tableRows()).toHaveLength(2); + expect(table().hidden).toBe(false); + expect(rowTexts()).toEqual(['Comment: a', 'Comment: b']); + }); + + test('clears the results of a run, as a new table would have', async () => { + const row = tableRows()[1]; + row.classList.add('ok'); + row.cells[3].textContent = '✓'; + tableRows()[2].classList.add('failed'); + + await show(['a', 'b', 'c']); + + tableRows().forEach((r) => { + expect(r.classList.contains('ok')).toBe(false); + expect(r.classList.contains('failed')).toBe(false); + expect(r.cells[3].textContent).toBe(''); + }); + }); + + test('shows which row is selected, in the row that is there', async () => { + tableRows()[1].click(); + expect(tableRows().map((r) => r.classList.contains('selected'))).toEqual([ + false, + true, + false, + ]); + + await show(['a', 'b', 'c']); + + expect(tableRows().map((r) => r.classList.contains('selected'))).toEqual([ + false, + true, + false, + ]); + }); + + test('shows a selected row that goes away as not selected', async () => { + tableRows()[2].click(); + + await show(['a']); + + expect(tableRows().some((r) => r.classList.contains('selected'))).toBe( + false + ); + }); + + test('shows a statement that is not enabled as such, and then as enabled', async () => { + tableRows()[0].classList.add('disabled'); + + await show(['a', 'b', 'c']); + + expect(tableRows()[0].classList.contains('disabled')).toBe(false); + }); + + test('the buttons of the rows are right for the statements that are there', async () => { + const rowsBefore = tableRows(); + expect(buttonOf(rowsBefore[0], 'Move up').disabled).toBe(true); + + clickRowButton(0, 'Move down'); + await flush(); + await show(['a', 'b']); + + const rows = tableRows(); + expect(buttonOf(rows[0], 'Move up').disabled).toBe(true); + expect(buttonOf(rows[0], 'Move down').disabled).toBe(false); + expect(buttonOf(rows[1], 'Move up').disabled).toBe(false); + expect(buttonOf(rows[1], 'Move down').disabled).toBe(true); + }); + + test('a button acts on the statement that is in its row now', async () => { + clickRowButton(0, 'Move down'); + await flush(); + expect(stored).toEqual(['two', 'one', 'three']); + + // Row 1 holds "one" now, and was made for row 1 when there were three rows + clickRowButton(1, 'Move down'); + await flush(); + + expect(stored).toEqual(['two', 'three', 'one']); + }); + + test('a statement that cannot be edited has no edit button, and no double click', async () => { + await show(['?odd', 'plain']); + const rows = tableRows(); + const before = saveCount(); + + expect(buttonOf(rows[0], 'Edit').hidden).toBe(true); + expect(buttonOf(rows[1], 'Edit').hidden).toBe(false); + rows[0].dispatchEvent(new MouseEvent('dblclick', {bubbles: true})); + expect(dialogOpen('edit-dialog')).toBe(false); + rows[1].dispatchEvent(new MouseEvent('dblclick', {bubbles: true})); + expect(dialogOpen('edit-dialog')).toBe(true); + expect(saveCount()).toBe(before); + (document.getElementById('edit-cancel') as HTMLButtonElement).click(); + }); + + test('the edit button follows the statement type when the rows are reused', async () => { + await show(['?odd', 'plain']); + expect(buttonOf(tableRows()[0], 'Edit').hidden).toBe(true); + + await show(['plain', '?odd']); + + expect(buttonOf(tableRows()[0], 'Edit').hidden).toBe(false); + expect(buttonOf(tableRows()[1], 'Edit').hidden).toBe(true); + }); +}); + +describe('how the table shows that changes are not possible', () => { + const table = (): HTMLElement => + document.getElementById('script-table') as HTMLElement; + const recordingFlag = (active: boolean): void => + (Browser.storage.onChanged.addListener as jest.Mock).mock.calls[0][0]({ + zaprecordingactive: {newValue: active}, + }); + + beforeEach(async () => { + stored = ['one', 'two']; + refreshView(); + await flush(); + }); + + test('is not shown while a change is being saved, as it is over too soon to see', async () => { + let release: () => void = () => {}; + holdSave = new Promise((resolve) => { + release = resolve; + }); + + clickRowButton(0, 'Move down'); + await flush(); + + // Busy, so that nothing can be clicked, but nothing changes in how it looks + expect(table().classList.contains('busy')).toBe(true); + expect(table().classList.contains('locked')).toBe(false); + + holdSave = undefined; + release(); + await flush(); + expect(table().classList.contains('busy')).toBe(false); + expect(table().classList.contains('locked')).toBe(false); + }); + + test('is not shown while the script is being loaded', async () => { + let release: () => void = () => {}; + holdLoads.push( + new Promise((resolve) => { + release = resolve; + }) + ); + + refreshView(); + await flush(); + expect(table().classList.contains('busy')).toBe(true); + expect(table().classList.contains('locked')).toBe(false); + + release(); + await flush(); + expect(table().classList.contains('busy')).toBe(false); + }); + + test('is shown for as long as a recording is made', async () => { + recordingFlag(true); + + expect(table().classList.contains('locked')).toBe(true); + expect(table().classList.contains('busy')).toBe(false); + + recordingFlag(false); + await flush(); + expect(table().classList.contains('locked')).toBe(false); + }); + + test('is shown for as long as the script cannot be loaded', async () => { + failLoads = 1; + refreshView(); + await flush(); + + expect(table().classList.contains('locked')).toBe(true); + + refreshView(); + await flush(); + expect(table().classList.contains('locked')).toBe(false); + }); + + test('is shown for as long as a script is being run', async () => { + let finish: (ok: boolean) => void = () => {}; + mockPlay.mockReset(); + mockPlay.mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }) + ); + + (document.getElementById('run-btn') as HTMLButtonElement).click(); + await flush(); + expect(table().classList.contains('locked')).toBe(true); + + finish(true); + await flush(); + expect(table().classList.contains('locked')).toBe(false); + }); +}); + +describe('the style of the buttons when changes are not possible', () => { + function rules(): {selectors: string[]; declarations: string}[] { + const css = Array.from(document.querySelectorAll('style')) + .map((style) => style.textContent ?? '') + .join('\n') + .replace(/@media[^{]*\{[^}]*\{[^}]*\}\s*\}/g, ''); + return css + .split('}') + .filter((rule) => rule.includes('{')) + .map((rule) => { + const [selectors, declarations] = rule.split('{'); + return { + selectors: selectors.split(',').map((sel) => sel.trim()), + declarations: declarations.trim(), + }; + }); + } + + const tableRules = (): ReturnType => + rules().filter((rule) => + rule.selectors.some((sel) => sel.startsWith('#script-table')) + ); + + test('never makes a button show that would be hidden', () => { + const dimming = tableRules().filter((rule) => + /opacity/.test(rule.declarations) + ); + + expect(dimming.length).toBeGreaterThan(0); + dimming.forEach((rule) => + rule.selectors.forEach((sel) => + expect(sel).toMatch(/:(hover|focus-within)/) + ) + ); + }); + + test('stops a button being used, locked or busy', () => { + const blocking = tableRules().filter((rule) => + /pointer-events:\s*none/.test(rule.declarations) + ); + + const selectors = blocking.flatMap((rule) => rule.selectors).join(' '); + expect(selectors).toContain('#script-table.locked td.actions button'); + expect(selectors).toContain('#script-table.busy td.actions button'); + }); + + test('busy has no effect on how the buttons look', () => { + tableRules() + .filter((rule) => rule.selectors.some((sel) => sel.includes('.busy'))) + .forEach((rule) => + expect(rule.declarations).not.toMatch(/opacity|visibility/) + ); + }); +}); + +describe('the focus after using a button of a row', () => { + const rows = (): HTMLTableRowElement[] => + Array.from(document.querySelectorAll('#script-body tr')); + const button = (row: number, title: string): HTMLButtonElement => + rows()[row].querySelector(`button[title="${title}"]`) as HTMLButtonElement; + const focusInTable = (): boolean => + (document.getElementById('script-table') as HTMLElement).contains( + document.activeElement + ); + + // As when the mouse is used, which gives the button the focus before the click + function mouseClick(target: HTMLButtonElement): void { + target.focus(); + target.dispatchEvent( + new MouseEvent('click', {bubbles: true, cancelable: true, detail: 1}) + ); + } + + beforeEach(async () => { + stored = ['one', 'two', 'three']; + refreshView(); + await flush(); + (document.activeElement as HTMLElement | null)?.blur(); + }); + + test('a button that is clicked with the mouse does not keep it', async () => { + mouseClick(button(0, 'Move down')); + await flush(); + + expect(stored).toEqual(['two', 'one', 'three']); + expect(focusInTable()).toBe(false); + }); + + test.each(['Move up', 'Move down'])( + 'moving with %s does not leave it in the table', + async (title) => { + mouseClick(button(1, title)); + await flush(); + + expect(focusInTable()).toBe(false); + } + ); + + test('deleting with the mouse does not leave it in the table', async () => { + mouseClick(button(1, 'Delete')); + (document.getElementById('confirm-ok') as HTMLButtonElement).click(); + await flush(); + + expect(stored).toEqual(['one', 'three']); + expect(focusInTable()).toBe(false); + }); + + test('editing with the mouse does not leave it in the table', async () => { + mouseClick(button(1, 'Edit')); + expect(dialogOpen('edit-dialog')).toBe(true); + (document.getElementById('edit-cancel') as HTMLButtonElement).click(); + + expect(focusInTable()).toBe(false); + }); + + test('a button used with the keyboard keeps it, so it can be used again', async () => { + const down = button(0, 'Move down'); + down.focus(); + + // A click made by the keyboard has no detail + down.click(); + await flush(); + + expect(stored).toEqual(['two', 'one', 'three']); + // The same button, as the rows are kept, and still with the focus + expect(button(0, 'Move down')).toBe(down); + expect(document.activeElement).toBe(down); + + down.click(); + await flush(); + expect(stored).toEqual(['one', 'two', 'three']); + }); + + test('a click on a button does not select its row', async () => { + mouseClick(button(2, 'Move up')); + await flush(); + + // The row that was moved is selected, which is by the move itself + const selected = rows().filter((r) => r.classList.contains('selected')); + expect(selected).toHaveLength(1); + expect(rows().indexOf(selected[0])).toBe(1); + }); +}); diff --git a/test/Background/tabs.test.ts b/test/Background/tabs.test.ts new file mode 100644 index 00000000..e22223ab --- /dev/null +++ b/test/Background/tabs.test.ts @@ -0,0 +1,149 @@ +/* + * Zed Attack Proxy (ZAP) and its related source files. + * + * ZAP is an HTTP/HTTPS proxy for assessing web application security. + * + * Copyright 2026 The ZAP Development Team + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Browser from 'webextension-polyfill'; +import showOrCreateTab, { + openInBackgroundIfClosed, +} from '../../source/utils/tabs'; + +jest.mock('webextension-polyfill', () => ({ + __esModule: true, + default: { + runtime: {getURL: jest.fn((page: string) => `ext://id/${page}`)}, + tabs: {query: jest.fn(), update: jest.fn(), create: jest.fn()}, + windows: {update: jest.fn()}, + }, +})); + +const mocks = Browser as unknown as { + tabs: {query: jest.Mock; update: jest.Mock; create: jest.Mock}; + windows: {update: jest.Mock}; +}; + +beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, 'log').mockImplementation(() => {}); + mocks.tabs.query.mockResolvedValue([]); + mocks.tabs.update.mockImplementation((id: number) => + Promise.resolve({id, windowId: 5}) + ); + mocks.tabs.create.mockResolvedValue({id: 99}); + mocks.windows.update.mockResolvedValue({}); +}); + +test('shows the tab that is already open', async () => { + mocks.tabs.query.mockResolvedValue([{id: 3, windowId: 5}]); + + const result = await showOrCreateTab('script.html'); + + expect(mocks.tabs.query).toHaveBeenCalledWith({url: 'ext://id/script.html'}); + expect(mocks.tabs.update).toHaveBeenCalledWith(3, {active: true}); + expect(mocks.windows.update).toHaveBeenCalledWith(5, {focused: true}); + expect(mocks.tabs.create).not.toHaveBeenCalled(); + expect(result).toEqual({tab: {id: 3, windowId: 5}, existed: true}); +}); + +test('opens a new tab if there is not one', async () => { + const result = await showOrCreateTab('script.html'); + + expect(mocks.tabs.create).toHaveBeenCalledWith({ + url: 'script.html', + active: true, + }); + expect(result).toEqual({tab: {id: 99}, existed: false}); +}); + +test('opens a new tab if the browser cannot look for one', async () => { + mocks.tabs.query.mockRejectedValue(new Error('Invalid url pattern')); + + const result = await showOrCreateTab('script.html'); + + expect(mocks.tabs.create).toHaveBeenCalledTimes(1); + expect(result.existed).toBe(false); +}); + +test('opens a new tab if the existing one cannot be shown', async () => { + mocks.tabs.query.mockResolvedValue([{id: 3, windowId: 5}]); + mocks.tabs.update.mockRejectedValue(new Error('No tab with id: 3')); + + const result = await showOrCreateTab('script.html'); + + expect(mocks.tabs.create).toHaveBeenCalledTimes(1); + expect(result.existed).toBe(false); +}); + +test('a failure to focus the window does not open another tab', async () => { + mocks.tabs.query.mockResolvedValue([{id: 3, windowId: 5}]); + mocks.windows.update.mockRejectedValue(new Error('No windows')); + + const result = await showOrCreateTab('script.html'); + + expect(mocks.tabs.create).not.toHaveBeenCalled(); + expect(result.existed).toBe(true); +}); + +describe('opening a page in the background', () => { + test('opens it behind the current tab if it is not open', async () => { + await openInBackgroundIfClosed('script.html'); + + expect(mocks.tabs.query).toHaveBeenCalledWith({ + url: 'ext://id/script.html', + }); + expect(mocks.tabs.create).toHaveBeenCalledWith({ + url: 'script.html', + active: false, + }); + }); + + test('never switches to a tab', async () => { + await openInBackgroundIfClosed('script.html'); + + expect(mocks.tabs.update).not.toHaveBeenCalled(); + expect(mocks.windows.update).not.toHaveBeenCalled(); + }); + + test('leaves it alone if it is already open', async () => { + mocks.tabs.query.mockResolvedValue([{id: 3, windowId: 5}]); + + await openInBackgroundIfClosed('script.html'); + + expect(mocks.tabs.create).not.toHaveBeenCalled(); + expect(mocks.tabs.update).not.toHaveBeenCalled(); + expect(mocks.windows.update).not.toHaveBeenCalled(); + }); + + test('opens it if the browser cannot look for an open one', async () => { + mocks.tabs.query.mockRejectedValue(new Error('Invalid url pattern')); + + await openInBackgroundIfClosed('script.html'); + + expect(mocks.tabs.create).toHaveBeenCalledWith({ + url: 'script.html', + active: false, + }); + }); + + test('reports a tab that cannot be opened', async () => { + mocks.tabs.create.mockRejectedValue(new Error('No window')); + + await expect(openInBackgroundIfClosed('script.html')).rejects.toThrow( + 'No window' + ); + }); +}); diff --git a/test/Background/util.test.ts b/test/Background/util.test.ts new file mode 100644 index 00000000..fd10f04b --- /dev/null +++ b/test/Background/util.test.ts @@ -0,0 +1,52 @@ +/* + * Zed Attack Proxy (ZAP) and its related source files. + * + * ZAP is an HTTP/HTTPS proxy for assessing web application security. + * + * Copyright 2026 The ZAP Development Team + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import {sleep} from '../../source/utils/util'; + +test('waits for the time given', async () => { + const started = Date.now(); + await sleep(50); + expect(Date.now() - started).toBeGreaterThanOrEqual(40); +}); + +test('stops waiting as soon as the signal is aborted', async () => { + const controller = new AbortController(); + const started = Date.now(); + setTimeout(() => controller.abort(), 20); + + await sleep(5000, controller.signal); + + expect(Date.now() - started).toBeLessThan(1000); +}); + +test('does not wait if the signal has already been aborted', async () => { + const controller = new AbortController(); + controller.abort(); + const started = Date.now(); + + await sleep(5000, controller.signal); + + expect(Date.now() - started).toBeLessThan(1000); +}); + +test('an abort after the wait has ended does nothing', async () => { + const controller = new AbortController(); + await sleep(10, controller.signal); + expect(() => controller.abort()).not.toThrow(); +}); diff --git a/test/Background/zestEdit.test.ts b/test/Background/zestEdit.test.ts new file mode 100644 index 00000000..ffe28357 --- /dev/null +++ b/test/Background/zestEdit.test.ts @@ -0,0 +1,267 @@ +/* + * Zed Attack Proxy (ZAP) and its related source files. + * + * ZAP is an HTTP/HTTPS proxy for assessing web application security. + * + * Copyright 2026 The ZAP Development Team + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + applyFieldValues, + getStatementType, + isReplayableUrl, + LOCATOR_TYPES, + moveItem, + newStatement, + parseZestScript, + toFieldValues, + StatementTypeDef, +} from '../../source/utils/zestEdit'; + +function typeDef(elementType: string): StatementTypeDef { + const def = getStatementType(elementType); + if (!def) { + throw new Error(`No such statement type: ${elementType}`); + } + return def; +} + +test('new statements have defaults', () => { + expect(newStatement('ZestClientElementClick')).toEqual({ + windowHandle: 'windowHandle1', + type: 'id', + element: '', + waitForMsec: 0, + index: 0, + enabled: true, + elementType: 'ZestClientElementClick', + }); +}); + +test('applies values, keeping hidden fields', () => { + const def = typeDef('ZestClientElementSendKeys'); + const stmt = {...newStatement('ZestClientElementSendKeys'), index: 3}; + const values = toFieldValues(def, stmt); + values.element = 'user'; + values.value = 'bob'; + values.waitForMsec = '25'; + values.enabled = false; + const {statement, errors} = applyFieldValues(def, stmt, values); + expect(errors).toEqual([]); + expect(statement).toMatchObject({ + index: 3, + windowHandle: 'windowHandle1', + element: 'user', + value: 'bob', + waitForMsec: 25, + enabled: false, + }); +}); + +test('reports validation errors', () => { + const def = typeDef('ZestClientElementClick'); + const stmt = newStatement('ZestClientElementClick'); + const values = toFieldValues(def, stmt); + values.waitForMsec = 'abc'; + const {statement, errors} = applyFieldValues(def, stmt, values); + expect(statement).toBeUndefined(); + expect(errors).toEqual([ + 'Locator is required', + 'Wait for (ms) must be a whole number', + ]); +}); + +test('frame index -1 means parent', () => { + const def = typeDef('ZestClientSwitchToFrame'); + const stmt = newStatement('ZestClientSwitchToFrame'); + const values = toFieldValues(def, stmt); + values.frameIndex = '-1'; + expect(applyFieldValues(def, stmt, values).statement?.parent).toBe(true); +}); + +test('moves items', () => { + expect(moveItem([1, 2, 3], 0, 1)).toEqual([2, 1, 3]); + expect(moveItem([1, 2, 3], 2, 0)).toEqual([3, 1, 2]); + expect(moveItem([1, 2, 3], 0, -1)).toEqual([1, 2, 3]); +}); + +test('parses a Zest script', () => { + const text = JSON.stringify({ + title: 'My script', + statements: [{elementType: 'ZestComment', comment: 'hi'}], + }); + expect(parseZestScript(text)).toEqual({ + title: 'My script', + statements: [{elementType: 'ZestComment', comment: 'hi'}], + }); +}); + +test('rejects things that are not Zest scripts', () => { + expect(() => parseZestScript('nope')).toThrow('not valid JSON'); + expect(() => parseZestScript('{}')).toThrow('not a Zest script'); + expect(() => parseZestScript('{"statements": [1]}')).toThrow( + 'not a Zest script' + ); + expect(() => parseZestScript('null')).toThrow('not a Zest script'); +}); + +test('only http and https URLs can be launched', () => { + expect(isReplayableUrl('https://example.com/a?b=c')).toBe(true); + expect(isReplayableUrl('http://localhost:8080')).toBe(true); + // eslint-disable-next-line no-script-url + expect(isReplayableUrl('javascript:alert(1)')).toBe(false); + expect(isReplayableUrl('file:///etc/passwd')).toBe(false); + expect(isReplayableUrl('example.com')).toBe(false); +}); + +test('launch URL is validated when editing', () => { + const def = typeDef('ZestClientLaunch'); + const stmt = newStatement('ZestClientLaunch'); + const values = toFieldValues(def, stmt); + values.url = 'file:///tmp/a.html'; + expect(applyFieldValues(def, stmt, values).errors).toEqual([ + 'URL must be a valid http or https URL', + ]); + values.url = 'https://example.com'; + expect(applyFieldValues(def, stmt, values).errors).toEqual([]); +}); + +describe('script settings that are not kept', () => { + const script = (extra: Record): string => + JSON.stringify({ + title: 'T', + statements: [{elementType: 'ZestComment', comment: 'hi'}], + ...extra, + }); + + test('the defaults are accepted', () => { + expect(() => + parseZestScript( + script({ + description: '', + prefix: '', + type: 'StandAlone', + parameters: {tokenStart: '{{', tokenEnd: '}}', tokens: {}}, + authentication: [], + options: {statementDelay: '500'}, + }) + ) + ).not.toThrow(); + }); + + test.each([ + ['a type other than standalone', {type: 'Targeted'}, 'script type'], + ['a description', {description: 'About'}, 'description'], + ['a prefix', {prefix: 'https://example.com'}, 'prefix'], + [ + 'token definitions', + {parameters: {tokenStart: '{{', tokenEnd: '}}', tokens: {a: 'b'}}}, + 'parameters', + ], + [ + 'other token delimiters', + {parameters: {tokenStart: '<<', tokenEnd: '>>', tokens: {}}}, + 'parameters', + ], + ['authentication', {authentication: [{a: 1}]}, 'authentication'], + ])('rejects %s', (_name, extra, feature) => { + expect(() => parseZestScript(script(extra))).toThrow( + `The script uses features that cannot be preserved (${feature})` + ); + }); + + test('lists everything that cannot be kept', () => { + expect(() => + parseZestScript(script({description: 'About', authentication: [{a: 1}]})) + ).toThrow('(description, authentication)'); + }); +}); + +describe('line breaks', () => { + test.each(['ZestComment', 'ZestClientElementSendKeys'])( + 'are kept when editing a %s', + (elementType) => { + const def = typeDef(elementType); + const field = elementType === 'ZestComment' ? 'comment' : 'value'; + const stmt = { + ...newStatement(elementType), + [field]: 'line 1\nline 2\r\nline 3', + }; + const values = toFieldValues(def, stmt); + // The locator of an element statement is required + if ('element' in stmt) { + values.element = 'target'; + } + values.enabled = false; + + const result = applyFieldValues(def, stmt, values); + + expect(result.statement?.[field]).toBe('line 1\nline 2\r\nline 3'); + expect(result.statement?.enabled).toBe(false); + } + ); + + test('the fields that can contain them are multiline', () => { + const kinds = (elementType: string): Record => + Object.fromEntries( + typeDef(elementType).fields.map((f) => [f.key, f.kind]) + ); + expect(kinds('ZestComment').comment).toBe('multiline'); + expect(kinds('ZestClientElementSendKeys').value).toBe('multiline'); + expect(kinds('ZestClientLaunch').url).toBe('text'); + }); +}); + +describe('the locator types', () => { + test('include partialLinkText, next to linkText', () => { + expect(LOCATOR_TYPES).toContain('partialLinkText'); + expect(LOCATOR_TYPES.indexOf('partialLinkText')).toBe( + LOCATOR_TYPES.indexOf('linkText') + 1 + ); + }); + + test('are all different', () => { + expect(new Set(LOCATOR_TYPES).size).toBe(LOCATOR_TYPES.length); + }); + + test.each(['ZestClientElementClick', 'ZestClientElementSendKeys'])( + 'a %s can use partialLinkText', + (elementType) => { + const def = typeDef(elementType); + const stmt = {...newStatement(elementType), type: 'partialLinkText'}; + const values = toFieldValues(def, stmt); + values.element = 'Sign in'; + + const result = applyFieldValues(def, stmt, values); + + expect(result.errors).toEqual([]); + expect(result.statement).toMatchObject({ + type: 'partialLinkText', + element: 'Sign in', + }); + } + ); + + test('a locator is still needed with partialLinkText', () => { + const def = typeDef('ZestClientElementClick'); + const stmt = { + ...newStatement('ZestClientElementClick'), + type: 'partialLinkText', + }; + + expect( + applyFieldValues(def, stmt, toFieldValues(def, stmt)).errors + ).toEqual(['Locator is required']); + }); +}); diff --git a/test/Background/zestScript.test.ts b/test/Background/zestScript.test.ts new file mode 100644 index 00000000..ceca4244 --- /dev/null +++ b/test/Background/zestScript.test.ts @@ -0,0 +1,127 @@ +/* + * Zed Attack Proxy (ZAP) and its related source files. + * + * ZAP is an HTTP/HTTPS proxy for assessing web application security. + * + * Copyright 2026 The ZAP Development Team + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import Browser from 'webextension-polyfill'; +import {ZestScript} from '../../source/types/zestScript/ZestScript'; + +jest.mock('webextension-polyfill', () => ({ + __esModule: true, + default: {storage: {sync: {get: jest.fn()}}}, +})); + +function replace(script: ZestScript, statements: unknown): void { + const prepared = script.prepareStatements(statements); + script.restoreStatements(prepared.statements, prepared.curIndex); +} + +test('preparing replacement statements does not change the script', () => { + const script = new ZestScript(); + script.addStatement(JSON.stringify({elementType: 'ZestComment'})); + + const prepared = script.prepareStatements([ + {elementType: 'ZestComment'}, + {elementType: 'ZestComment'}, + ]); + + expect(prepared.statements).toHaveLength(2); + expect(prepared.curIndex).toBe(3); + expect(script.getZestStatementCount()).toBe(1); + expect(script.getCurIndex()).toBe(2); +}); + +test('replaces and renumbers the statements', () => { + const script = new ZestScript(); + script.addStatement(JSON.stringify({elementType: 'ZestComment'})); + + replace(script, [ + {index: 7, elementType: 'ZestComment', comment: 'a'}, + {elementType: 'ZestComment', comment: 'b', extra: true}, + ]); + + expect(script.getStatements().map((s) => JSON.parse(s))).toEqual([ + {index: 1, elementType: 'ZestComment', comment: 'a'}, + {index: 2, elementType: 'ZestComment', comment: 'b', extra: true}, + ]); + expect(script.getCurIndex()).toBe(3); + expect(script.getZestStatementCount()).toBe(2); +}); + +test('statements added afterwards continue the numbering', () => { + const script = new ZestScript(); + replace(script, [{elementType: 'ZestComment'}]); + + const added = JSON.parse( + script.addStatement(JSON.stringify({elementType: 'ZestComment'})) + ); + + expect(added.index).toBe(2); +}); + +test('replacing with an empty list clears the script', () => { + const script = new ZestScript(); + script.addStatement(JSON.stringify({elementType: 'ZestComment'})); + + replace(script, []); + + expect(script.getZestStatementCount()).toBe(0); + expect(script.getCurIndex()).toBe(1); +}); + +test.each([ + ['not a list', 'nope'], + ['missing', undefined], + ['null', null], + ['an object', {a: 1}], + ['a list of non-objects', [1, 2]], + ['a list containing null', [{a: 1}, null]], + ['a list containing a list', [[1]]], +])('rejects statements that are %s', (_name, statements) => { + const script = new ZestScript(); + script.addStatement(JSON.stringify({elementType: 'ZestComment'})); + + expect(() => script.prepareStatements(statements)).toThrow( + 'The statements must be a list of objects' + ); + // and leaves the script as it was + expect(script.getZestStatementCount()).toBe(1); +}); + +describe('getting the script', () => { + const get = Browser.storage.sync.get as jest.Mock; + + test('returns the script with the stored name and delay', async () => { + get.mockResolvedValueOnce({zapscriptname: 'Mine', zapstatementdelay: 250}); + const script = new ZestScript(); + script.addStatement(JSON.stringify({elementType: 'ZestComment'})); + + const msg = await script.getZestScript(); + + expect(msg.title).toBe('Mine'); + expect(msg.statementCount).toBe(1); + expect(JSON.parse(msg.script).options).toEqual({statementDelay: '250'}); + }); + + test('fails if the settings cannot be read, rather than never finishing', async () => { + get.mockRejectedValueOnce(new Error('storage unavailable')); + + await expect(new ZestScript().getZestScript()).rejects.toThrow( + 'storage unavailable' + ); + }); +}); diff --git a/test/Background/zestSummary.test.ts b/test/Background/zestSummary.test.ts new file mode 100644 index 00000000..f5579bfc --- /dev/null +++ b/test/Background/zestSummary.test.ts @@ -0,0 +1,85 @@ +/* + * Zed Attack Proxy (ZAP) and its related source files. + * + * ZAP is an HTTP/HTTPS proxy for assessing web application security. + * + * Copyright 2026 The ZAP Development Team + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import {summarizeZestScript} from '../../source/utils/zestSummary'; + +test('summarizes recorded statements', () => { + const script = JSON.stringify({ + statements: [ + { + index: 1, + elementType: 'ZestClientLaunch', + browserType: 'Chrome', + url: 'http://a.com', + }, + { + index: 2, + elementType: 'ZestClientElementSendKeys', + type: 'id', + element: 'user', + value: 'bob', + }, + { + index: 3, + elementType: 'ZestClientElementClick', + type: 'cssSelector', + element: '.go', + enabled: false, + }, + {index: 4, elementType: 'ZestClientSwitchToFrame', parent: true}, + { + index: 5, + elementType: 'ZestClientWindowClose', + windowHandle: 'windowHandle1', + }, + ], + }); + expect(summarizeZestScript(script)).toEqual([ + {index: 1, enabled: true, summary: 'Launch Chrome http://a.com'}, + {index: 2, enabled: true, summary: 'Send keys "bob" to element id: user'}, + {index: 3, enabled: false, summary: 'Click element cssSelector: .go'}, + {index: 4, enabled: true, summary: 'Switch to frame parent'}, + {index: 5, enabled: true, summary: 'Close window windowHandle1'}, + ]); +}); + +test('handles empty script', () => { + expect(summarizeZestScript('{}')).toEqual([]); +}); + +test('summarizes a statement that finds a link by part of its text', () => { + const script = JSON.stringify({ + statements: [ + { + index: 1, + elementType: 'ZestClientElementClick', + type: 'partialLinkText', + element: 'Sign in', + }, + ], + }); + + expect(summarizeZestScript(script)).toEqual([ + { + index: 1, + enabled: true, + summary: 'Click element partialLinkText: Sign in', + }, + ]); +}); diff --git a/test/drivers/BaseDriver.ts b/test/drivers/BaseDriver.ts index 7f122d8e..10f72a1e 100644 --- a/test/drivers/BaseDriver.ts +++ b/test/drivers/BaseDriver.ts @@ -105,12 +105,25 @@ abstract class BaseDriver { await this.selectLatestWindow(wd); } - private waitForExtensionTabClosed(): () => Promise { - return async () => { - const wd = await this.getWebDriver(); - const handles = await wd.getAllWindowHandles(); - return handles.length === 2; - }; + /** + * Waits for the popup tab to close and returns the handle of the tab that was + * opened for the login URL. The script viewer is also opened (in the + * background) when recording starts, so the tab cannot be picked by position. + */ + private async selectLoginUrlTab(wd: WebDriver): Promise { + await wd.wait(async () => { + for (const handle of await wd.getAllWindowHandles()) { + try { + await wd.switchTo().window(handle); + if ((await wd.getCurrentUrl()).startsWith('http://localhost')) { + return true; + } + } catch { + // The popup tab closed while looking, so try again + } + } + return false; + }); } public async toggleRecording(loginUrl = ''): Promise { @@ -122,9 +135,7 @@ abstract class BaseDriver { } await wd.findElement(By.id('record-btn')).click(); if (loginUrl !== '') { - await wd.wait(this.waitForExtensionTabClosed()); - const handles = await wd.getAllWindowHandles(); - await wd.switchTo().window(handles[1]); + await this.selectLoginUrlTab(wd); } else { await this.selectLatestWindow(wd); } diff --git a/views/help.html b/views/help.html index 3bf7b458..05c0e0c0 100644 --- a/views/help.html +++ b/views/help.html @@ -20,6 +20,8 @@

Summary

You can also stop the recording via the extension dialog and then download the recording using a filename you specify.

+ You can view, edit, replay, load and save the recorded script using the Script Viewer, as detailed below. +

If you are using the full ZAP extension and start recording in ZAP then the Zest script will be automatically updated in the ZAP Script Console so you do not need to download it manually. @@ -66,9 +68,12 @@

Download Script Button

Clicking on this button will download any recorded script using the name in the above field. You must have recorded something and specified a script name. +

View Script Button

+ Opens the Script Viewer in a new tab, as detailed below. +

Config Button

- Shows the extension configuration screen. This is only available in the full ZAP extension, the recorder extension - does not have this button or any configuration options. + Shows the extension configuration screen. + In the full ZAP extension it also has the options for how to reach ZAP, which the recorder extension does not have.

Configuration Options

The configuration screen (accessible via the Config Button) has the following options: @@ -85,6 +90,72 @@

Statement Delay

Help Button

Shows this screen. +

Script Viewer

+ The Script Viewer shows the script you have recorded, one line per Zest statement, in a similar way to ZAP. + Open it via the View Script Button in the Recorder Dialog. + If the Script Viewer is already open then the button switches to that tab and reloads the script, + which clears the results of any previous run. + The recorded script can be changed, replayed, loaded and saved from here. +

+ When you start recording from the Recorder Dialog the Script Viewer is opened in a tab behind the current one, unless it is already open. + You stay on the page that you are recording, and can switch to the Script Viewer whenever you want to see the script. + While a script is being recorded the Script Viewer shows each statement as it is recorded, and says that it is recording. + It scrolls to follow the latest statement, unless you have scrolled up to look at something else. + Statements cannot be changed or replayed while you are recording or replaying a script. + Stop recording first. + +

Toolbar

+ New - discards the current script and its name, after asking for confirmation.
+ Load... - replaces the current script with a Zest script (.zst or .json) from your filestore. + The statement delay stored in the file is not used, that is set via the configuration options. + Scripts which use settings that the extension cannot keep are not loaded, so that they are not changed without you knowing. + These are token definitions (parameters), authentication, a description, a prefix and a script type other than StandAlone.
+ Save - downloads the current script, including any changes you have made, as a .zst file. + The file is named after the script name, or "script.zst" if it has no name.
+ Refresh - reloads the script, e.g. after recording something new.
+ Run - replays the script, see below.
+ Undo - reverses the last change you made to the script, and can be used repeatedly.
+ Redo - puts back a change that you have undone. It can be used repeatedly, until you make another change.
+ Add - adds a new statement of the type chosen in the list next to it. + It is added after the selected row (click a row to select it) or at the end if no row is selected. + +

Editing

+ Hover over a row to show its buttons:
+ ▲ ▼ - move the statement up or down.
+ ✎ - edit the statement. Double clicking on the row does the same. + This is available for the statement types Launch, Click, Send keys, Clear, Submit, Scroll to, + Mouse over, Switch to frame, Close window and Comment. + Unticking Enabled keeps the statement in the script but skips it when the script is run.
+ ✕ - delete the statement, after asking for confirmation. +

+ Changes are saved as soon as you make them. + When you add or edit a statement you can choose how an element is found using a locator type + (id, cssSelector, xpath, className, name, linkText, partialLinkText or tagName) and a locator. + A partialLinkText locator finds the first link that has the text in it, while a linkText locator finds a link that has exactly that text. + +

Running a Script

+ Choose "in New Window" or "in New Tab" next to the Run button, then click Run. + The first Launch statement opens the new window or tab and the other statements are run in it. + Each row shows its result as the script runs, and the script stops at the first statement that fails. + A box is shown on the page being replayed which shows the statement being run. + You can drag it out of the way and it will not affect the script. +

+ Comments, Close window statements and disabled statements are not run. + Click, Send keys and Clear statements wait for their element to be enabled, and fail if it stays disabled. + Switching to a frame only works for frames from the same site as the page. + If the statement has a frame name then the frame with that name or id is used, otherwise the frame index is used. + The Statement Delay configuration option is used between statements, if it is set. +

+ Clicking Stop cancels the script, ending any wait straight away. + +

Stepping Through a Script

+ If you tick "Step through" before clicking Run then the first statement is run and the script then pauses. + The box on the page shows the next statement with Next and Cancel buttons. + The statement is only run when you click Next, and Cancel stops the script. + The Script Viewer also has Next and Stop buttons while the script is paused, which can be used if you have moved + to a page that is not showing the box. + The Statement Delay is not used when stepping through a script. +

Notification Panel

When you are recording a script then a notification panel is shown on the current browser tab. The panel will display the Zest elements recorded and then revert to showing a button: "Stop and Download Recording". diff --git a/views/options.html b/views/options.html index 75c13cf4..64214f19 100644 --- a/views/options.html +++ b/views/options.html @@ -7,23 +7,25 @@ -

ZAP by Checkmarx Browser Extension

- This extension gives ZAP access to the browser. +

ZAP by Checkmarx Browser Extension

+
This extension gives ZAP access to the browser.
+
These options should be set correctly if the browser is launched from ZAP but may need to be set manually if you install this add-on in a browser not launched by ZAP. +
- + - + - + diff --git a/views/popup.html b/views/popup.html index 58f8488b..1d62f50b 100644 --- a/views/popup.html +++ b/views/popup.html @@ -37,6 +37,12 @@

+ +
diff --git a/views/script.html b/views/script.html new file mode 100644 index 00000000..99d7e2ce --- /dev/null +++ b/views/script.html @@ -0,0 +1,148 @@ + + + + + + ZAP Recorded Script + + + + + +

+
+
+ + + + + + + + + + +
+
+ + +
+
+ + +
+

For example http://zap/ or http://localhost:8080/
As set in the ZAP API options
+ + + + + + + + + + + + +

+
+
+ + + + + +
+ +

+

+

+ + + + +
+ + diff --git a/webpack.config.js b/webpack.config.js index d0fd91ef..4660730d 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -63,6 +63,7 @@ module.exports = { contentScript: path.join(sourcePath, 'ContentScript', 'index.ts'), options: path.join(sourcePath, 'Options', 'index.tsx'), popup: path.join(sourcePath, 'Popup', 'index.tsx'), + script: path.join(sourcePath, 'ScriptView', 'index.ts'), }, output: { @@ -165,6 +166,13 @@ module.exports = { hash: true, filename: 'help.html', }), + new HtmlWebpackPlugin({ + template: path.join(viewsPath, 'script.html'), + inject: 'body', + chunks: ['script'], + hash: true, + filename: 'script.html', + }), new HtmlWebpackPlugin({ template: path.join(viewsPath, 'popup.html'), inject: 'body', diff --git a/webpack.config.rec.js b/webpack.config.rec.js index 83c4d548..813d7fbb 100644 --- a/webpack.config.rec.js +++ b/webpack.config.rec.js @@ -60,7 +60,9 @@ module.exports = { manifest: path.join(sourcePath, 'manifest.rec.json'), background: path.join(sourcePath, 'Background', 'index.ts'), contentScript: path.join(sourcePath, 'ContentScript', 'index.ts'), + options: path.join(sourcePath, 'Options', 'index.tsx'), popup: path.join(sourcePath, 'Popup', 'index.tsx'), + script: path.join(sourcePath, 'ScriptView', 'index.ts'), }, output: { @@ -149,6 +151,13 @@ module.exports = { cleanStaleWebpackAssets: false, verbose: true, }), + new HtmlWebpackPlugin({ + template: path.join(viewsPath, 'options.html'), + inject: 'body', + chunks: ['options'], + hash: true, + filename: 'options.html', + }), new HtmlWebpackPlugin({ template: path.join(viewsPath, 'help.html'), inject: 'body', @@ -156,6 +165,13 @@ module.exports = { hash: true, filename: 'help.html', }), + new HtmlWebpackPlugin({ + template: path.join(viewsPath, 'script.html'), + inject: 'body', + chunks: ['script'], + hash: true, + filename: 'script.html', + }), new HtmlWebpackPlugin({ template: path.join(viewsPath, 'popup.html'), inject: 'body',