import fs from 'node:fs' import * as crypto from "node:crypto"; import { Locator, Page, TestInfo, expect } from '@playwright/test'; interface CellObject { table: number row: number, word: string } interface CurrentTableContent { count: number, word_prefix: string } export interface ArrayTables { sort_order: string, array: CurrentTableContent[] } export type role = "alert" | "alertdialog" | "application" | "article" | "banner" | "blockquote" | "button" | "caption" | "cell" | "checkbox" | "code" | "columnheader" | "combobox" | "complementary" | "contentinfo" | "definition" | "deletion" | "dialog" | "directory" | "document" | "emphasis" | "feed" | "figure" | "form" | "generic" | "grid" | "gridcell" | "group" | "heading" | "img" | "insertion" | "link" | "list" | "listbox" | "listitem" | "log" | "main" | "marquee" | "math" | "meter" | "menu" | "menubar" | "menuitem" | "menuitemcheckbox" | "menuitemradio" | "navigation" | "none" | "note" | "option" | "paragraph" | "presentation" | "progressbar" | "radio" | "radiogroup" | "region" | "row" | "rowgroup" | "rowheader" | "scrollbar" | "search" | "searchbox" | "separator" | "slider" | "spinbutton" | "status" | "strong" | "subscript" | "superscript" | "switch" | "tab" | "table" | "tablist" | "tabpanel" | "term" | "textbox" | "time" | "timer" | "toolbar" | "tooltip" | "tree" | "treegrid" | "treeitem" interface CellArray { table: number; row: number; word: string; } export type ScrollToPosition = "top" | "bottom"; export type ClickOrEnter = "click" | "Enter" export const fileReader = async (filePath: string): Promise => { try { const data = fs.readFileSync(filePath, { encoding: 'utf8' }); console.log(`fileReader::data length:", '${data.length}'`); return data } catch (err) { console.error("fileReader::err:", err, "#"); throw err } } export const fileWriter = async (filePath: string, data: string): Promise => { try { fs.writeFileSync(filePath, data, "utf8"); console.log(`fileWriter::File written to ${filePath}...`); } catch (err) { console.error("fileWriter::err:", err, "#"); throw err; } } export const loopOverTablesAndClickOnUrls = async (page: Page, cellObj: CellObject, timeout = 50, ariaSnapshotName: string) => { let cellLabel = `id-table-${cellObj["table"]}-row-${cellObj["row"]}-nth` try { console.log(`current aria-label:${cellLabel}...`) console.log(`current cell content: '${cellLabel}'...`) let currentCellElement = page.getByLabel(cellLabel).locator('a') console.log("currentCellElement:", currentCellElement, "#") let currentInnerText = await currentCellElement.innerText() console.log(`currentCellElement::innerText: '${currentInnerText}'`) expect(currentInnerText).toBe(cellObj.word) await currentCellElement.click({ timeout: 1000 }); await page.waitForTimeout(timeout) await expect(page.getByLabel('editor')).toMatchAriaSnapshot({ name: ariaSnapshotName }); } catch (err) { console.log("cellLabel:", cellLabel, "#") console.log("err:", err, "#") throw err } } export const assertTableStap = async (page: Page, count: number, sortOrder: string, testIdx: number, subFolderName: string, action: string) => { let containerTables = page.getByLabel('words-frequency', { exact: true }) let tablesArray = containerTables.getByRole("table") let tablesArrayLen = await tablesArray.count() console.log("tablesArrayLen:", tablesArrayLen, "#") await expect(tablesArray).toHaveCount(count) let containerTablesAriaSnap = await containerTables.ariaSnapshot() if (action === "read") { const ariaSnapshot = await fileReader(`${import.meta.dirname}/${subFolderName}/test-${testIdx}-${sortOrder}.txt`) expect(containerTablesAriaSnap).toBe(ariaSnapshot) } else if (action === "write") { // the automatic aria snapshot test save system doesn't work, we save it manually test-words-frequency-2-filtering-sorting-snapshots fileWriter(`${import.meta.dirname}/${subFolderName}/test-${testIdx}-${sortOrder}.txt`, containerTablesAriaSnap) } else { throw Error(`Wrong condition: '${action}'`) } } export async function testWithLoop(page: Page, testLLMTextFilePath: string, cellArray2: CellArray[], assertTitleString: string) { // await page.goto(process.env.DOMAIN_PORT ?? "/"); console.log(page.url()) console.log("Let's try with a much longer, multiline text while scrolling the conteditable div on click") console.log("first upload a new, longer, multiline text then populate again the words frequency tables and re-try again the word links") const fileChooserPromise = page.waitForEvent('filechooser'); await page.getByRole('button', { name: 'id-input-file-selector' }).click(); await page.waitForTimeout(200) const fileChooser = await fileChooserPromise; await fileChooser.setFiles(testLLMTextFilePath); await page.waitForTimeout(200) await page.getByRole('button', { name: 'btn4-get-words-frequency' }).click(); const wordsFreqTableTitle = page.getByLabel('id-words-frequency-table-title') console.log("assertTitleString:", assertTitleString, "#") await expect(wordsFreqTableTitle).toContainText(assertTitleString); const editor = page.getByLabel("editor"); // avoid snapshot differences due to spellcheck editor.evaluate((el: HTMLDivElement) => el.setAttribute("spellcheck", "false")) await page.waitForTimeout(100) console.log("try with a new array of tables/rows...") for (let idx in cellArray2) { await loopOverTablesAndClickOnUrls(page, cellArray2[idx], 100, `test-loop-${assertTitleString}-${idx}.txt`) } console.log("end!") } export async function assertCellAndLink(page: Page, gameEditor: Locator, idCell: string, expectedCellString: string, assertScreenshot: boolean = true) { let tableOfWordsElNth0 = page.getByLabel(idCell).getByRole('cell'); await expect(tableOfWordsElNth0).toMatchAriaSnapshot(`- cell "${idCell}-link": "${expectedCellString}"`); await page.getByLabel(`${idCell}-link`).click(); await page.waitForTimeout(100); if (assertScreenshot) { await expect(gameEditor).toHaveScreenshot(); } } export async function assertCellAndLinkAriaSnapshot(page: Page, idCell: string, expectedCellString: string, idElementSnapshot: string, expectedSnapshotString: string) { // await assertCellAndLink(page, page.locator("not_used"), idCell, expectedCellString, false) let tableOfWordsElNth0 = page.getByLabel(idCell).getByRole('cell'); await expect(tableOfWordsElNth0).toMatchAriaSnapshot(`- cell "${idCell}-link": "${expectedCellString}"`); await page.getByLabel(`${idCell}-link`).click(); await page.waitForTimeout(100); await expectOnlyVisibleTextInElement(page, idElementSnapshot, expectedSnapshotString) } export async function expectVisibleTextWithWalker( page: Page, idElement: string, expectedString: string, timeout = 10000 ): Promise { // First, check that the text is present in the DOM console.log(`expectVisibleTextWithWalker::start:${idElement} => ${expectedString} #`) const loc = page.locator(`#${idElement}`) try { await expect(loc).toContainText(expectedString) } catch { console.error(`expectVisibleTextWithWalker, idElement ${idElement} allTextContents:`, await loc.allTextContents(), "#") } console.log(`expectVisibleTextWithWalker::found expectedString, go ahead!`) await page.waitForFunction( ({ idElement, expected }: { idElement: string; expected: string }) => { const container = document.getElementById(idElement); if (!!container && !container.textContent?.includes(expected)) { console.error("expectVisibleTextWithWalker::DEBUG:expected:", expected, "#") } return !!container && container.textContent?.includes(expected); }, { idElement, expected: expectedString }, { timeout } ); // Next, check that the element is scrolled so that the expected text is visible in the viewport await page.waitForFunction( ({ idElement, expected }: { idElement: string; expected: string }) => { const container = document.getElementById(idElement); if (!container) return false; const parentRect = container.getBoundingClientRect(); let visible = ''; function getVisibleText(node: Node): string { if (node.nodeType === Node.TEXT_NODE) { const range = document.createRange(); range.selectNode(node); const rects = range.getClientRects(); for (const rect of rects) { if ( rect.bottom > parentRect.top && rect.top < parentRect.bottom && rect.right > parentRect.left && rect.left < parentRect.right ) { return node.textContent ?? ''; } } return ''; } else if (node.nodeType === Node.ELEMENT_NODE) { let text = ''; for (const child of (node as Element).childNodes) { text += getVisibleText(child); } return text; } return ''; } visible = getVisibleText(container); // Check that the visible portion contains the expected string return visible.includes(expected); }, { idElement, expected: expectedString }, { timeout } ); // Optionally, assert the scroll position is not at the top or bottom (unless that's expected) // For example, you can check that the scrollTop is not zero (not at top) const scrolled = await page.evaluate((id) => { const el = document.getElementById(id); if (!el) return null; return { scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight }; }, idElement); if (scrolled) { // You can add more specific assertions here if you know the expected scroll position // For now, just log the scroll state console.error(`Scroll state for #${idElement}:`, scrolled); } } /** * @param page Playwright Page object * @param idElement The id of the element to check * @param expectedVisible The exact string expected to be visible in the viewport */ export async function expectOnlyVisibleTextInElement( page: Page, idElement: string, expectedVisible: string ) { // Use bounding rects to get visible text for complex HTML const visibleText = await page.evaluate((id) => { const el = document.getElementById(id); if (!el) { throw Error(`HTML element with id '${id}' not found!`) } const parentRect = el.getBoundingClientRect(); let visible = ''; function getVisibleText(node: Node): string { if (node.nodeType === Node.TEXT_NODE) { const range = document.createRange(); range.selectNode(node); const rects = range.getClientRects(); for (const rect of rects) { if ( rect.bottom > parentRect.top && rect.top < parentRect.bottom && rect.right > parentRect.left && rect.left < parentRect.right ) { return node.textContent || ''; } } return ''; } else if (node.nodeType === Node.ELEMENT_NODE) { let text = ''; for (const child of (node as Element).childNodes) { text += getVisibleText(child); } return text; } return ''; } visible = getVisibleText(el); return visible.trim(); }, idElement); const rndString = crypto.randomBytes(20).toString('hex'); expect(visibleText).not.toBe(expectedVisible + ` - STRING TO NOT MATCH: ${rndString}!`); // we'll check only if the expected string is within the Page element, just to try handling devices with different viewports try { expect(visibleText).toContain(expectedVisible); } catch (err) { console.log("expectedVisible:", typeof expectedVisible, expectedVisible, "#") console.log("visible text:", typeof visibleText, visibleText, "#") console.log("error:", err, "#") } } /** * Scrolls the element with the given id to the bottom. * @param page Playwright Page object * @param idElement The id of the scrollable element */ export async function scrollToBottomById(page: Page, idElement: string) { await page.evaluate((id) => { const el = document.getElementById(id); if (el) { el.scrollTop = el.scrollHeight; } }, idElement); } export async function scrollToTopById(page: Page, idElement: string) { await page.evaluate((id) => { const el = document.getElementById(id); if (el) { el.scrollTop = 0; } }, idElement); } export async function uploadFileWithPageAndFilepath(page: Page, filepath: string) { console.log(`preparing uploading of file '${filepath}'!`) await page.getByRole('link', { name: 'Save / Load' }).click(); await page.waitForTimeout(100) const fileChooserPromise = page.waitForEvent('filechooser'); await page.getByRole('button', { name: '📁 Open File' }).click(); const fileChooser = await fileChooserPromise; await fileChooser.setFiles(filepath); await page.waitForTimeout(300) console.log(`file '${filepath}' uploaded!!`) } export async function assertVisibleTextAfterNavigation(page: Page, idElement: string, expectedString: string, scrollTo: ScrollToPosition, idElementContentEditable: string = "gametext", projectName = "") { // scroll to top gametext if (scrollTo === "top") { await scrollToTopById(page, idElementContentEditable); } else if (scrollTo === "bottom") { await scrollToBottomById(page, idElementContentEditable); } console.log("#") if (projectName === "MobileChromeLandscape") { let newIdEl = idElement.replace('-div', "") await page.getByRole('link', { name: newIdEl }).click({timeout: 1000}) } else { await page.getByLabel(idElement).click(); } await page.waitForTimeout(200) // assert visible gametext if (projectName !== "MobileChromeLandscape") { await expectVisibleTextWithWalker(page, idElementContentEditable, expectedString) } else { try { await expect(page.locator(idElementContentEditable)).toHaveScreenshot() } catch { console.error("since the space is not much, only in case of MobileChromeLandscape let's skip this check... at least we tried =)") } } await page.waitForTimeout(200) } export async function fillInputFieldWithString(page: Page, inputString: string, clickOrEnter: ClickOrEnter = "Enter"): Promise { await page.getByRole('searchbox', {name: 'Word Search Input'}).click(); await page.getByRole('searchbox', {name: 'Word Search Input'}).fill(inputString); if (clickOrEnter === "click") { await page.getByRole('button', { name: 'id-perform-wordsearch' }).click(); } else { await page.getByRole('searchbox', {name: 'Word Search Input'}).press('Enter'); } } export async function initTest({page, workerInfo, filepath, targetUrl = 'http://localhost:8000/', setUi = true}: { page: Page, workerInfo: TestInfo, filepath: string, targetUrl?: string, setUi?: boolean }): Promise { const projectName = workerInfo.project.name console.log("workerInfo:", workerInfo.project.name, "#") // 1. Connect to the local web server page if (targetUrl) await page.goto(targetUrl); // 2. Activate the required UI mode (e.g., switch to classic or advanced UI) console.log("setui:", setUi, "#") if (setUi) await page.getByRole('button', { name: 'Set UI' }).click(); await openMobileMenu(page, "#found mobile button for global menu, open it to prepare json story upload!") // 3. Upload a saved JSON story file to provide long text content for analysis await uploadFileWithPageAndFilepath(page, filepath) // activate wordsearch await openMobileMenu(page, "#found mobile button for global menu, open it to toggle word search!") await page.getByRole('link', { name: 'Settings' }).click(); await page.getByRole('checkbox', { name: 'WordSearch Tool' }).check(); await page.getByRole('button', { name: 'OK' }).click(); return projectName } export async function openMobileMenu(page: Page, msg: string) { const mobileButtonGlobalMenu = page.getByRole('button', { name: 'Main Menu Options' }) if (await mobileButtonGlobalMenu.isVisible({timeout: 500})) { await mobileButtonGlobalMenu.click(); await page.waitForTimeout(200) console.log(msg) } } export async function standardCheck(page: Page, projectName: string, expectedString: string, testName: string, click: boolean = true) { // start as a normal test if (click) await page.getByRole('button', { name: 'id-perform-wordsearch' }).click(); await page.waitForTimeout(200) await expect(page.getByLabel('wordsearch_candidates_count')).toMatchAriaSnapshot(`- text: /1\\d\\d\\d result\\(s\\) found/`); await expect(page.getByLabel('id-div-candidate-1-nth')).toMatchAriaSnapshot({name: `${testName}-0-${projectName}.txt`}); const wordsearch_results = page.getByLabel("wordsearch_results") await expect(wordsearch_results).toMatchAriaSnapshot({name: `${testName}-1-${projectName}.txt`}); await page.waitForTimeout(200) await page.getByLabel('id-div-candidate-1-nth').click(); await assertVisibleTextAfterNavigation(page, 'id-div-1-range-1-nth', expectedString, "bottom", "gametext", projectName); await page.waitForTimeout(200) }