"""Run manually with Playwright installed: python tests/browser_editor.py.""" import os from pathlib import Path import subprocess import sys import time import urllib.request from playwright.sync_api import sync_playwright ROOT = Path(__file__).resolve().parents[1] env = dict(os.environ, GRADIO_ANALYTICS_ENABLED='False', MPLCONFIGDIR='/tmp/motigen-matplotlib') env.pop('SPACE_ID', None) with open('/tmp/motigen-editor-server.log', 'w') as log: server = subprocess.Popen([sys.executable, str(ROOT / 'tests/editor_server.py')], env=env, stdout=log, stderr=log) try: for _ in range(60): try: urllib.request.urlopen('http://127.0.0.1:7869/config', timeout=1).close() break except Exception: if server.poll() is not None: raise RuntimeError(Path('/tmp/motigen-editor-server.log').read_text()) time.sleep(1) with sync_playwright() as p: # The synthetic cross-origin host has no real network address; allow # its local iframe fixture in Chromium's local-network policy. browser = p.chromium.launch(headless=True, args=['--no-sandbox', '--disable-features=LocalNetworkAccessChecks']) page = browser.new_page(viewport={'width':1440, 'height':1100}) errors = [] page.on('pageerror', lambda error: errors.append(str(error))) page.goto('http://127.0.0.1:7869', wait_until='networkidle') page.wait_for_function('Boolean(window.motigenEditor)') get_input = lambda: page.evaluate('window.motigenEditor.getInput()') assert page.locator('#me-abstract select').count() == 0 point = page.locator('[data-point="1"] .me-point').bounding_box() chart = page.locator('#me-contour').bounding_box() x,y = point['x']+point['width']/2, point['y']+point['height']/2 page.mouse.move(x,y);page.mouse.down();page.mouse.move(x,y+100*chart['height']/280,steps=15);page.mouse.up() assert get_input()['abstract'] == '0,-2,3,-1', get_input() for _ in range(6):page.get_by_role('button', name='Add contour point', exact=True).click() assert len(get_input()['abstract'].split(',')) == 10 assert page.get_by_role('button', name='Add contour point', exact=True).is_disabled() for _ in range(6):page.get_by_role('button', name='Remove last contour point', exact=True).click() assert page.get_by_role('button', name='Remove last contour point', exact=True).is_disabled() assert get_input()['abstract'] == '0,-2,3,-1' page.locator('[data-point="3"]').focus();page.keyboard.press('ArrowUp') assert get_input()['abstract'] == '0,-2,3,1' page.keyboard.press('ArrowDown') assert get_input()['abstract'] == '0,-2,3,-1' page.get_by_role('button', name='Generate music', exact=True).click() page.wait_for_function('Boolean(document.querySelector("#score-preview iframe")) && document.querySelector("#status").textContent.trim() === ""', timeout=30000) assert not page.locator('#status').is_visible() assert 'Selected candidate' not in page.locator('#score-panel').inner_text() assert page.frame_locator('iframe[title="Generated sheet music"]').locator('#legend').count() == 0 page.get_by_text('Generation details', exact=True).click() assert 'Selected candidate 7/12' in page.locator('#result-details').inner_text() assert '0,-2,3,-1' in page.locator('.gradio-container').inner_text() page.get_by_text('Model prompt used', exact=True).click() assert '%motif:v1:step_skip_leap: 0,-2,3,-1' in page.locator('details pre').inner_text() page.screenshot(path=str(ROOT / 'validation/abstract-editor.png'), full_page=True) page.get_by_role('tab', name='Write on a staff', exact=True).click() assert get_input()['mode'] == 'Concrete notes' assert page.locator('#me-staff [data-engraver="vexflow"] .vf-stavenote').count() == 4 assert page.locator('#me-staff [data-note] > rect').first.get_attribute('stroke') == 'none' assert page.evaluate("document.fonts.check('20px Bravura')") for button in page.locator('[data-duration]').all(): note_box=button.locator('svg').bounding_box();button_box=button.bounding_box() assert note_box['y'] >= button_box['y'] and note_box['y']+note_box['height'] <= button_box['y']+button_box['height'] assert page.locator('#me-note-list, [data-select]').count() == 0 # Reproduce real click -> key behavior, without test-only focus(). page.locator('[data-note="1"]').click() page.keyboard.press('ArrowUp') assert get_input()['notes'][1][0] == 'A4' page.keyboard.press('ArrowDown') assert get_input()['notes'][1][0] == 'G4' page.keyboard.press('ArrowRight') assert page.evaluate('document.activeElement.dataset.note') == '2' page.keyboard.press('ArrowLeft') assert page.evaluate('document.activeElement.dataset.note') == '1' assert page.locator('#me-concrete .me-help-panel').get_attribute('open') is None # Pointer drag from palette inserts a new pitch at the requested slot. staff = page.locator('#me-staff') def point(x,y): box=staff.bounding_box() return box['x']+x*box['width']/680, box['y']+y*box['height']/272 source = page.get_by_role('button', name='Eighth note', exact=True).bounding_box() page.mouse.move(source['x']+source['width']/2, source['y']+source['height']/2) page.mouse.down();page.mouse.move(*point(318,120), steps=15);page.mouse.up() assert get_input()['notes'][-1] == ['A4','0.5'], get_input() # Move that note upward to C5 and left to the second slot. page.mouse.move(*point(318,120));page.mouse.down();page.mouse.move(*point(162,104), steps=15);page.mouse.up() assert get_input()['notes'][1] == ['C5','0.5'], get_input() page.keyboard.press('ArrowUp');page.keyboard.press('ArrowDown') assert get_input()['notes'][1] == ['C5','0.5'] assert page.evaluate('document.activeElement.dataset.note') == '1' page.get_by_label('Accidental', exact=True).select_option('#') page.get_by_role('button', name='Half note', exact=True).click() page.get_by_label('Dotted', exact=True).check() assert get_input()['notes'][1] == ['C#5','3'], get_input() page.get_by_role('button', name='Undo', exact=True).click() assert get_input()['notes'][1] == ['C#5','2'] # Keyboard and pitch selector are alternatives to dragging. page.locator('[data-note="1"]').focus();page.keyboard.press('ArrowDown') assert get_input()['notes'][1][0] == 'B#4' page.get_by_label('Selected note pitch').select_option('35') assert get_input()['notes'][1][0] == 'C#5' # Drag a barline into a gap, remove it, and restore it with Undo. before_bars = get_input()['notes'] source = page.get_by_role('button', name='Barline tool', exact=True).bounding_box() page.mouse.move(source['x']+source['width']/2, source['y']+source['height']/2) page.mouse.down();page.mouse.move(*point(188,112), steps=15);page.mouse.up() assert get_input()['notes'] == before_bars[:2] + [['|','']] + before_bars[2:] page.locator('[data-bar-after="2"]').click() assert get_input()['notes'] == before_bars page.get_by_role('button', name='Undo', exact=True).click() # Selected tool also places a trailing barline with a single tap. page.get_by_role('button', name='Barline tool', exact=True).click() page.mouse.click(*point(344,112)) with_bars = before_bars[:2] + [['|','']] + before_bars[2:] + [['|','']] assert get_input()['notes'] == with_bars page.locator('[data-bar-after="5"]').focus();page.keyboard.press('Delete') assert get_input()['notes'] == with_bars[:-1] page.get_by_role('button', name='Undo', exact=True).click() assert get_input()['notes'] == with_bars page.keyboard.press('Escape') # Deleting an earlier note moves its following measure division. page.locator('[data-note="0"]').focus();page.keyboard.press('Delete') assert get_input()['notes'] == with_bars[1:] page.get_by_role('button', name='Undo', exact=True).click() assert get_input()['notes'] == with_bars page.get_by_role('button', name='Hear motif', exact=False).click() page.get_by_role('button', name='Generate music', exact=True).click() page.wait_for_function('document.querySelector("#status").textContent.includes("Composing")', timeout=10000) page.wait_for_function('Boolean(document.querySelector("#score-preview iframe")) && document.querySelector("#status").textContent.trim() === ""', timeout=30000) assert not page.locator('#status').is_visible() assert 'Selected candidate' not in page.locator('#score-panel').inner_text() assert page.frame_locator('iframe[title="Generated sheet music"]').locator('#legend').count() == 0 page.get_by_text('Generation details', exact=True).click() assert 'Selected candidate 7/12' in page.locator('#result-details').inner_text() frame=page.frame_locator('iframe[title="Generated sheet music"]') frame.locator('[data-motif-match="true"]').first.wait_for() page.get_by_role('tab', name='Live generation', exact=True).click() raw = page.get_by_label('Model output · updates while composing').input_value() assert '^c4' in raw, raw page.get_by_text('Model prompt used', exact=True).click() assert '%motif:abc: C2 ^c4 | G F E4 |' in page.locator('details pre').inner_text() page.get_by_role('tab', name='Sheet music', exact=True).click() page.screenshot(path=str(ROOT / 'validation/concrete-editor.png'), full_page=True) # Length bounds, tap-to-insert, and undo retain a valid serialized motif. page.get_by_role('button', name='Clear', exact=True).click() assert get_input()['notes'] == [] assert 'Add 4 more' in page.locator('#me-note-status').inner_text() page.get_by_role('button', name='Undo', exact=True).click() assert get_input()['notes'] == with_bars for _ in range(7): source = page.get_by_role('button', name='Quarter note', exact=True).bounding_box() page.mouse.move(source['x']+source['width']/2, source['y']+source['height']/2) page.mouse.down();page.mouse.move(*point(350,136), steps=8);page.mouse.up() assert sum(row[0] != '|' for row in get_input()['notes']) == 10, get_input() page.get_by_role('button', name='Reset', exact=True).click() assert all(row[0] != '|' for row in get_input()['notes']) page.set_viewport_size({'width':390,'height':844}) assert page.evaluate('document.documentElement.scrollWidth <= innerWidth') page.screenshot(path=str(ROOT / 'validation/editor-mobile.png'), full_page=True) mobile = browser.new_page(viewport={'width':390,'height':844}, has_touch=True, is_mobile=True) mobile.on('pageerror', lambda error: errors.append(str(error))) mobile.goto('http://127.0.0.1:7869', wait_until='networkidle') # Real touch drag edits the contour without scrolling the page. mobile.locator('#me-contour').scroll_into_view_if_needed() point=mobile.locator('[data-point="1"] .me-point').bounding_box() chart=mobile.locator('#me-contour').bounding_box() x,y=point['x']+point['width']/2,point['y']+point['height']/2 touch=mobile.context.new_cdp_session(mobile) touch.send('Input.dispatchTouchEvent', {'type':'touchStart','touchPoints':[{'x':x,'y':y}]}) for i in range(1,11): touch.send('Input.dispatchTouchEvent', {'type':'touchMove','touchPoints':[{'x':x,'y':y+100*chart['height']/280*i/10}]}) touch.send('Input.dispatchTouchEvent', {'type':'touchEnd','touchPoints':[]}) assert mobile.evaluate('window.motigenEditor.getInput().abstract') == '0,-2,3,-1' mobile.get_by_role('tab', name='Write on a staff', exact=True).tap() mobile.wait_for_function('window.motigenEditor.getInput().mode === "Concrete notes"') mobile.get_by_role('button', name='Clear', exact=True).tap() mobile.locator('#me-staff').scroll_into_view_if_needed() for x,y in [(110,160),(162,152),(214,144),(266,128)]: box=mobile.locator('#me-staff').bounding_box() mobile.touchscreen.tap(box['x']+x*box['width']/680,box['y']+y*box['height']/272) assert mobile.evaluate('window.motigenEditor.getInput().notes') == [['C4','1'],['D4','1'],['E4','1'],['G4','1']] mobile.get_by_role('button', name='Barline tool', exact=True).tap() mobile.locator('#me-staff').scroll_into_view_if_needed() box=mobile.locator('#me-staff').bounding_box() mobile.touchscreen.tap(box['x']+188*box['width']/680,box['y']+112*box['height']/272) assert mobile.evaluate('window.motigenEditor.getInput().notes') == [['C4','1'],['D4','1'],['|',''],['E4','1'],['G4','1']] mobile.locator('[data-bar-after="2"]').tap() assert len(mobile.evaluate('window.motigenEditor.getInput().notes')) == 4 mobile.get_by_role('button', name='Scroll staff right', exact=True).tap() mobile.wait_for_function('document.querySelector(".me-staff-scroll").scrollLeft > 100') mobile.close() # Advance only the cat's timer, preserving normal app/browser timers. cat_page=browser.new_page(viewport={'width':390,'height':844}) cat_page.add_init_script("""window.__catTicks=[]; const realInterval=window.setInterval; window.setInterval=(fn,ms,...args)=>{if(ms===180000){window.__catTicks.push(fn);return 8675309;}return realInterval(fn,ms,...args);};""") cat_page.goto('http://127.0.0.1:7869',wait_until='networkidle') cat_page.wait_for_function('Boolean(window.motigenEditor)') assert cat_page.evaluate('window.__catTicks.length') == 1 assert cat_page.locator('.cat-walker').count() == 0 cat_page.evaluate('window.__catTicks[0]()') cat_page.wait_for_function('document.querySelector("#motigen-cat-lane").style.visibility === "visible"') assert abs(cat_page.locator('#motigen-cat-lane').bounding_box()['y'] + 40 - 844) < 2 cat_page.evaluate('window.scrollTo(0,300)') assert abs(cat_page.locator('#motigen-cat-lane').bounding_box()['y'] + 40 - 844) < 2 assert cat_page.locator('.cat-walker.ltr').count() == 1 cat_page.evaluate('window.__catTicks[0]()') assert cat_page.locator('.cat-walker').count() == 1 assert cat_page.locator('#motigen-cat-lane').evaluate("el=>getComputedStyle(el).pointerEvents") == 'none' assert cat_page.evaluate('document.documentElement.scrollWidth <= innerWidth') cat_page.evaluate("document.querySelector('.cat-walker').dispatchEvent(new Event('animationend'))") assert cat_page.locator('.cat-walker').count() == 0 cat_page.evaluate('window.__catTicks[0]()') assert cat_page.locator('.cat-walker.rtl').count() == 1 cat_page.locator('.cat-walker').evaluate('el=>el.remove()') cat_page.emulate_media(reduced_motion='reduce') cat_page.evaluate('window.__catTicks[0]()') assert cat_page.locator('.cat-walker').count() == 0 cat_page.close() # Hugging Face can embed the app in a frame taller than the screen. # Use different hostnames so this also exercises cross-origin clipping. embedded=browser.new_page(viewport={'width':1000,'height':800}) embedded.on('pageerror', lambda error: errors.append(str(error))) embedded.route('http://localhost:7869/embed-test', lambda route: route.fulfill(content_type='text/html',body='
Host header
Host footer
')) embedded.goto('http://localhost:7869/embed-test',wait_until='networkidle') embedded.frame_locator('iframe').locator('#motif-editor').wait_for() child=embedded.locator('iframe').element_handle().content_frame() child.wait_for_function('Boolean(window.walkCat)') child.evaluate('window.walkCat()') child.wait_for_function('document.querySelector("#motigen-cat-lane").style.visibility === "visible"') def cat_at_bottom(): box=child.locator('#motigen-cat-lane').bounding_box() return abs(box['y']+box['height']-embedded.viewport_size['height'])<2 assert cat_at_bottom() for scroll in (350,850): embedded.evaluate('(y)=>window.scrollTo(0,y)',scroll) for _ in range(20): if cat_at_bottom():break embedded.wait_for_timeout(100) assert cat_at_bottom(), child.locator('#motigen-cat-lane').bounding_box() embedded.set_viewport_size({'width':390,'height':700}) for _ in range(20): if cat_at_bottom():break embedded.wait_for_timeout(100) assert cat_at_bottom() embedded.screenshot(path=str(ROOT/'validation/cat-embedded-viewport.png')) embedded.close() assert not errors, errors print('PASS: click/drag then keyboard note editing, viewport cat in cross-origin embed while scrolling/resizing, draggable contour (mouse/touch/keyboard), three-minute cat scheduling/cleanup, no duplicate note chips, 4–10 limits, barline drag/tap/remove/undo/prompt, palette drag, pitch drag, reorder, durations, accidental, dotted note, keyboard, undo, clear, preview audio, both generation payloads, highlighted result, mobile layout', flush=True) browser.close() finally: server.terminate() server.wait(timeout=15)