diff --git a/CHANGELOG.md b/CHANGELOG.md index 43c9450..7137783 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this add-on will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased +### Added +- Support for Enter key in input fields. + ### Changed - Branding to ZAP by Checkmarx. diff --git a/source/ContentScript/recorder.ts b/source/ContentScript/recorder.ts index f641070..7d19694 100644 --- a/source/ContentScript/recorder.ts +++ b/source/ContentScript/recorder.ts @@ -23,6 +23,7 @@ import { ZestStatement, ZestStatementElementClick, ZestStatementElementSendKeys, + ZestStatementElementSubmit, ZestStatementLaunchBrowser, ZestStatementSwitchToFrame, } from '../types/zestScript/ZestStatement'; @@ -44,7 +45,20 @@ class Recorder { isNotificationRaised = false; - async sendZestScriptToZAP(zestStatement: ZestStatement): Promise { + // Enter keydown events often occur before the input change events, so we reorder them if needed + cachedSubmit?: ZestStatementElementSubmit; + + // We can get duplicate events for the enter key, this allows us to dedup them + cachedTimeStamp = -1; + + async sendZestScriptToZAP( + zestStatement: ZestStatement, + sendCache: boolean + ): Promise { + if (sendCache) { + this.handleCachedSubmit(); + } + // console.log('Sending statement', zestStatement); this.notify(zestStatement); return Browser.runtime.sendMessage({ type: ZEST_SCRIPT, @@ -52,20 +66,32 @@ class Recorder { }); } + handleCachedSubmit(): void { + if (this.cachedSubmit) { + // console.log('Sending cached submit', this.cachedSubmit); + this.sendZestScriptToZAP(this.cachedSubmit, false); + delete this.cachedSubmit; + this.cachedTimeStamp = -1; + } + } + handleFrameSwitches(level: number, frameIndex: number): void { if (this.curLevel === level && this.curFrame === frameIndex) { return; } if (this.curLevel > level) { while (this.curLevel > level) { - this.sendZestScriptToZAP(new ZestStatementSwitchToFrame(-1)); + this.sendZestScriptToZAP(new ZestStatementSwitchToFrame(-1), true); this.curLevel -= 1; } this.curFrame = frameIndex; } else { this.curLevel += 1; this.curFrame = frameIndex; - this.sendZestScriptToZAP(new ZestStatementSwitchToFrame(frameIndex)); + this.sendZestScriptToZAP( + new ZestStatementSwitchToFrame(frameIndex), + true + ); } if (this.curLevel !== level) { console.log('Error in switching frames'); @@ -81,7 +107,10 @@ class Recorder { this.handleFrameSwitches(level, frame); console.log(event, 'clicked'); const elementLocator = getPath(event.target as HTMLElement, element); - this.sendZestScriptToZAP(new ZestStatementElementClick(elementLocator)); + this.sendZestScriptToZAP( + new ZestStatementElementClick(elementLocator), + true + ); // click on target element } @@ -118,12 +147,44 @@ class Recorder { this.handleFrameSwitches(level, frame); console.log(event, 'change', (event.target as HTMLInputElement).value); const elementLocator = getPath(event.target as HTMLElement, element); + // Send the keys before a cached submit statement on the same element + if ( + this.cachedSubmit && + this.cachedSubmit.elementLocator.element !== elementLocator.element + ) { + // The cached submit was not on the same element, so send it + this.handleCachedSubmit(); + } this.sendZestScriptToZAP( new ZestStatementElementSendKeys( elementLocator, (event.target as HTMLInputElement).value - ) + ), + false ); + // Now send the cached submit, if there still is one + this.handleCachedSubmit(); + } + + handleKeypress( + params: {level: number; frame: number; element: Document}, + event: KeyboardEvent + ): void { + if (!this.shouldRecord(event.target as HTMLElement)) return; + const {element} = params; + if (event.key === 'Enter') { + if (this.cachedSubmit && this.cachedTimeStamp === event.timeStamp) { + // console.log('Ignoring dup Enter event', this.cachedSubmit); + return; + } + this.handleCachedSubmit(); + const elementLocator = getPath(event.target as HTMLElement, element); + // console.log('Enter key pressed', elementLocator, event.timeStamp); + // Cache the statement as it often occurs before the change event occurs + this.cachedSubmit = new ZestStatementElementSubmit(elementLocator); + this.cachedTimeStamp = event.timeStamp; + // console.log('Caching submit', this.cachedSubmit); + } } handleResize(): void { @@ -145,6 +206,9 @@ class Recorder { level: number, frame: number ): void { + // A list of all of the text elements that we have added event listeners to + const textElements = new Set(); + element.addEventListener( 'click', this.handleClick.bind(this, {level, frame, element}) @@ -173,6 +237,45 @@ class Recorder { i += 1; } }); + + // Add listeners to all of the text fields + element.querySelectorAll('input').forEach((input) => { + if (!textElements.has(input)) { + textElements.add(input); + input.addEventListener( + 'keydown', + this.handleKeypress.bind(this, {level, frame, element}) + ); + } + }); + // Observer callback function to handle DOM mutations to detect added text fields + const domMutated: MutationCallback = (mutationsList: MutationRecord[]) => { + mutationsList.forEach((mutation) => { + if (mutation.type === 'childList') { + // Look for added input elements + if (mutation.target instanceof Element) { + const inputs = mutation.target.getElementsByTagName('input'); + for (let j = 0; j < inputs.length; j += 1) { + const input = inputs[j]; + if (!textElements.has(input)) { + textElements.add(input); + input.addEventListener( + 'keydown', + this.handleKeypress.bind(this, {level, frame, element}) + ); + } + } + } + } + }); + }; + + const observer = new MutationObserver(domMutated); + observer.observe(document, { + attributes: false, + childList: true, + subtree: true, + }); } shouldRecord(element: HTMLElement): boolean { @@ -196,7 +299,10 @@ class Recorder { // send window resize event to ensure same size const browserType = this.getBrowserName(); const url = window.location.href; - this.sendZestScriptToZAP(new ZestStatementLaunchBrowser(browserType, url)); + this.sendZestScriptToZAP( + new ZestStatementLaunchBrowser(browserType, url), + true + ); this.handleResize(); } @@ -221,6 +327,7 @@ class Recorder { stopRecordingUserInteractions(): void { console.log('Stopping Recording User Interactions ...'); + this.handleCachedSubmit(); Browser.storage.sync.set({zaprecordingactive: false}); this.active = false; const floatingDiv = document.getElementById('ZapfloatingDiv'); @@ -367,6 +474,9 @@ class Recorder { } else if (stmt instanceof ZestStatementElementSendKeys) { notifyMessage.title = 'Send Keys'; notifyMessage.message = `${stmt.elementLocator.element}: ${stmt.keys}`; + } else if (stmt instanceof ZestStatementElementSubmit) { + notifyMessage.title = 'Submit'; + notifyMessage.message = `${stmt.elementLocator.element}`; } else if (stmt instanceof ZestStatementLaunchBrowser) { notifyMessage.title = 'Launch Browser'; notifyMessage.message = stmt.browserType; diff --git a/source/types/zestScript/ZestStatement.ts b/source/types/zestScript/ZestStatement.ts index f9b3355..7597563 100644 --- a/source/types/zestScript/ZestStatement.ts +++ b/source/types/zestScript/ZestStatement.ts @@ -23,9 +23,11 @@ import { ZEST_CLIENT_ELEMENT_CLICK, ZEST_CLIENT_ELEMENT_MOUSE_OVER, ZEST_CLIENT_ELEMENT_SEND_KEYS, + ZEST_CLIENT_ELEMENT_SUBMIT, ZEST_CLIENT_LAUNCH, ZEST_CLIENT_SWITCH_TO_FRAME, ZEST_CLIENT_WINDOW_CLOSE, + ZEST_COMMENT, } from '../../utils/constants'; class ElementLocator { @@ -91,6 +93,26 @@ class ZestStatementLaunchBrowser extends ZestStatement { } } +class ZestComment extends ZestStatement { + comment: string; + + windowHandle: string; + + constructor(comment: string) { + super(ZEST_COMMENT); + this.comment = comment; + } + + toJSON(): string { + return JSON.stringify({ + index: this.index, + enabled: true, + elementType: this.elementType, + comment: this.comment, + }); + } +} + abstract class ZestStatementElement extends ZestStatement { elementLocator: ElementLocator; @@ -147,6 +169,29 @@ class ZestStatementElementSendKeys extends ZestStatementElement { } } +class ZestStatementElementSubmit extends ZestStatementElement { + keys: string; + + constructor( + elementLocator: ElementLocator, + windowHandle = DEFAULT_WINDOW_HANDLE + ) { + super(ZEST_CLIENT_ELEMENT_SUBMIT, elementLocator); + this.windowHandle = windowHandle; + } + + toJSON(): string { + return JSON.stringify({ + value: this.keys, + windowHandle: this.windowHandle, + ...this.elementLocator.toJSON(), + index: this.index, + enabled: true, + elementType: this.elementType, + }); + } +} + class ZestStatementElementClear extends ZestStatementElement { constructor( elementLocator: ElementLocator, @@ -234,12 +279,14 @@ class ZestStatementElementMouseOver extends ZestStatementElement { export { ElementLocator, + ZestComment, ZestStatement, ZestStatementLaunchBrowser, ZestStatementElementMouseOver, ZestStatementElementClick, ZestStatementSwitchToFrame, ZestStatementElementSendKeys, + ZestStatementElementSubmit, ZestStatementElementClear, ZestStatementWindowClose, }; diff --git a/source/utils/constants.ts b/source/utils/constants.ts index 9955504..0135018 100644 --- a/source/utils/constants.ts +++ b/source/utils/constants.ts @@ -20,10 +20,12 @@ export const ZEST_CLIENT_SWITCH_TO_FRAME = 'ZestClientSwitchToFrame'; export const ZEST_CLIENT_ELEMENT_CLICK = 'ZestClientElementClick'; export const ZEST_CLIENT_ELEMENT_SEND_KEYS = 'ZestClientElementSendKeys'; +export const ZEST_CLIENT_ELEMENT_SUBMIT = 'ZestClientElementSubmit'; export const ZEST_CLIENT_LAUNCH = 'ZestClientLaunch'; export const ZEST_CLIENT_ELEMENT_CLEAR = 'ZestClientElementClear'; export const ZEST_CLIENT_WINDOW_CLOSE = 'ZestClientWindowClose'; export const ZEST_CLIENT_ELEMENT_MOUSE_OVER = 'ZestClientMouseOverElement'; +export const ZEST_COMMENT = 'ZestComment'; export const DEFAULT_WINDOW_HANDLE = 'windowHandle1'; export const ZAP_STOP_RECORDING = 'zapStopRecording'; diff --git a/test/ContentScript/integrationTests.test.ts b/test/ContentScript/integrationTests.test.ts index e03f8d8..fed972b 100644 --- a/test/ContentScript/integrationTests.test.ts +++ b/test/ContentScript/integrationTests.test.ts @@ -127,6 +127,28 @@ function integrationTests( expect(JSON.stringify(Array.from(actualData))).toBe(expectedData); }); + test('Should record return key', async () => { + // Given / When + server = getFakeZapServer(actualData, _JSONPORT); + const context = await driver.getContext(_JSONPORT, true); + await driver.setEnable(false); + const page = await context.newPage(); + await page.goto( + `http://localhost:${_HTTPPORT}/webpages/interactions.html` + ); + await page.type('#input-1', 'testinput'); + await page.keyboard.press('Enter'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(1000); + await page.close(); + // Then + const expectedData = + '["{\\"action\\":{\\"action\\":\\"reportZestStatement\\"},\\"body\\":{\\"statementJson\\":\\"{\\"windowHandle\\":\\"windowHandle1\\",\\"type\\":\\"id\\",\\"element\\":\\"input-1\\",\\"index\\":1,\\"enabled\\":true,\\"elementType\\":\\"ZestClientElementClear\\"}\\",\\"apikey\\":\\"not set\\"}}",' + + '"{\\"action\\":{\\"action\\":\\"reportZestStatement\\"},\\"body\\":{\\"statementJson\\":\\"{\\"value\\":\\"testinput\\",\\"windowHandle\\":\\"windowHandle1\\",\\"type\\":\\"id\\",\\"element\\":\\"input-1\\",\\"index\\":2,\\"enabled\\":true,\\"elementType\\":\\"ZestClientElementSendKeys\\"}\\",\\"apikey\\":\\"not set\\"}}",' + + '"{\\"action\\":{\\"action\\":\\"reportZestStatement\\"},\\"body\\":{\\"statementJson\\":\\"{\\"windowHandle\\":\\"windowHandle1\\",\\"type\\":\\"id\\",\\"element\\":\\"input-1\\",\\"index\\":3,\\"enabled\\":true,\\"elementType\\":\\"ZestClientElementSubmit\\"}\\",\\"apikey\\":\\"not set\\"}}"]'; + expect(JSON.stringify(Array.from(actualData))).toBe(expectedData); + }); + test('Should stop recording', async () => { // Given / When server = getFakeZapServer(actualData, _JSONPORT);