File size: 18,999 Bytes
cef4f83 303453c cef4f83 617394d cef4f83 617394d cef4f83 4689083 617394d 8bc0bae cef4f83 983cc69 617394d 075a516 cef4f83 075a516 cef4f83 9accdd0 cef4f83 4689083 cef4f83 075a516 29c673d cef4f83 9accdd0 cef4f83 9accdd0 cef4f83 9accdd0 cef4f83 617394d cef4f83 075a516 cef4f83 9accdd0 cef4f83 617394d 075a516 617394d 075a516 303453c 075a516 cef4f83 075a516 cef4f83 | 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 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 | """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='<body style="margin:0"><div style="height:180px">Host header</div><iframe src="http://127.0.0.1:7869" style="border:0;width:100%;height:2400px"></iframe><div style="height:600px">Host footer</div></body>'))
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)
|