"""Desktop/mobile integration against real PostgreSQL and the production API.""" import socket import threading import time import pytest import uvicorn from app.main import create_app from scan_support import OWNER, OTHER, ScanAuth, answer_store, postgres_server from test_answer_scans import png, request from test_answer_grading import assessment from app.answer_grading import validate_assessment from app.math_photo import PhotoUnavailable playwright = pytest.importorskip('playwright.sync_api') @pytest.fixture(autouse=True) def unlocked_open_ended(monkeypatch): """These tests answer the open-ended question, which is locked by default.""" monkeypatch.setenv('MATHLY_OPEN_ENDED_LOCKED', '0') @pytest.fixture def scan_server(answer_store): app = create_app(auth=ScanAuth()); app.state.answers = answer_store with socket.socket() as sock: sock.bind(('127.0.0.1', 0)) server = uvicorn.Server(uvicorn.Config(app, log_level='error')) thread = threading.Thread(target=server.run, kwargs={'sockets': [sock]}, daemon=True) thread.start() try: deadline = time.monotonic() + 5 while not server.started: assert thread.is_alive() and time.monotonic() < deadline time.sleep(.01) yield f'http://127.0.0.1:{sock.getsockname()[1]}', answer_store finally: server.should_exit = True; thread.join(timeout=5) def session(browser, owner=OWNER, mobile=False): ctx = browser.new_context(viewport={'width': 390 if mobile else 1280, 'height': 844}, is_mobile=mobile, has_touch=mobile) ctx.route('https://cdn.jsdelivr.net/**', lambda route: route.abort()) # Stub only the external sign-in SDK; all app APIs and ownership checks run. ctx.add_init_script('''window.supabase={createClient:()=>({auth:{ getSession:async()=>{const id=localStorage.getItem('test.account')||'%s';return {data:{session:{access_token:id,user:{id}}}};}, onAuthStateChange:cb=>{window.testAuthEvent=cb;return {data:{subscription:{unsubscribe(){}}}};}, signOut:async()=>window.testAuthEvent('SIGNED_OUT',null) }})};''' % owner) return ctx def open_question(page): page.locator('.mx-nav [data-view="path"]').click() # the app opens on the home screen page.locator('.mx-node[data-id="a_30_side"]').click() playwright.expect(page.locator('.mx-answer')).to_be_visible() playwright.expect(page.locator('.mx-choices')).to_have_count(0) def test_two_device_capture_retake_failure_retry_and_original_attempt(scan_server): url, store = scan_server with playwright.sync_playwright() as p: browser = p.chromium.launch() try: computer = session(browser).new_page(); errors=[] computer.on('pageerror', lambda error: errors.append(str(error))) computer.goto(url) with computer.expect_request('**/api/math/answer-attempts') as resume: open_question(computer) assert len(resume.value.post_data_json['question_version'])==64 computer.locator('[data-request]').click() # no window over the question: the button itself shows the 4-digit code and the progress wait = computer.locator('.mx-answer .mx-phone-live') playwright.expect(wait).to_contain_text('ממתינים לטלפון') playwright.expect(wait.locator('.mx-scan-code span')).to_have_count(4) assert computer.locator('.mx-scan-overlay').count() == 0 playwright.expect(computer.locator('.mx-answer')).to_be_visible() # the question stays in view code = wait.locator('.mx-scan-code').inner_text().replace('\n', '') assert len(code) == 4 and code.isdigit() with store.connect() as c: attempt_id = str(c.execute('select id from math_question_attempts').fetchone()[0]) phone = session(browser, mobile=True).new_page() phone.on('pageerror', lambda error: errors.append(str(error))) other = request(store, question='another-question') phone.goto(url) # the newest request pops up first, as a window; declining it leaves the other one ask = phone.locator('.mx-scan-overlay') playwright.expect(ask).to_contain_text('Draw your proof') phone.locator('[data-decline]').click() playwright.expect(phone.locator('[data-status]')).to_contain_text('סירבתם') assert store.action(OWNER, 'get', scan_id=other['scan']['id'])['scan']['state'] == 'declined' phone.locator('[data-close]').click() playwright.expect(ask).to_contain_text('משולש ישר', timeout=10000) # the phone shows the same code as the computer assert ask.locator('.mx-scan-code').inner_text().replace('\n', '') == code phone.locator('[data-camera]').dispatch_event('cancel') playwright.expect(phone.locator('[data-status]')).to_contain_text('לא ניתנה הרשאה') image = {'name': 'answer.png', 'mimeType': 'image/png', 'buffer': png()} with phone.expect_file_chooser() as chooser: phone.locator('[data-take]').click() chooser.value.set_files(image) playwright.expect(phone.locator('[data-submit]')).to_be_visible() phone.screenshot(path='/tmp/mathly-answer-phone.png', full_page=True, animations='disabled') first = phone.locator('[data-preview]').get_attribute('src') phone.locator('[data-camera]').set_input_files(image) playwright.expect(phone.locator('[data-preview]')).not_to_have_attribute('src', first) assert store.action(OWNER, 'get', attempt_id=attempt_id)['attachment'] is None store.fail_put = True phone.locator('[data-submit]').click() playwright.expect(phone.locator('[data-status]')).to_contain_text('אפשר לנסות שוב') playwright.expect(wait).to_contain_text('ההעלאה נכשלה', timeout=10000) assert phone.locator('[data-preview]').is_visible() store.fail_put = False started, release = threading.Event(), threading.Event() original_put = store.put def slow_put(key, data): started.set(); assert release.wait(timeout=15); original_put(key, data) store.put = slow_put phone.locator('[data-submit]').click() playwright.expect(wait).to_contain_text('מקבלים את התמונה מהטלפון', timeout=10000) assert started.is_set() release.set() playwright.expect(phone.locator('[data-status]')).to_contain_text('נשלחה למחשב', timeout=10000) playwright.expect(wait).to_have_count(0, timeout=10000) # the code box gives way to the photo playwright.expect(computer.locator('.mx-answer-image')).to_be_visible() assert computer.locator('.mx-answer-image').evaluate('img=>img.complete&&img.naturalWidth>0') computer.screenshot(path='/tmp/mathly-answer-computer.png', full_page=True, animations='disabled') assert computer.evaluate("!JSON.parse(localStorage.getItem('mathly.progress.v1:'+'%s@test.invalid')||'{}').done?.a_30_side" % OWNER) computer.reload();open_question(computer) playwright.expect(computer.locator('.mx-answer-image')).to_be_visible() foreign = session(browser, OTHER, mobile=True).new_page();foreign.goto(url) foreign.wait_for_timeout(1500) playwright.expect(foreign.locator('.mx-scan-overlay')).to_have_count(0) assert store.action(OWNER, 'get', attempt_id=attempt_id)['attachment'] assert not errors finally: browser.close() def test_expiry_renewal_reconnect_and_account_change(scan_server): url, store = scan_server with playwright.sync_playwright() as p: browser = p.chromium.launch() try: computer = session(browser).new_page();computer.goto(url);open_question(computer) computer.locator('[data-request]').click() playwright.expect(computer.locator('.mx-phone-live')).to_contain_text('ממתינים לטלפון') with store.connect() as c: old = str(c.execute('select id from math_answer_scans').fetchone()[0]) c.execute("update math_answer_scans set expires_at=now()-interval '1 second'") computer.route('**/api/math/answer-attempts/*', lambda r:r.abort(), times=1) playwright.expect(computer.locator('.mx-answer [role=status]')).to_have_text('בקשת הצילום פגה', timeout=15000) playwright.expect(computer.locator('.mx-phone-live')).to_have_count(0) playwright.expect(computer.locator('[data-request]')).to_contain_text('בקשת צילום חדשה') phone = session(browser, mobile=True).new_page();phone.goto(url) phone.wait_for_timeout(1500) playwright.expect(phone.locator('.mx-scan-overlay')).to_have_count(0) # an expired request does not pop up computer.locator('[data-request]').click() playwright.expect(phone.locator('.mx-scan-overlay')).to_be_visible(timeout=10000) assert store.action(OWNER, 'pending')[0]['scan']['generation'] == 2 phone.locator('[data-camera]').set_input_files({'name':'answer.png','mimeType':'image/png','buffer':png()}) phone.locator('[data-submit]').click() playwright.expect(phone.locator('[data-status]')).to_contain_text('נשלחה למחשב') playwright.expect(computer.locator('.mx-answer-image')).to_be_visible(timeout=10000) # Account change removes the photograph and all old-account continuations. computer.evaluate("localStorage.setItem('test.account','%s');window.testAuthEvent('SIGNED_IN',{access_token:'%s',user:{id:'%s'}})" % (OTHER, OTHER, OTHER)) computer.locator('.mx-nav [data-view="path"]').click() # the new account starts on home playwright.expect(computer.locator('.mx-node[data-id="a_30_side"]')).to_be_visible() assert computer.locator('.mx-answer-image').count() == 0 open_question(computer) playwright.expect(computer.locator('[data-request]')).to_be_enabled() assert computer.locator('.mx-answer-image').count() == 0 finally: browser.close() def test_success_response_loss_is_safe_to_retry(scan_server): url, store = scan_server with playwright.sync_playwright() as p: browser = p.chromium.launch() try: computer=session(browser).new_page();computer.goto(url);open_question(computer) computer.locator('[data-request]').click() playwright.expect(computer.locator('.mx-phone-live')).to_contain_text('ממתינים לטלפון') phone=session(browser,mobile=True).new_page();phone.goto(url) playwright.expect(phone.locator('.mx-scan-overlay')).to_be_visible() phone.locator('[data-camera]').set_input_files({'name':'answer.png','mimeType':'image/png','buffer':png()}) def lost_response(route): response=route.fetch() assert response.ok route.abort() phone.route('**/api/math/answer-scans/*/uploads/*',lost_response,times=1) phone.locator('[data-submit]').click() playwright.expect(phone.locator('[data-status]')).to_contain_text('אפשר לנסות שוב') playwright.expect(computer.locator('.mx-answer-image')).to_be_visible(timeout=10000) phone.locator('[data-submit]').click() playwright.expect(phone.locator('[data-status]')).to_contain_text('נשלחה למחשב') with store.connect() as c: assert c.execute('select count(*) from math_answer_attachments').fetchone()[0]==1 computer.locator('.mx-x').click();computer.locator('.mx-node[data-id="m_def"]').click() computer.locator('.mx-choices button[data-m=""]').click() # one right answer of ten is recorded, but does not finish the skill assert computer.evaluate("JSON.parse(localStorage.getItem('mathly.progress.v1:'+'%s@test.invalid')).solved.m_def.length===1" % OWNER) finally: browser.close() @pytest.mark.parametrize('verdict', ['correct', 'partial', 'incorrect', 'unreadable', 'needs_review']) def test_check_feedback_progress_reload_and_retake(scan_server, monkeypatch, verdict): url, store = scan_server monkeypatch.setenv('ANTHROPIC_API_KEY', 'browser-test-key') calls = [] started, release = threading.Event(), threading.Event() class Grader: def __init__(self, key): pass def grade(self, image, snapshot): calls.append(snapshot) if len(calls) == 1: raise PhotoUnavailable('temporary failure') started.set(); assert release.wait(timeout=15) changes = {'correct': {}, 'partial': {'complete': False, 'score': 60}, 'incorrect': {'answer_correct': False, 'score': 0}, 'unreadable': {'readable': False}, 'needs_review': {'confident': False}}[verdict] # Verify that model text is escaped before rendering. return validate_assessment(assessment(feedback='משוב על הפתרון', **changes)) monkeypatch.setattr('app.answer_routes.AnswerGrader', Grader) from test_answer_scans import begin, finish from app.math_skills import algebra9, question_version skill = next(s for s in algebra9()['skills'] if s['id'] == 'a_30_side') done = store.action(OWNER, 'resume', question_id=skill['id'], question_version=question_version(skill['sample']), snapshot=skill['sample']) done = finish(store, begin(store, store.action(OWNER, 'create_scan', attempt_id=done['attempt']['id']))) with playwright.sync_playwright() as p: browser = p.chromium.launch() try: computer = session(browser).new_page(); errors=[] computer.on('pageerror', lambda error: errors.append(str(error))) computer.goto(url); open_question(computer) playwright.expect(computer.locator('.mx-answer-image')).to_be_visible() # Opening a received answer checks nothing: the owner presses the button, and the first check fails. playwright.expect(computer.locator('[data-grade]')).to_have_text('בדיקת התשובה') assert calls == [] computer.locator('[data-grade]').click() playwright.expect(computer.locator('[data-grade]')).to_have_text('ניסיון בדיקה נוסף') assert len(calls) == 1 computer.locator('[data-grade]').click() playwright.expect(computer.locator('.mx-answer [role=status]')).to_have_text('בודקים את התשובה…') assert started.wait(timeout=5) playwright.expect(computer.locator('[data-new-attempt]')).to_be_disabled() release.set() panel = computer.locator('.mx-grade') playwright.expect(panel).to_be_visible(timeout=10000) playwright.expect(panel).to_contain_text('') assert panel.locator('script').count() == 0 if verdict in ('unreadable', 'needs_review'): assert '/100' not in panel.inner_text() key = 'mathly.progress.v1:' + OWNER + '@test.invalid' assert computer.evaluate("key=>!!JSON.parse(localStorage.getItem(key)||'{}').done?.a_30_side", key) == (verdict == 'correct') computer.reload();open_question(computer) playwright.expect(computer.locator('.mx-grade')).to_be_visible() playwright.expect(computer.locator('.mx-answer-image')).to_be_visible() assert len(calls) == 2 computer.locator('[data-new-attempt]').click() playwright.expect(computer.locator('[data-request]')).to_be_enabled() assert computer.locator('.mx-grade').count() == 0 assert computer.locator('.mx-answer-image').count() == 0 # The old attempt, photograph, and grade remain recoverable. assert store.grade(OWNER, 'get', attempt_id=done['attempt']['id'])['result']['verdict'] == verdict assert store.read(done['attachment']['object_key']) assert not errors finally: release.set(); browser.close() def test_a_phone_upload_is_kept_and_checked_only_when_the_computer_asks(scan_server, monkeypatch): url, store = scan_server monkeypatch.setenv('ANTHROPIC_API_KEY', 'browser-test-key') calls=[] class Grader: def __init__(self, key): pass def grade(self, image, snapshot): calls.append(snapshot) return validate_assessment(assessment()) monkeypatch.setattr('app.answer_routes.AnswerGrader', Grader) with playwright.sync_playwright() as p: browser=p.chromium.launch() try: computer=session(browser).new_page();computer.goto(url);open_question(computer) computer.locator('[data-request]').click() playwright.expect(computer.locator('.mx-phone-live')).to_contain_text('ממתינים לטלפון') phone=session(browser,mobile=True).new_page();phone.goto(url) playwright.expect(phone.locator('.mx-scan-overlay')).to_be_visible() phone.locator('[data-camera]').set_input_files({'name':'answer.png','mimeType':'image/png','buffer':png()}) playwright.expect(phone.locator('.mx-scan-card')).to_contain_text('הצילום יישמר כתשובה') phone.locator('[data-submit]').click() playwright.expect(phone.locator('[data-status]')).to_contain_text('נשלחה למחשב') playwright.expect(computer.locator('[data-grade]')).to_have_text('בדיקת התשובה') # kept, not checked assert calls==[] computer.locator('[data-grade]').click() playwright.expect(computer.locator('.mx-grade')).to_contain_text('100/100',timeout=10000) assert len(calls)==1 finally: browser.close() def test_phone_declines_and_computer_withdraws(scan_server): url, store = scan_server with playwright.sync_playwright() as p: browser = p.chromium.launch() try: computer = session(browser).new_page(); errors = [] computer.on('pageerror', lambda error: errors.append(str(error))) computer.goto(url); open_question(computer) computer.locator('[data-request]').click() wait = computer.locator('.mx-phone-live') playwright.expect(wait.locator('.mx-scan-code span')).to_have_count(4) phone = session(browser, mobile=True).new_page() phone.on('pageerror', lambda error: errors.append(str(error))) phone.goto(url) # the phone declines: the computer reports it phone.locator('[data-decline]').click() playwright.expect(computer.locator('.mx-answer [role=status]')).to_have_text('הבקשה נדחתה בטלפון', timeout=10000) playwright.expect(wait).to_have_count(0) phone.locator('[data-close]').click() # a new request pops up again; the computer withdraws it and the phone's window says so computer.locator('[data-request]').click() playwright.expect(phone.locator('.mx-scan-overlay [data-take]')).to_be_visible(timeout=10000) computer.locator('[data-cancel-scan]').click() playwright.expect(wait).to_have_count(0) playwright.expect(phone.locator('[data-status]')).to_contain_text('בוטלה במחשב', timeout=10000) assert store.action(OWNER, 'pending') == [] assert not errors finally: browser.close() def test_phone_asks_once_for_notifications_and_the_bell_toggles(answer_store, monkeypatch): """The phone asks once (אישור / לא, תודה); the 🔔 in the top bar turns notifications on and off later. The browser's push service is stubbed: only the page's choices and what it sends the server are checked.""" from app.push import new_private_key monkeypatch.setenv('VAPID_PRIVATE_KEY', new_private_key()) app = create_app(auth=ScanAuth()); app.state.answers = answer_store saved = {} class Push: # the stored subscriptions, without Supabase on, public, why = True, '', '' def save(self, user, sub): saved[sub['endpoint']] = user def delete(self, endpoint, user=None): saved.pop(endpoint, None) def scan_requested(self, *a): return 0 app.state.push = Push() from app.push import public_key import os app.state.push.public = public_key(os.environ['VAPID_PRIVATE_KEY']) with socket.socket() as sock: sock.bind(('127.0.0.1', 0)) server = uvicorn.Server(uvicorn.Config(app, log_level='error')) thread = threading.Thread(target=server.run, kwargs={'sockets': [sock]}, daemon=True); thread.start() while not server.started: time.sleep(.01) url = f'http://127.0.0.1:{sock.getsockname()[1]}' stub = '''(()=>{let perm=localStorage.getItem('test.perm')||'default',sub=null; Object.defineProperty(window,'Notification',{value:{get permission(){return perm;}, requestPermission:async()=>(perm=localStorage.getItem('test.answer')||'granted')}}); const mk=()=>({endpoint:'https://push.test/'+Math.random(),toJSON(){return {endpoint:this.endpoint,keys:{p256dh:'p',auth:'a'}};}, unsubscribe:async()=>{sub=null;return true;}}); window.PushManager=function(){}; const reg={pushManager:{getSubscription:async()=>sub,subscribe:async()=>(sub=mk())},getNotifications:async()=>[]}; Object.defineProperty(navigator,'serviceWorker',{value:{register:async()=>reg,ready:Promise.resolve(reg), getRegistration:async()=>reg,addEventListener(){}}});})();''' with playwright.sync_playwright() as p: browser = p.chromium.launch() try: ctx = session(browser, mobile=True); ctx.add_init_script(stub) phone = ctx.new_page(); errors = [] phone.on('pageerror', lambda error: errors.append(str(error))) phone.goto(url) ask = phone.locator('.mx-push-ask') playwright.expect(ask).to_contain_text('התראות מהמחשב') ask.get_by_role('button', name='לא, תודה').click() playwright.expect(ask).to_have_count(0) bell = phone.locator('.mx-push-btn').first playwright.expect(bell).to_have_text('🔕') assert not saved phone.reload() # asked once: not again playwright.expect(bell).to_have_text('🔕') phone.wait_for_timeout(500); playwright.expect(ask).to_have_count(0) bell.click() phone.locator('.mx-push-ask').get_by_role('button', name='אישור').click() playwright.expect(bell).to_have_text('🔔') assert list(saved.values()) == [OWNER] bell.click() phone.locator('.mx-push-ask').get_by_role('button', name='כיבוי').click() playwright.expect(bell).to_have_text('🔕') assert not saved # a refusal in the browser's own question: the window says so, and the bell then explains the block phone.evaluate("localStorage.setItem('test.answer','denied')") bell.click() phone.locator('.mx-push-ask').get_by_role('button', name='אישור').click() playwright.expect(phone.locator('.mx-push-ask')).to_contain_text('נחסמו') phone.locator('.mx-push-ask').get_by_role('button', name='ביטול').click() playwright.expect(bell).to_have_text('🔕') assert errors == [] # a computer never shows the bell computer = session(browser).new_page(); computer.goto(url) playwright.expect(computer.locator('.mx-acts').first).to_be_visible() assert computer.locator('.mx-push-btn:visible').count() == 0 finally: browser.close(); server.should_exit = True; thread.join(timeout=5) def test_the_phone_answers_a_photo_request_by_drawing_on_the_board(scan_server): url, store = scan_server with playwright.sync_playwright() as p: browser = p.chromium.launch() try: computer = session(browser).new_page(); errors = [] computer.on('pageerror', lambda error: errors.append(str(error))) computer.goto(url) open_question(computer) computer.locator('[data-request]').click() wait = computer.locator('.mx-answer .mx-phone-live') playwright.expect(wait).to_contain_text('ממתינים לטלפון') phone = session(browser, mobile=True).new_page() phone.on('pageerror', lambda error: errors.append(str(error))) phone.goto(url) ask = phone.locator('.mx-scan-overlay') playwright.expect(ask).to_contain_text('משולש ישר', timeout=10000) phone.locator('[data-draw]').click() # the board instead of a photo board = phone.locator('.mx-board.full') playwright.expect(board).to_be_visible() box = board.locator('canvas').bounding_box() assert box['width'] > 300 and box['height'] > 500 # the whole phone screen phone.locator('.mx-board-send').click() # nothing drawn yet: nothing is sent playwright.expect(phone.locator('.mx-board-send')).to_contain_text('הלוח ריק') phone.locator('.mx-board [data-do=question]').click() # the question, copied to the top phone.locator('.mx-board [data-tool=pen]').click() x, y = box['x'] + 60, box['y'] + 300 phone.mouse.move(x, y); phone.mouse.down() for k in range(1, 12): phone.mouse.move(x + k * 20, y + (k % 3) * 15) phone.mouse.up() phone.wait_for_timeout(2600) phone.locator('.mx-board-send').click() playwright.expect(phone.locator('[data-status]')).to_contain_text('נשלחה למחשב', timeout=10000) playwright.expect(board).to_have_count(0, timeout=5000) # back to the request's window playwright.expect(computer.locator('.mx-answer-image')).to_be_visible(timeout=10000) with store.connect() as c: attempt_id = str(c.execute('select id from math_question_attempts').fetchone()[0]) assert store.action(OWNER, 'get', attempt_id=attempt_id)['attachment'] assert not errors finally: browser.close() @pytest.mark.parametrize('teacher_score,completes', [(100, True), (85, False)]) def test_the_teachers_review_shows_above_the_ai_check_and_a_100_completes_the_skill(scan_server, teacher_score, completes): import uuid url, store = scan_server from test_answer_scans import begin, finish from app.math_skills import algebra9, question_version skill = next(s for s in algebra9()['skills'] if s['id'] == 'a_30_side') made = store.action(OWNER, 'resume', question_id=skill['id'], question_version=question_version(skill['sample']), snapshot=skill['sample']) aid = finish(store, begin(store, store.action(OWNER, 'create_scan', attempt_id=made['attempt']['id'])))['attempt']['id'] lease = str(uuid.uuid4()) # the AI called it partial: 60 store.grade(OWNER, 'start', attempt_id=aid, lease_id=lease) store.grade(OWNER, 'complete', attempt_id=aid, lease_id=lease, result=validate_assessment( assessment(complete=False, score=60, feedback='חסר פירוט בשלב האחרון'))) store.review(OWNER, 'review', attempt_id=aid, reviewer='teacher@test.invalid', score=teacher_score, comment='הדרך נכונה מאוד') key = 'mathly.progress.v1:' + OWNER + '@test.invalid' with playwright.sync_playwright() as p: browser = p.chromium.launch() try: page = session(browser).new_page(); errors = [] page.on('pageerror', lambda error: errors.append(str(error))) page.goto(url); open_question(page) note = page.locator('.mx-teacher-note') playwright.expect(note).to_contain_text('הערת המורה') playwright.expect(note).to_contain_text(f'{teacher_score}/100') playwright.expect(note).to_contain_text('הדרך נכונה מאוד') # shown as text, never as markup assert note.locator('b').count() == 0 grade = page.locator('.mx-grade') playwright.expect(grade.locator('h4')).to_contain_text('בדיקה אוטומטית: ') playwright.expect(grade.locator('h4')).to_contain_text('60/100') # the AI's own score stays visible assert page.locator('.mx-teacher-note, .mx-grade').first.get_attribute('class').startswith('mx-teacher-note') # above it page.wait_for_timeout(500) assert page.evaluate("key=>!!JSON.parse(localStorage.getItem(key)||'{}').done?.a_30_side", key) == completes for _ in range(6): # a finished skill is celebrated: dismiss it if not page.locator('.sky-cele [data-sky=go]').count(): break page.locator('.sky-cele [data-sky=go]').first.click(); page.wait_for_timeout(200) # the student asks the teacher to look again page.locator('.mx-ask summary').click() page.locator('[data-ask-msg]').fill('כתבתי את כל השלבים') page.locator('[data-ask-review]').click() playwright.expect(page.locator('.mx-ask-wait')).to_contain_text('הבקשה ממתינה') asked = store.review(OWNER, 'get', attempt_id=aid)['request'] assert asked['state'] == 'open' and asked['message'] == 'כתבתי את כל השלבים' page.reload(); open_question(page) playwright.expect(page.locator('.mx-ask-wait')).to_contain_text('הבקשה ממתינה') # and it is remembered assert page.locator('[data-ask-review]').count() == 0 and not errors finally: browser.close()