Spaces:
Runtime error
Runtime error
File size: 10,794 Bytes
5c0b162 6a6bd27 5c0b162 6a6bd27 5c0b162 6a6bd27 5c0b162 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 | 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);
|