import { chromium } from 'playwright'; // The port moves between runs; pass it in rather than editing this file. const BASE = process.env.STUDIO_URL ?? 'http://127.0.0.1:7910'; const b = await chromium.launch({ executablePath: '/opt/pw-browsers/chromium' }); const page = await b.newPage(); const fails = []; const check = (l, ok, extra) => { console.log((ok?' ok ':' FAIL ')+l+(ok||!extra?'':'\n '+extra)); if(!ok) fails.push(l); }; const errors = []; page.on('pageerror', (e) => errors.push(String(e))); await page.goto(`${BASE}/`, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('#style-input', { timeout: 15000 }); await page.getByRole('button', { name: /Write or paste a score|Open the score editor/ }).click(); await page.waitForTimeout(400); const grid = page.locator('svg[aria-label="Note grid"]'); check('the grid opens by default', await grid.isVisible()); const notes = () => page.locator('rect[data-note]'); const mine = () => page.locator('rect[data-note][data-voice="0"]'); const before = await notes().count(); check('the skeleton renders as notes', before > 0, `count ${before}`); check('notation renders beside it', await page.locator('text=Notation').first().isVisible()); await page.waitForTimeout(900); const engraved = await page.locator('.console-scroll svg').count(); check('abcjs engraved something', engraved > 0, `${engraved} svg`); // The engraved staff was absolutely positioned and lay across the grid, // swallowing every click meant for a note. Test a note that is actually // inside the scroller's visible box, otherwise this only proves the note was // scrolled out of view. const topAtNote = await page.evaluate(() => { const scroll = document.querySelector('svg[aria-label="Note grid"]')?.parentElement; if (!scroll) return 'no scroller'; const view = scroll.getBoundingClientRect(); const note = [...document.querySelectorAll('rect[data-note]')].find((r) => { const b = r.getBoundingClientRect(); return b.top >= view.top && b.bottom <= view.bottom && b.left >= view.left && b.right <= view.right; }); if (!note) return 'no note in view'; const b = note.getBoundingClientRect(); const el = document.elementFromPoint(b.x + b.width / 2, b.y + b.height / 2); return el ? el.tagName + (el.getAttribute('data-note') ? '#note' : '') : 'null'; }); check('nothing overlays the notes', topAtNote === 'rect#note', `topmost was ${topAtNote}`); // --- read the ABC back out of the text view -------------------------------- const abcOf = async () => { await page.getByRole('button', { name: 'text', exact: true }).click(); const v = await page.locator('textarea[aria-label="Score as ABC notation"]').inputValue(); await page.getByRole('button', { name: 'notes', exact: true }).click(); await page.waitForTimeout(200); return v; }; const original = await abcOf(); check('text view shows ABC', original.includes('V:Vocal'), original.slice(0, 60)); // --- drag a note up -------------------------------------------------------- const first = mine().first(); const box = await first.boundingBox(); await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.mouse.down(); await page.mouse.move(box.x + box.width / 2, box.y - 13 * 2, { steps: 8 }); await page.mouse.up(); await page.waitForTimeout(250); const afterDrag = await abcOf(); check('dragging up changed the score', afterDrag !== original); check('drag did not destroy the voices', afterDrag.includes('V:Vocal') && afterDrag.includes('V:Ins')); check('headers survived the rewrite', afterDrag.includes('T:Your title') && afterDrag.includes('K:G')); // --- add a note by clicking empty grid ------------------------------------- const scroller = page.locator('div.console-scroll').filter({ has: grid }); const gb = await scroller.boundingBox(); const countBeforeAdd = await notes().count(); await page.mouse.click(gb.x + gb.width - 40, gb.y + gb.height - 30); await page.waitForTimeout(250); check('clicking empty grid adds a note', (await notes().count()) === countBeforeAdd + 1, `${countBeforeAdd} -> ${await notes().count()}`); // --- delete with the keyboard ---------------------------------------------- const countBeforeDelete = await notes().count(); await mine().first().focus(); await page.keyboard.press('Backspace'); await page.waitForTimeout(250); check('Backspace deletes the focused note', (await notes().count()) === countBeforeDelete - 1, `${countBeforeDelete} -> ${await notes().count()}`); // --- arrow keys nudge ------------------------------------------------------- await mine().first().focus(); const beforeNudge = await abcOf(); await mine().first().focus(); await page.keyboard.press('ArrowUp'); await page.waitForTimeout(250); check('arrow keys move a note without a mouse', (await abcOf()) !== beforeNudge); // --- paste a score and see it render --------------------------------------- await page.getByRole('button', { name: 'text', exact: true }).click(); const pasted = 'X:1\nT:Pasted\nM:3/4\nL:1/8\nK:D\nV:Lead\nD2 F2 A2 | d6 |'; await page.locator('textarea[aria-label="Score as ABC notation"]').fill(pasted); await page.getByRole('button', { name: 'notes', exact: true }).click(); await page.waitForTimeout(400); check('a pasted score renders in the grid', (await notes().count()) === 4, `got ${await notes().count()}`); check('its voice name is picked up', await page.getByRole('button', { name: 'Lead' }).isVisible()); // --- an unsupported score must refuse, not mangle -------------------------- await page.getByRole('button', { name: 'text', exact: true }).click(); await page.locator('textarea[aria-label="Score as ABC notation"]').fill('X:1\nM:4/4\nL:1/8\nK:C\nV:1\n(3CDE G4 |'); await page.getByRole('button', { name: 'notes', exact: true }).click(); await page.waitForTimeout(300); check('a tuplet is refused with an explanation', await page.getByText(/cannot draw without losing it/).isVisible()); await page.getByRole('button', { name: 'Edit it as text' }).click(); await page.waitForTimeout(200); check('the refused score is still intact in the text', (await page.locator('textarea[aria-label="Score as ABC notation"]').inputValue()).includes('(3CDE')); // --- the score shape that failed in the wild -------------------------------- // Chord symbols, repeats, two voices, and three octaves between the bass and // the top of the vocal. This used to be refused outright. const REAL = 'X:1\nT:Take\nM:4/4\nL:1/8\nQ:1/4=84\nK:E\nV:Vocal\n|: "G#m" B2 c2 B2 A2 | "E" e6 z2 :|\nV:Ins\n|: "G#m" E,,4 G,,4 | "E" E,,8 :|'; await page.getByRole('button', { name: 'text', exact: true }).click(); await page.locator('textarea[aria-label="Score as ABC notation"]').fill(REAL); await page.getByRole('button', { name: 'notes', exact: true }).click(); await page.waitForTimeout(500); check('a score with chord symbols is no longer refused', !(await page.getByText(/cannot draw without losing it/).isVisible().catch(() => false))); check('it renders as notes', (await notes().count()) > 0, `got ${await notes().count()}`); const chords = await page.locator('text[data-chord]').allTextContents(); check('chord symbols are drawn in the lane', chords.includes('G#m') && chords.includes('E'), chords.join(',')); // Every note of the instrument must exist, however far apart the parts are. const wanted = await page.evaluate(() => { const rects = [...document.querySelectorAll('rect[data-note]')]; const ys = rects.map((r) => Number(r.getAttribute('y'))); const svg = document.querySelector('svg[aria-label="Note grid"]'); return { count: rects.length, minY: Math.min(...ys), maxY: Math.max(...ys), svgHeight: Number(svg.getAttribute('height')) }; }); check('the keyboard spans every note, none clipped off', wanted.minY >= 0 && wanted.maxY < wanted.svgHeight, JSON.stringify(wanted)); const scrollState = await page.evaluate(() => { const box = document.querySelector('svg[aria-label="Note grid"]').parentElement; return { scrollHeight: box.scrollHeight, clientHeight: box.clientHeight }; }); check('the grid scrolls vertically rather than hiding the rest', scrollState.scrollHeight > scrollState.clientHeight, JSON.stringify(scrollState)); // --- playback --------------------------------------------------------------- await page.getByRole('button', { name: 'Play the score' }).click(); await page.waitForTimeout(700); const head = await page.locator('line[data-playhead]').count(); check('a playhead appears while playing', head === 1, `${head} playheads`); const firstX = await page.locator('line[data-playhead]').getAttribute('x1'); await page.waitForTimeout(700); const laterX = await page.locator('line[data-playhead]').getAttribute('x1').catch(() => null); check('the playhead advances', laterX !== null && Number(laterX) > Number(firstX), `${firstX} -> ${laterX}`); check('notes light up as they sound', (await page.locator('rect[data-sounding="yes"]').count()) > 0); await page.getByRole('button', { name: 'Stop' }).click(); await page.waitForTimeout(300); check('stopping clears the playhead', (await page.locator('line[data-playhead]').count()) === 0); // --- the notation pane must not clip its last system ------------------------ const staff = await page.evaluate(() => { const label = [...document.querySelectorAll('button')].find((b) => b.textContent.trim().startsWith('Notation')); const box = label?.parentElement?.querySelector('div.console-scroll'); if (!box) return null; const svg = box.querySelector('svg'); if (!svg) return { noSvg: true }; const b = box.getBoundingClientRect(), s = svg.getBoundingClientRect(); return { reachable: box.scrollHeight <= box.clientHeight + 1 || box.scrollHeight > box.clientHeight, boxH: b.height, svgH: s.height, scrollH: box.scrollHeight, clientH: box.clientHeight }; }); check('the notation pane can reach the bottom of the sheet', staff !== null && !staff.noSvg && staff.scrollH <= staff.clientH + 1 || (staff && staff.scrollH > staff.clientH), JSON.stringify(staff)); // The notation pane once overlapped the grid's lower strip, which still // scrolled and still showed notes but silently swallowed every click there. const overlap = await page.evaluate(() => { const svg = document.querySelector('svg[aria-label="Note grid"]'); const box = svg.parentElement.getBoundingClientRect(); const x = box.right - 40; const y = box.bottom - 8; const top = document.elementFromPoint(x, y); return { tag: top?.tagName ?? 'none', inGrid: Boolean(top && svg.contains(top)) }; }); check('nothing covers the bottom of the grid', overlap.inGrid, JSON.stringify(overlap)); check('no page errors throughout', errors.length === 0, errors.join('\n')); await b.close(); console.log(fails.length ? `\n${fails.length} FAILURES` : '\nall assertions passed'); process.exit(fails.length ? 1 : 0);