diff --git a/src/browser/PW_live.js b/src/browser/PW_live.js index 8bcf95c..646139d 100644 --- a/src/browser/PW_live.js +++ b/src/browser/PW_live.js @@ -115,7 +115,7 @@ function updateTooltipPosition(x, y) { window.mousemove_updateToolTip_running = false; function mousemove_updateTooltip(event) { - if (mousemove_updateToolTip_running === true) return; //exit early so we don't sawmp the CPU + if (mousemove_updateToolTip_running === true) return; //exit early so we don't swamp the CPU try { mousemove_updateToolTip_running = true; const element = document.elementFromPoint(event.x, event.y); @@ -185,7 +185,7 @@ window.addEventListener("click", recordModeClickHandler, true); /******** page object model feature ********/ window.navigation.onnavigatesuccess = async () => await reload_page_object_model_elements(); -window.setInterval(async () => await reload_page_object_model_elements(), 5000); //refresh the page object model highlighting every 5 seconds in case on-screen elements have changed +//window.setInterval(async () => await reload_page_object_model_elements(), 5000); //refresh the page object model highlighting every 5 seconds in case on-screen elements have changed var pageObjectFilePath = ""; @@ -202,28 +202,21 @@ async function reload_page_object_model_elements() { const pageObject = window.PW_pages[pageObjectFilePath]; if (pageObject === undefined) return; - const propertyRegex = new RegExp(config.pageObjectModel.propertySelectorRegex.slice(1, -1)); const pageObjectModelImportStatement = await PW_importStatement(pageObject.className, pageObjectFilePath); - for (var prop in pageObject.page) { + for (var prop of pageObject.selectors) { try { - const selectorMethodName = propertyRegex.exec(prop)?.[1]; - if (!selectorMethodName) continue; - - const selector = pageObject.page[prop]; - const matchingElements = playwright.locator(selector).elements; + const matchingElements = playwright.locator(prop.selector).elements; if (matchingElements.length > 1) { //todo: show a warning somehow } if (matchingElements.length === 0) { - console.info(`could not find element for selector ${selector}. skipping.`); + console.info(`could not find element for selector ${prop.selector}. skipping.`); continue; } - const selectorMethod = "" + pageObject.page[selectorMethodName].toString(); - const selectorMethodArgs = selectorMethod.slice(selectorMethod.indexOf("("), selectorMethod.indexOf(")") + 1); const primaryAction = config.pageObjectModel.primaryActionByCssSelector.find(([css]) => matchingElements[0].matches(css))[1]; const secondaryActions = config.pageObjectModel.secondaryActionByCssSelector.filter(([css]) => matchingElements[0].matches(css)).map(([, action]) => action); - const dataPageObjectModel = `${pageObject.className}.${selectorMethodName}${selectorMethodArgs}`; + const dataPageObjectModel = `${pageObject.className}.${prop.selectorMethod.name}(${prop.selectorMethod.args.join(', ')})`; for (const el of matchingElements) { el.setAttribute("data-page-object-model", dataPageObjectModel); el.setAttribute("data-page-object-model-import", pageObjectModelImportStatement); @@ -241,6 +234,12 @@ async function reload_page_object_model_elements() { function clearPageObjectModelElements() { if (window.PW_overlays !== undefined) for (const el of window.PW_overlays) config.pageObjectModel.overlay.off(el); + //clean up any rogue elements + const pageObjectModelAttributes = ['data-page-object-model', 'data-page-object-model-import', 'data-page-object-model-primary-action', 'data-page-object-model-secondary-actions']; + document.querySelectorAll(pageObjectModelAttributes.join(', ')).forEach(el => { + pageObjectModelAttributes.forEach(attr => el.removeAttribute(attr)); + config.pageObjectModel.overlay.off(el) + }); window.PW_overlays = []; } diff --git a/src/hotModuleReload.ts b/src/hotModuleReload.ts index 4fffb20..13ad0c3 100644 --- a/src/hotModuleReload.ts +++ b/src/hotModuleReload.ts @@ -44,7 +44,7 @@ export module hotModuleReload { const blockToExecute = _getBlockToExecute(s.testFnContents, newTestFnContents); if (blockToExecute === '') return; - await evalLines(blockToExecute, s); + await evalLines(blockToExecute); s.testFnContents = newTestFnContents; } finally { release(); @@ -52,10 +52,10 @@ export module hotModuleReload { }); } - async function evalLines(lines: string, s: hotModuleReloadState) { - const importsBlock = _rewriteAsDynamicImports(s.imports).join('\n'); + async function evalLines(lines: string) { + const importsBlock = _rewriteAsDynamicImports(_state.imports).join('\n'); const wrappedEvalLines = _wrapAsyncAsPromise(importsBlock + '\n\n' + lines, _extractVariableListFrom(lines)); - await _evalCore(s.evalScope, s.pageEvaluate, [wrappedEvalLines]); + return _evalCore(_state.evalScope, _state.pageEvaluate, wrappedEvalLines); } function _rewriteAsDynamicImports(imports: ImportDeclaration[]) @@ -91,10 +91,11 @@ ${variables.length === 0 ? `` : `Object.assign(globalThis, { ${variables.join(', return variableNames.flat(); } - export async function _evalCore(evalScope: (s: string) => any, pageEvaluate: (pageFunction: string) => Promise, codeBlocks: string[]) { + export async function _evalCore(evalScope: (s: string) => any, pageEvaluate: (pageFunction: string) => Promise, codeBlock: string) { + let result; try { await pageEvaluate(`window.PW_executing = true`); - await evalScope(codeBlocks.join('')); + result = await evalScope(codeBlock); await pageEvaluate(`PW_reportError()`); } catch (error) { if (error instanceof Error) { @@ -107,6 +108,7 @@ ${variables.length === 0 ? `` : `Object.assign(globalThis, { ${variables.join(', } finally { await pageEvaluate(`window.PW_executing = false; window.reload_page_object_model_elements();`); } + return result; } export function _extractImports(filename: string) { diff --git a/src/main.ts b/src/main.ts index 3a32e62..29ed6a5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -10,10 +10,10 @@ import { hotModuleReload } from "./hotModuleReload"; import { pageObjectModel } from "./pageObjectModel"; import { getTestCallingLocation } from "./utility"; import fs from 'fs/promises'; +import process from 'node:process'; import { ts } from "ts-morph"; -import { PlaywrightLiveRecorderConfig, PlaywrightLiveRecorderConfigFile } from "./types"; - -//github copilot directive: for the defaultConfig property - add inline jsdoc @default attributes for each property below, have the @default value match the actual values of each property +import { PlaywrightLiveRecorderConfig, PlaywrightLiveRecorderConfigFile, PlaywrightLiveRecorderConfig_recorder, PlaywrightLiveRecorderConfig_pageObjectModel, PlaywrightLiveRecorderConfig_diagnostic, TestCallingLocation } from "./types"; +export { PlaywrightLiveRecorderConfig, PlaywrightLiveRecorderConfigFile, PlaywrightLiveRecorderConfig_recorder, PlaywrightLiveRecorderConfig_pageObjectModel, PlaywrightLiveRecorderConfig_diagnostic, TestCallingLocation }; export module PlaywrightLiveRecorder { export const defaultConfig: PlaywrightLiveRecorderConfig = { @@ -126,11 +126,11 @@ export class ${className} { pageState.PlaywrightLiveRecorder_started = true; const isHeadless = test.info().project.use.headless; - if (isHeadless !== false) { - console.error('startLiveCoding called while running headless'); + const pwdebug = process.env.PWDEBUG == 'console'; + if (isHeadless !== false && !pwdebug) { + console.error('startLiveCoding called while running headless or env variable PWDEBUG=console not set'); return; } - config = _mergeConfig(defaultConfig, await _configFromFile(), configOverrides); if (!config.pageObjectModel.path.endsWith('/')) config.pageObjectModel.path +='/'; @@ -140,7 +140,7 @@ export class ${className} { await testFileWriter.init(page, testCallingLocation); await hotModuleReload.init(testCallingLocation, config.pageObjectModel.importerCustomizationHooks, (str: string) => page.evaluate(str), evalScope); - await page.exposeFunction('PW_eval', (codeBlocks: string[]) => hotModuleReload._evalCore(evalScope, s => page.evaluate(s), codeBlocks)); + await page.exposeFunction('PW_eval', (codeBlock: string) => hotModuleReload._evalCore(evalScope, s => page.evaluate(s), codeBlock)); await recorder.init(config.recorder, page); @@ -150,7 +150,7 @@ export class ${className} { if (config.pageObjectModel.enabled) { config.pageObjectModel.baseUrl = config.pageObjectModel.baseUrl ?? test.info().project.use.baseURL!; - await pageObjectModel.init(nodePath.dirname(testCallingLocation.file), config.pageObjectModel, page); + await pageObjectModel.init(nodePath.dirname(testCallingLocation.file), config.pageObjectModel, evalScope, page); } page.on('load', async page => { diff --git a/src/pageObjectModel.ts b/src/pageObjectModel.ts index ca0c89c..d556a41 100644 --- a/src/pageObjectModel.ts +++ b/src/pageObjectModel.ts @@ -1,45 +1,45 @@ import _ from "lodash"; import fs from "fs/promises"; +import process from "node:process"; import nodePath from "node:path"; -import ts from "typescript"; import chokidar from "chokidar"; import { PlaywrightLiveRecorderConfig_pageObjectModel } from "./types"; import { Page } from "@playwright/test"; import AsyncLock from "async-lock"; -import { ModuleKind, Project } from "ts-morph"; - - -export interface PageObjectEntry { - name: string; - deps: string[]; - content: string; - isLoaded: boolean; -} +import { ModuleKind, Project, SyntaxKind } from "ts-morph"; //scans and watches page object model files, transpiles and exposes page object models to the browser context export module pageObjectModel { export let _state: { testFileDir: string, config: PlaywrightLiveRecorderConfig_pageObjectModel, + evalScope: (s: string) => Promise, page: Page, } = {}; - const TrackedPaths: Set = new Set(); - const TrackedPageObjects: { [name: string]: PageObjectEntry } = {}; + let currentPageFilePath!: string; + let currentPageFilePathWatcher!: chokidar.FSWatcher; const lock = new AsyncLock(); - export async function init(testFileDir: string, config: PlaywrightLiveRecorderConfig_pageObjectModel, page: Page) { - _state = {testFileDir, config, page}; - await page.exposeFunction('PW_urlToFilePath', (url: string) => config.urlToFilePath(url, config.aliases)); - await page.exposeFunction('PW_importStatement', (className: string, pathFromRoot: string) => _importStatement(className, nodePath.join(_state.config.path, pathFromRoot), _state.testFileDir)); + export async function init(testFileDir: string, config: PlaywrightLiveRecorderConfig_pageObjectModel, evalScope: (s: string) => any, page: Page) { + _state = {testFileDir, config, evalScope, page}; + + await page.exposeFunction('PW_urlToFilePath', async (url: string) => { + const newfilePath = config.urlToFilePath(url, config.aliases); + if (newfilePath === currentPageFilePath) return currentPageFilePath; + currentPageFilePath = newfilePath; + + await currentPageFilePathWatcher?.close(); + currentPageFilePathWatcher = chokidar.watch(currentPageFilePath, { cwd: config.path }) + .on( 'add', /*path*/() => reload(page)) + .on('change', /*path*/() => reload(page)); + + return currentPageFilePath; + }); + + await page.exposeFunction('PW_importStatement', (className: string, pathFromRoot: string) => _importStatement(className, nodePath.join(process.cwd(), _state.config.path, pathFromRoot), _state.testFileDir)); await page.exposeFunction('PW_ensurePageObjectModelCreated', (path: string) => _ensurePageObjectModelCreated(fullRelativePath(path, config), classNameFromPath(path), config)); await page.exposeFunction('PW_appendToPageObjectModel', (path: string, codeBlock: string) => _appendToPageObjectModel(fullRelativePath(path, config), classNameFromPath(path), codeBlock, config)); - - const watch = chokidar.watch(`${config.filenameConvention}`, { cwd: config.path }); - - //note: watch.getWatched is empty so we can't init all here, instead the individual page reload process gets hit for each file on startup, which ensures everything is loaded - watch.on('add', path => reload(path, config.path, page)); - watch.on('change', path => reload(path, config.path, page)); } export function _importStatement(className: string, pathFromRoot: string, testFileDir: string) { @@ -49,79 +49,60 @@ export module pageObjectModel { return `import { ${className} } from '${importPath}';` } - export async function reload(path: string, config_pageObjectModel_path: string, page: Page) { + export async function reload(page: Page) { await lock.acquire('reload', async (release) => { - TrackedPaths.add(path); - const pageModel = await _reload(path, config_pageObjectModel_path); - TrackedPageObjects[pageModel.name] = pageModel; - await _attemptLoadPageObjectModel(pageModel, page); + try { + const f = nodePath.parse(currentPageFilePath); + const absolutePath = nodePath.join(process.cwd(), _state.config.path, f.dir, f.base); + + //use ts-morph to parse helper methods including args + const project = new Project({ tsConfigFilePath: 'tsconfig.json' }); + const sourceFile = project.addSourceFileAtPath(absolutePath); + + const exportedClass = sourceFile.getClasses().find(cls => cls.isExported()); + if (exportedClass === undefined) return; + + const staticProperties = exportedClass?.getStaticProperties(); + const staticMethods = exportedClass?.getStaticMethods(); + + //use dynamic import to evaluate selector property values + const importPath = absolutePath.replaceAll('\\', '/'); // absolute path with extension + const importResult = (await _state.evalScope(`(async function() { + try { + const temporaryEvalResult = await import('${importPath}'); + return temporaryEvalResult; + } catch (err) { + console.error(err); + } + })()`)); + const classInstance = Object.entries(importResult)[0][1] as Function; + + const selectorPropertyValues = _(Object.keys(classInstance).filter(key => _state.config.propertySelectorRegex.test(key))).keyBy(x => x).mapValues(key => (classInstance)[key]).value(); + + const selectorProperties = staticProperties.filter(prop => _state.config.propertySelectorRegex.test(prop.getName())) + .map(prop => { + const name = prop.getName(); + const selector = selectorPropertyValues[name]; + const selectorMethodName = _state.config.propertySelectorRegex.exec(name)?.[1]; + const selectorMethodNode = staticMethods.find(m => m.getName() === selectorMethodName); + const selectorMethod = selectorMethodNode ? { name: selectorMethodNode.getName(), args: selectorMethodNode.getParameters().map(p => p.getName()), body: selectorMethodNode.getText() } : { name: selectorMethodName, args: [], body: ''}; + return { name, selector: selector, selectorMethod }; + }); + const helperMethods = staticMethods.filter(m => !selectorProperties.some(p => m.getName() === _state.config.propertySelectorRegex.exec(p.name)?.[1])) + .map(method => ({name: method.getName(), args: method.getParameters().map(p => p.getName()), body: method.getText()})); + + const evalString = `window.PW_pages[\`${currentPageFilePath}\`] = { className: '${exportedClass.getName()}', selectors: ${JSON.stringify(selectorProperties)}, methods: ${JSON.stringify(helperMethods)}}`; + await page.evaluate(evalString); + } catch (e) { + console.error(`error calling page.addScriptTag for page object model ${currentPageFilePath}`); + console.error(e); + } + await page.evaluate('reload_page_object_model_elements()'); release(); }); } - export async function _attemptLoadPageObjectModel(entry: PageObjectEntry, page: Page) { - if (entry.deps.some(dep => TrackedPageObjects[dep]?.isLoaded !== true)) - return; //not all dependencies are loaded, don't load the script yet, it'll get automatically loaded when the last thing it's dependent upon is loaded - - try { - await page.addScriptTag({ content: entry.content }); - entry.isLoaded = true; //it loaded successfully, mark it as loaded - - //attempt reload of any TrackedPageObjects dependent upon it - for (const otherEntry of _.filter(TrackedPageObjects, (otherEntry) => otherEntry.name !== entry.name && otherEntry.deps.includes(entry.name))) - await _attemptLoadPageObjectModel(otherEntry, page); - } catch (e) { - console.error(`error calling page.addScriptTag for page object model ${entry.name}`); - } - } - - export async function _reload(path: string, config_pageObjectModel_path: string) { - const fileContents = await fs.readFile(`${config_pageObjectModel_path}${path}`, { encoding: 'utf8' }); - const className = /\\?([^\\]+?)\.ts/.exec(path)![1]; //extract filename without extension as module name - - const pageEntry = _transpile(path.replaceAll('\\', '/'), className, fileContents); - return pageEntry; - } - - export async function _transpile2(normalizedFilePath: string, className: string): Promise<{ [name: string]: PageObjectEntry }> { - const tsProject = new Project({compilerOptions: { strict: false, module: ModuleKind.ESNext}}); - tsProject.addSourceFileAtPath(nodePath.join(_state.config.path, normalizedFilePath)); - const emitResult = tsProject.emitToMemory(); - //emit result contains entire graph of local files to load - const fileEntries = emitResult.getFiles().map(x => ({ path: nodePath.relative(_state.config.path, x.filePath), content: x.text })); - const newPageObjectEntries: { [name: string]: PageObjectEntry } = {}; - for (const entry of fileEntries) { - newPageObjectEntries[entry.path] = { name: entry.path, content: entry.content, deps: [], isLoaded: true }; - } - return newPageObjectEntries; - } - - export function _transpile(normalizedFilePath: string, className: string, fileContents: string): PageObjectEntry { - const transpiled = ts.transpileModule(fileContents, { compilerOptions: { module: ts.ModuleKind.ESNext, strict: false } } ).outputText; - const deps = _getDeps(transpiled); - const content = _cleanUpTranspiledSource(normalizedFilePath, className, transpiled); - return { name: className, deps, content, isLoaded: false }; - } - - export function _getDeps(transpiled: string) { - //todo: replace hardcoded string replacements with using typescript lib to walk to AST instead - const deps = [...transpiled.matchAll(/\bimport\b\s*{?(\s*[^};]+)}?\s*from\s*([^;]*);?/g)].map(i => i[1].split(',').map(i => i.trim())).flat(); //fetch the variable names - return deps; - } - - export function _cleanUpTranspiledSource(normalizedFilePath: string, className: string, transpiled: string) { - //todo: replace hardcoded string replacements with using typescript lib to walk to AST instead - const exportReplacementText = `window.PW_pages['${normalizedFilePath}'] = {className: '${className}', page: ${className} };`; - const content = transpiled - //.replaceAll(/\bimport\b\s*({?\s*[^};]+}?)\s*from\s*([^;]*);?/g, 'const $1 = require($2);') //convert 'import' to 'require' statements - .replaceAll(/\bimport\b\s*({?\s*[^};]+}?)\s*from\s*([^;]*);?/g, '') - .replace(`var ${className} = /** @class */ (function () {\r\n function ${className}() {\r\n }`, `var ${className} = {};`) //export class fixup - .replace(` return ${className};\r\n}());\r\nexport { ${className} };`, exportReplacementText) //export class fixup - .replace(`export var ${className};`, exportReplacementText) //export module fixup - return content; - } - async function _appendToPageObjectModel(fullRelativePath: string, className: string, codeBlock: string, config: { generateClassTemplate: (className: string) => string}) { await _ensurePageObjectModelCreated(fullRelativePath, className, config); try { @@ -147,22 +128,10 @@ export module pageObjectModel { } function classNameFromPath(path: string) { return /([^/]+).ts/.exec(path)![1]; } - function fullRelativePath(path: string, config: { path: string }) { return path.normalize(nodePath.join(config.path, path)); } - - export function hotReloadedPageObjectModelSrc() { - var str = ''; - for (const entryName in TrackedPageObjects) { - const pageEntry = TrackedPageObjects[entryName]; - - str += pageEntry.content.replace(/\nwindow.PW_pages\[.*/, '') + '\n\n'; - } - - return str; - } + function fullRelativePath(path: string, config: { path: string }) { return nodePath.normalize(nodePath.join(config.path, path)); } export async function reloadAll(configPath: string, page: Page) { - for (const path in TrackedPaths) { - await pageObjectModel.reload(path, configPath, page); - } + if (!currentPageFilePath) return; + await reload(page); } } diff --git a/src/utility.ts b/src/utility.ts index c684786..2ab4935 100644 --- a/src/utility.ts +++ b/src/utility.ts @@ -1,8 +1,8 @@ import { test } from "@playwright/test"; import ErrorStackParser from "error-stack-parser"; import fs from "fs/promises"; -import path from "path"; -import url from "url"; +import path from "node:path"; +import url from "node:url"; import { TestCallingLocation } from "./types"; export async function getTestCallingLocation() {