diff --git a/graph-ui/tools/handtest-fixes-architecture.mjs b/graph-ui/tools/handtest-fixes-architecture.mjs index 793f71fd3..3575a0d12 100644 --- a/graph-ui/tools/handtest-fixes-architecture.mjs +++ b/graph-ui/tools/handtest-fixes-architecture.mjs @@ -33,6 +33,7 @@ */ import { chromium } from 'playwright'; +import { architectureNodeSelector, projectNamePattern } from './handtest-locators.mjs'; import { mkdir, writeFile } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { inflateSync } from 'node:zlib'; @@ -446,7 +447,7 @@ async function hoverSweep(page) { const hid = []; let shown = false; for (const id of ids.slice(0, 14)) { - const chip = page.locator(`button.architecture-node-label[data-node-id="${id.replace(/"/g, '\\"')}"]`).first(); + const chip = page.locator(architectureNodeSelector(id)).first(); if (!(await chip.isVisible().catch(() => false))) continue; await chip.hover({ timeout: 3000 }).catch(() => {}); await wait(450); @@ -498,7 +499,7 @@ async function k21(page) { async function switchProject(page, name) { await page.locator('.atlas-shell details summary', { hasText: PROJECT }).first().click(); - const entry = page.locator('.atlas-project-results button', { has: page.locator('.atlas-project-result-name', { hasText: new RegExp(`^${name}$`) }) }).first(); + const entry = page.locator('.atlas-project-results button', { has: page.locator('.atlas-project-result-name', { hasText: projectNamePattern(name) }) }).first(); await entry.waitFor({ timeout: 20000 }); await entry.click(); } diff --git a/graph-ui/tools/handtest-locators.mjs b/graph-ui/tools/handtest-locators.mjs new file mode 100644 index 000000000..d04e96934 --- /dev/null +++ b/graph-ui/tools/handtest-locators.mjs @@ -0,0 +1,14 @@ +import { literalPattern } from './literal-pattern.mjs'; + +/** Match one architecture chip by its graph node ID. */ +export function architectureNodeSelector(id) { + // CSS strings need escapes for backslashes, quotes, and control characters. + // Terminate each hex escape so the next character cannot extend its code point. + const escaped = id.replace(/[\x00-\x1f\x7f"\\]/g, char => `\\${char.codePointAt(0).toString(16)} `); + return `button.architecture-node-label[data-node-id="${escaped}"]`; +} + +/** Match a complete project name in the project chooser. */ +export function projectNamePattern(name) { + return literalPattern(name, { start: true, end: true }); +} diff --git a/graph-ui/tools/handtest-locators.test.mjs b/graph-ui/tools/handtest-locators.test.mjs new file mode 100644 index 000000000..bfcaaecaa --- /dev/null +++ b/graph-ui/tools/handtest-locators.test.mjs @@ -0,0 +1,24 @@ +import { expect, it } from 'vitest'; +import { JSDOM } from 'jsdom'; +import { architectureNodeSelector, projectNamePattern } from './handtest-locators.mjs'; + +it.each(['node', 'node"name', 'node\\name', 'node\\"name', 'node\nname', 'node\rname', 'node\fname', 'node], button'])('selects only the literal graph node ID: %j', id => { + const { document } = new JSDOM('').window; + const target = document.createElement('button'); + target.className = 'architecture-node-label'; + target.setAttribute('data-node-id', id); + document.body.append(target); + expect(document.querySelectorAll(architectureNodeSelector(id))).toHaveLength(1); + expect(document.querySelector(architectureNodeSelector(id))).toBe(target); +}); + +it.each(['a.b', 'a+b', '(project)', '[project]', 'a\\b', '*', '(a+)+$'])('matches a complete literal project name: %j', name => { + const pattern = projectNamePattern(name); + expect(pattern.test(name)).toBe(true); + expect(pattern.test(`prefix${name}`)).toBe(false); + expect(pattern.test(`${name}suffix`)).toBe(false); +}); + +it('does not treat a project-name dot as a wildcard', () => { + expect(projectNamePattern('a.b').test('axb')).toBe(false); +});