diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 90a521a..cdfc860 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,6 +34,9 @@ jobs: .scratch/m1-uat/run-*/*-failure.json .scratch/m2-uat/run-*/*.png .scratch/m2-uat/run-*/*-failure.json + .scratch/m2-uat/run-*/browser-final.json + .scratch/m2-uat/run-*/hosted-network.json + .scratch/m2-uat/run-*/mermaid-save-response.json if-no-files-found: ignore retention-days: 7 - run: npm pack --dry-run diff --git a/test/diagram-uat.test.mjs b/test/diagram-uat.test.mjs index 5a01900..9212c64 100644 --- a/test/diagram-uat.test.mjs +++ b/test/diagram-uat.test.mjs @@ -263,9 +263,28 @@ test('bundled canvas drawing, selected-shape threads, review and conflict retain await frame.getByLabel('Choose diagram').selectOption(id); await frame.locator('.excalidraw canvas.interactive').first().waitFor(); await eventually(() => frame.getByRole('status').textContent().then(text => text.includes('Diagram ready')), 'Canvas did not finish opening'); + await editorReady(frame); return frame; }; + async function editorReady(frame) { + await eventually(() => frame.locator('body').evaluate(() => ['.toolbar', '.workspace'].every(selector => { + const element = document.querySelector(selector); + return element && !element.inert; + })), 'Editor did not release its toolbar and canvas'); + } + async function selectShape(target, frame, point, shapeId) { + await editorReady(frame); + const canvas = frame.locator('.excalidraw canvas.interactive'); + const box = await canvas.boundingBox(); + await canvas.click({ position: { x: box.width - 120, y: 100 } }); + await target.keyboard.press('Escape'); + await eventually(() => frame.getByText(/^Selected shape:/).count().then(count => count === 0), 'Canvas selection did not clear'); + await target.keyboard.press('v'); + await target.mouse.click(point.x, point.y); + await eventually(() => frame.getByText(`Selected shape: ${shapeId}`, { exact: true }).count().then(Boolean), 'Intended saved shape was not selected'); + } async function draw(target, frame, xOffset = 0) { + await editorReady(frame); const canvas = frame.locator('.excalidraw canvas.interactive').first(); const box = await canvas.boundingBox(); assert.ok(box && box.width > 300 && box.height > 200, 'Real canvas must have a usable viewport'); @@ -299,19 +318,21 @@ test('bundled canvas drawing, selected-shape threads, review and conflict retain const native = JSON.parse(draft.bytes.toString()); assert.ok(native.elements.some(el => el.id === createdShapeId && el.type === 'rectangle' && el.width > 100 && el.height > 70)); assert.ok(native.elements.some(el => el.type === 'text' && el.text === 'Native acceptance text')); + const saveResponse = page.waitForResponse(response => response.url() === `${origin}${path}/save` && response.request().method() === 'POST'); await ui.getByRole('button', { name: 'Save diagram', exact: true }).click(); - await eventually(() => status().textContent().then(text => text.includes('Diagram saved.')), 'Save did not acknowledge browser edit'); + assert.equal((await saveResponse).status(), 200, 'Native canvas save did not succeed'); const accepted = await read(); savedRevision = accepted.revisionId; + assert.deepEqual(accepted.scene.elements, native.elements, 'Native save changed the drawn shapes or text'); assert.ok(accepted.scene.elements.some(el => el.id === createdShapeId && !el.isDeleted)); assert.match(accepted.svg, / ui.getByText(`Selected shape: ${createdShapeId}`, { exact: true }).count().then(Boolean), 'Saved shape could not be selected for comment'); + await selectShape(page, ui, rectanglePoint, createdShapeId); await ui.getByLabel('Your name', { exact: true }).fill('Canvas reviewer'); await ui.getByLabel('Shape comment', { exact: true }).fill('Please keep this component visible.'); + const commentResponse = page.waitForResponse(response => response.url() === `${origin}${path}/comments` && response.request().method() === 'POST'); await ui.getByRole('button', { name: 'Comment on shape', exact: true }).click(); - await eventually(() => status().textContent().then(text => text.includes('Shape comment saved.')), 'Shape comment did not save'); + assert.equal((await commentResponse).status(), 201, 'Selected-shape comment did not save'); const comments = (await json(origin, `${path}/comments`)).data.items; const comment = comments.find(c => (c.text || c.content?.text) === 'Please keep this component visible.'); assert.ok(comment); rootCommentId = comment.id; @@ -319,10 +340,36 @@ test('bundled canvas drawing, selected-shape threads, review and conflict retain assert.equal(comment.anchor.revisionId, savedRevision); const record = ui.locator('.record').filter({ hasText: 'Please keep this component visible.' }); await record.getByLabel('Reply to shape comment').fill('Confirmed; this stays in the diagram.'); - await record.getByRole('button', { name: 'Reply', exact: true }).click(); - await record.getByText('Confirmed; this stays in the diagram.', { exact: false }).waitFor(); - await page.mouse.click(lastDrawPoint.x, lastDrawPoint.y); + // The actual reply can arrive via SSE before its POST acknowledges and + // releases the canvas. Hold that response to exercise this ordering. + let releaseReply, finishReply, replyArrived = false, replyError; + const replyFinished = new Promise(resolve => { finishReply = resolve; }); + const replyBarrier = new Promise(resolve => { releaseReply = resolve; }); + const replyPath = `${origin}/api/feedback/${key}/${comment.id}/reply`; + const holdReply = async route => { + replyArrived = true; + try { const response = await route.fetch(); await replyBarrier; await route.fulfill({ response }); } + catch (error) { replyError = error.message; } + finally { finishReply(); } + }; + await page.route(replyPath, holdReply); + try { + await record.getByRole('button', { name: 'Reply', exact: true }).click(); + await record.getByText('Confirmed; this stays in the diagram.', { exact: false }).waitFor(); + assert.equal(await ui.locator('.workspace').evaluate(element => element.inert), true, 'Reply visibility must not imply editable canvas'); + } finally { + releaseReply(); + if (replyArrived) await replyFinished; + await page.unroute(replyPath, holdReply); + } + assert.equal(replyError, undefined); + await selectShape(page, ui, rectanglePoint, createdShapeId); await page.keyboard.press('Delete'); + const afterDelete = JSON.parse((await downloadBytes(page, () => ui.getByRole('button', { name: 'Export JSON', exact: true }).click())).bytes.toString()); + assert.equal(afterDelete.elements.some(element => element.id === createdShapeId && !element.isDeleted), false, 'Native canvas still contains the shape after Delete'); + assert.ok(afterDelete.elements.some(element => element.id === 'uat-stable-shape' && !element.isDeleted), 'Delete removed another component'); + assert.ok(afterDelete.elements.some(element => element.type === 'text' && element.text === 'Native acceptance text'), 'Delete removed the unrelated text'); + await editorReady(ui); await ui.getByRole('button', { name: 'Save diagram', exact: true }).click(); await eventually(() => read().then(doc => doc.revisionId !== savedRevision && !doc.scene.elements.some(el => el.id === createdShapeId && !el.isDeleted)), 'Deleting a selected canvas shape was not saved'); await page.screenshot({ path: join(fixture, 'canvas-comment-reply.png'), fullPage: true }); @@ -344,17 +391,73 @@ test('bundled canvas drawing, selected-shape threads, review and conflict retain await eventually(() => status().textContent().then(text => text.includes('Mermaid imported')), 'Bundled Mermaid conversion failed'); await eventually(() => previousCanvas.evaluate(element => !element.isConnected), 'Imported diagram did not mount a new native canvas'); await ui.locator('.excalidraw canvas.interactive').waitFor(); + await editorReady(ui); const imported = JSON.parse((await downloadBytes(page, () => ui.getByRole('button', { name: 'Export JSON', exact: true }).click())).bytes.toString()); await writeFile(join(fixture,'mermaid-native-export.json'), JSON.stringify(imported,null,2)); assert.ok(imported.elements.some(el => el.type === 'text' && el.text === 'Client')); assert.ok(imported.elements.some(el => el.type === 'arrow')); await ui.getByLabel('Diagram title').fill('Imported Mermaid acceptance'); - await ui.getByRole('button', { name: 'Save diagram', exact: true }).click(); - await eventually(() => status().textContent().then(text => text.includes('Diagram saved.')), 'Imported Mermaid diagram did not save'); - const diagrams = (await json(origin, root)).data.diagrams; - assert.ok(diagrams.some(diagram => diagram.title === 'Imported Mermaid acceptance')); + const sourceBefore = await readFile(file, 'utf8'); + const { parseDocument } = await import('../src/diagrams/document.js'); + const before = parseDocument(sourceBefore); + // Delay the real post-save listing response until the save's filesystem + // notification reaches the editor. Its status message can replace the + // transient save acknowledgement; accepted source is the success contract. + await ui.locator('body').evaluate(() => { + window.__htmldropUatSourceNotifications = 0; + window.__htmldropUatSourceObserver = event => { + if (event.origin === location.origin && event.source === parent && event.data?.type === 'htmldrop:diagram-source-changed') window.__htmldropUatSourceNotifications += 1; + }; + addEventListener('message', window.__htmldropUatSourceObserver); + }); + let finishRefresh, responseRecord, requestBody, acceptedDiagram, notificationError; + const refreshFinished = new Promise(resolve => { finishRefresh = resolve; }); + const holdRefresh = async route => { + if (route.request().method() !== 'GET') { await route.continue(); return; } + const response = await route.fetch(); + try { + await eventually(() => ui.locator('body').evaluate(() => window.__htmldropUatSourceNotifications > 0), 'Own-save source notification did not arrive'); + } catch (error) { notificationError = error.message; } + await route.fulfill({ response }); + finishRefresh(); + }; + await page.route(origin + root, holdRefresh); + try { + const responsePending = page.waitForResponse(response => response.url() === origin + root && response.request().method() === 'POST'); + await ui.getByRole('button', { name: 'Save diagram', exact: true }).click(); + const response = await responsePending; + requestBody = response.request().postDataJSON(); + responseRecord = { status: response.status(), body: await response.json() }; + assert.equal(responseRecord.status, 201, JSON.stringify(responseRecord.body)); + acceptedDiagram = responseRecord.body; + assert.equal(acceptedDiagram.title, 'Imported Mermaid acceptance'); + assert.deepEqual(acceptedDiagram.scene.elements, imported.elements, 'Save changed imported native shapes or geometry'); + await refreshFinished; + assert.equal(notificationError, undefined); + const persisted = parseDocument(await readFile(file, 'utf8')); + const embedded = persisted.diagrams.find(diagram => diagram.diagramId === acceptedDiagram.diagramId); + assert.ok(embedded, 'Accepted Mermaid diagram is missing from authoritative source'); + assert.equal(embedded.revisionId, acceptedDiagram.revisionId); + assert.deepEqual(embedded.scene, acceptedDiagram.scene); + assert.equal(embedded.svg, acceptedDiagram.svg); + for (const prior of before.diagrams) { + const retained = persisted.diagrams.find(diagram => diagram.diagramId === prior.diagramId); + assert.equal(retained?.revisionId, prior.revisionId, 'Import changed a previous diagram revision'); + assert.deepEqual(retained?.scene, prior.scene, 'Import overwrote a previous diagram'); + } + const diagrams = (await json(origin, root)).data.diagrams; + assert.ok(diagrams.some(diagram => diagram.diagramId === acceptedDiagram.diagramId && diagram.revisionId === acceptedDiagram.revisionId)); + await eventually(() => ui.getByLabel('Choose diagram').inputValue().then(value => value === acceptedDiagram.diagramId), 'Editor did not adopt the accepted Mermaid diagram'); + await eventually(() => ui.locator('.toolbar').evaluate(element => !element.inert), 'Mermaid save did not release the editor'); + assert.equal(await ui.getByRole('button', { name: 'Save diagram', exact: true }).isDisabled(), true, 'Accepted Mermaid scene remained unsaved'); + } finally { + await writeFile(join(fixture, 'mermaid-save-response.json'), JSON.stringify({ status: await status().textContent(), notificationError, sourceNotifications: await ui.locator('body').evaluate(() => window.__htmldropUatSourceNotifications), response: responseRecord, request: requestBody, diagramId: acceptedDiagram?.diagramId, sourceBefore, sourceAfter: await readFile(file, 'utf8') }, null, 2)); + await ui.locator('body').evaluate(() => { removeEventListener('message', window.__htmldropUatSourceObserver); delete window.__htmldropUatSourceObserver; delete window.__htmldropUatSourceNotifications; }); + await page.unroute(origin + root, holdRefresh); + } await ui.getByLabel('Choose diagram').selectOption(id); await eventually(() => ui.getByLabel('Diagram title').inputValue().then(title => title === 'Browser-drawn architecture'), 'Original diagram did not reopen after import'); + await editorReady(ui); }); await t.test('private reviewer edits real canvas and proposes; owner accepts then rejects a separate candidate', async () => { @@ -405,6 +508,7 @@ test('bundled canvas drawing, selected-shape threads, review and conflict retain }); await t.test('external source conflict yields 409, preserves accepted bytes and downloadable unsaved draft', async () => { + await editorReady(ui); await ui.getByLabel('Diagram title').fill('Unsaved conflict draft'); const expectedDraft = JSON.parse((await downloadBytes(page, () => ui.getByRole('button', { name: 'Export JSON', exact: true }).click())).bytes.toString()); let release, observedStatus;