|
| 1 | +import fs from 'node:fs' |
| 2 | +import os from 'node:os' |
| 3 | +import path from 'node:path' |
1 | 4 | import { test, expect } from './fixtures/server' |
2 | 5 | import { deleteViaPageMenu } from './fixtures/atlasPage' |
3 | | -import { openCard } from './fixtures/atlasBoard' |
| 6 | +import { closeCard, deleteSticky, openCard } from './fixtures/atlasBoard' |
4 | 7 | import { clickRowAction } from './inventoryRow' |
5 | 8 | import { contextMenu } from './fixtures/contextMenu' |
6 | 9 |
|
@@ -54,6 +57,19 @@ async function pasteHTML(page: import('@playwright/test').Page, html: string, pl |
54 | 57 | }, { html, text: plainTextSibling }) |
55 | 58 | } |
56 | 59 |
|
| 60 | +// pastePlainText injects text/plain VERBATIM (unlike pasteText below, |
| 61 | +// which URI-encodes to mimic the diagram tool's own copy format) -- |
| 62 | +// the shape a copied file path or ordinary prose actually has. |
| 63 | +async function pastePlainText(page: import('@playwright/test').Page, raw: string) { |
| 64 | + // eslint-disable-next-line no-restricted-syntax -- cursor-position-only gesture, not a checkable interaction (pasteText's own comment below has the full reasoning) |
| 65 | + await page.mouse.move(1000, 220) |
| 66 | + await page.evaluate((t) => { |
| 67 | + const dt = new DataTransfer() |
| 68 | + dt.setData('text/plain', t) |
| 69 | + window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })) |
| 70 | + }, raw) |
| 71 | +} |
| 72 | + |
57 | 73 | async function pasteText(page: import('@playwright/test').Page, raw: string) { |
58 | 74 | // The paste anchors at the pointer (a paste inside a frame files |
59 | 75 | // into it, by design) -- aim at open canvas so the entities land at |
@@ -186,3 +202,90 @@ test('a table copied from an M365 app (HTML clipboard flavor) lands a board-loca |
186 | 202 | await clickRowAction(page, listRow, 'Delete') |
187 | 203 | await expect(listRow).toHaveCount(0) |
188 | 204 | }) |
| 205 | + |
| 206 | +// Regression: pasting a local file PATH (text) landed a sticky note |
| 207 | +// containing the raw path string, while DROPPING the same file landed |
| 208 | +// the real thing (goal 0179's founding rule). A pasted path now routes |
| 209 | +// through the drop door's own landing pipeline: .md becomes a mirrored |
| 210 | +// document card, .drawio a diagram board object -- and a path that |
| 211 | +// doesn't resolve on disk still falls back to the note, never a dead |
| 212 | +// end. |
| 213 | +test('pasting a file path lands what dropping the file would; a dead path still falls back to a note', async ({ page }) => { |
| 214 | + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mill-e2e-paste-path-')) |
| 215 | + const mdPath = path.join(dir, 'ZzE2ePastedDocPath.md') |
| 216 | + fs.writeFileSync(mdPath, '# Pasted doc\n\nbody\n') |
| 217 | + const drawioPath = path.join(dir, 'ZzE2ePastedDiagramPath.drawio') |
| 218 | + fs.writeFileSync(drawioPath, '<mxfile><diagram name="P">' |
| 219 | + + '<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>' |
| 220 | + + '<mxCell id="2" value="Box" vertex="1" parent="1"><mxGeometry x="0" y="0" width="80" height="40"/></mxCell>' |
| 221 | + + '</root></mxGraphModel></diagram></mxfile>') |
| 222 | + try { |
| 223 | + await page.goto('/') |
| 224 | + await page.getByRole('link', { name: 'Atlas' }).click() |
| 225 | + await expect(page.getByTestId('atlas-board')).toBeVisible() |
| 226 | + |
| 227 | + // .md path -> mirrored document card, exactly like dropping the file. |
| 228 | + await pastePlainText(page, mdPath) |
| 229 | + const card = page.getByTestId('atlas-note-card').filter({ hasText: 'ZzE2ePastedDocPath' }) |
| 230 | + await expect(card).toBeVisible() |
| 231 | + await expect(page.getByTestId('atlas-sticky-note').filter({ hasText: 'ZzE2ePastedDocPath' })).toHaveCount(0) |
| 232 | + |
| 233 | + // .drawio path -> diagram board object, never a card or note. |
| 234 | + await pastePlainText(page, drawioPath) |
| 235 | + const diagram = page.locator('[data-testid="atlas-board-object"][data-object-kind="diagram"]') |
| 236 | + await expect(diagram).toHaveCount(1) |
| 237 | + await expect(page.getByTestId('atlas-note-card').filter({ hasText: 'ZzE2ePastedDiagramPath' })).toHaveCount(0) |
| 238 | + |
| 239 | + // A path-shaped string that doesn't exist stays ordinary text: the |
| 240 | + // note fallback, so nothing a user pastes ever vanishes. |
| 241 | + const deadPath = path.join(dir, 'ZzE2eDeadPath.md') |
| 242 | + await pastePlainText(page, deadPath) |
| 243 | + const note = page.getByTestId('atlas-sticky-note').filter({ hasText: 'ZzE2eDeadPath' }) |
| 244 | + await expect(note).toBeVisible() |
| 245 | + |
| 246 | + // Cleanup (shared pool): note, diagram object, card. |
| 247 | + await deleteSticky(page, note) |
| 248 | + await deleteObjectViaMenu(diagram) |
| 249 | + await expect(diagram).toHaveCount(0) |
| 250 | + await card.click({ button: 'right' }) |
| 251 | + const menu = contextMenu(page) |
| 252 | + await expect(menu).toBeVisible() |
| 253 | + await menu.getByText('Delete', { exact: true }).click() |
| 254 | + await expect(card).toHaveCount(0) |
| 255 | + } finally { |
| 256 | + fs.rmSync(dir, { recursive: true, force: true }) |
| 257 | + } |
| 258 | +}) |
| 259 | + |
| 260 | +// Regression: the board's window-level paste door stayed live while a |
| 261 | +// card page (a modal dialog) covered the board -- pasting with focus |
| 262 | +// on no field landed a sticky note INVISIBLY behind the dialog. The |
| 263 | +// door now stands down while a modal surface is open, and comes back |
| 264 | +// the moment it closes. |
| 265 | +test('pasting while a card page is open lands nothing behind it; the door returns on close', async ({ page }) => { |
| 266 | + await page.goto('/') |
| 267 | + await page.getByRole('link', { name: 'Atlas' }).click() |
| 268 | + await expect(page.getByTestId('atlas-board')).toBeVisible() |
| 269 | + |
| 270 | + const card = page.getByTestId('atlas-note-card').filter({ hasText: 'Discovery workstream' }).first() |
| 271 | + await openCard(page, card) |
| 272 | + const overlay = page.locator('[data-component="atlas-card-overlay"]') |
| 273 | + await expect(overlay).toBeVisible() |
| 274 | + // Land focus on nothing editable: click the page's own header region |
| 275 | + // (the real state a user reaches by clicking any non-field area). |
| 276 | + await page.getByTestId('atlas-page-header').click() |
| 277 | + |
| 278 | + await pastePlainText(page, 'ZzE2eModalGateProbe') |
| 279 | + // No observable "nothing happened" signal exists to await -- a fixed |
| 280 | + // settle window is the only way to assert the note did NOT land. |
| 281 | + await page.waitForTimeout(800) // asserting a no-op: no observable condition exists to await |
| 282 | + await expect(page.getByTestId('atlas-sticky-note').filter({ hasText: 'ZzE2eModalGateProbe' })).toHaveCount(0) |
| 283 | + |
| 284 | + await closeCard(page, overlay) |
| 285 | + |
| 286 | + // The same paste with the board foreground again lands its note. |
| 287 | + await pastePlainText(page, 'ZzE2eModalGateProbe') |
| 288 | + const note = page.getByTestId('atlas-sticky-note').filter({ hasText: 'ZzE2eModalGateProbe' }) |
| 289 | + await expect(note).toBeVisible() |
| 290 | + await deleteSticky(page, note) |
| 291 | +}) |
0 commit comments