From ab2ddb8e03d26f688e06969ad334f8012fb78676 Mon Sep 17 00:00:00 2001 From: Simon Bennetts Date: Wed, 27 Nov 2024 17:13:12 +0000 Subject: [PATCH] Record enter key presses in input fields Signed-off-by: Simon Bennetts --- CHANGELOG.md | 3 + source/ContentScript/recorder.ts | 114 +++++++++++++++++++- source/types/zestScript/ZestStatement.ts | 45 ++++++++ source/utils/constants.ts | 2 + test/ContentScript/integrationTests.test.ts | 22 ++++ 5 files changed, 184 insertions(+), 2 deletions(-) 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..29c8440 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 = true + ): Promise { + if (sendCache) { + this.handleCachedSubmit(); + } + // console.log('Sending statement', zestStatement); this.notify(zestStatement); return Browser.runtime.sendMessage({ type: ZEST_SCRIPT, @@ -52,6 +66,15 @@ 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; @@ -118,12 +141,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 { @@ -140,11 +195,30 @@ class Recorder { console.log('Window Resize : ', width, height); } + addListenerToInputField( + elements: Set, + inputField: HTMLElement, + level: number, + frame: number, + element: Document + ): void { + if (!elements.has(inputField)) { + elements.add(inputField); + inputField.addEventListener( + 'keydown', + this.handleKeypress.bind(this, {level, frame, element}) + ); + } + } + addListenersToDocument( element: Document, 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 +247,38 @@ class Recorder { i += 1; } }); + + // Add listeners to all of the text fields + element.querySelectorAll('input').forEach((input) => { + this.addListenerToInputField(textElements, input, 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) { + this.addListenerToInputField( + textElements, + inputs[j], + level, + frame, + element + ); + } + } + } + }); + }; + + const observer = new MutationObserver(domMutated); + observer.observe(document, { + attributes: false, + childList: true, + subtree: true, + }); } shouldRecord(element: HTMLElement): boolean { @@ -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..a37cfa0 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,24 @@ class ZestStatementLaunchBrowser extends ZestStatement { } } +class ZestComment extends ZestStatement { + comment: 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 +167,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 +277,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);