|
| 1 | +package main |
| 2 | + |
| 3 | +import ( |
| 4 | + "fmt" |
| 5 | + "os" |
| 6 | + "path/filepath" |
| 7 | + "time" |
| 8 | +) |
| 9 | + |
| 10 | +// A minimal two-page drawio file: page switching only exists with 2+ |
| 11 | +// pages, so a single-page fixture could never exercise the editor's |
| 12 | +// tab bar at all. |
| 13 | +const drawioTwoPageFixture = `<mxfile host="app.diagrams.net" type="device"> |
| 14 | + <diagram id="smoke-page-one" name="Overview"> |
| 15 | + <mxGraphModel dx="800" dy="600" grid="1" gridSize="10" page="1" pageScale="1" pageWidth="850" pageHeight="1100"> |
| 16 | + <root> |
| 17 | + <mxCell id="0" /> |
| 18 | + <mxCell id="1" parent="0" /> |
| 19 | + <mxCell id="a1" value="Alpha" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1"> |
| 20 | + <mxGeometry x="120" y="120" width="120" height="60" as="geometry" /> |
| 21 | + </mxCell> |
| 22 | + </root> |
| 23 | + </mxGraphModel> |
| 24 | + </diagram> |
| 25 | + <diagram id="smoke-page-two" name="Detail"> |
| 26 | + <mxGraphModel dx="800" dy="600" grid="1" gridSize="10" page="1" pageScale="1" pageWidth="850" pageHeight="1100"> |
| 27 | + <root> |
| 28 | + <mxCell id="0" /> |
| 29 | + <mxCell id="1" parent="0" /> |
| 30 | + <mxCell id="b1" value="Gamma" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1"> |
| 31 | + <mxGeometry x="200" y="160" width="140" height="70" as="geometry" /> |
| 32 | + </mxCell> |
| 33 | + </root> |
| 34 | + </mxGraphModel> |
| 35 | + </diagram> |
| 36 | +</mxfile>` |
| 37 | + |
| 38 | +// landTwoPageDiagram writes the fixture to a throwaway path and lands |
| 39 | +// it as a diagram board object through the same RPC a native drop |
| 40 | +// uses, waiting until the board face renders and settles. |
| 41 | +func landTwoPageDiagram(c mcpCaller) error { |
| 42 | + dir, err := os.MkdirTemp("", "mill-smoke-drawio") |
| 43 | + if err != nil { |
| 44 | + return err |
| 45 | + } |
| 46 | + fixturePath := filepath.Join(dir, "two-page-smoke.drawio") |
| 47 | + if err := os.WriteFile(fixturePath, []byte(drawioTwoPageFixture), 0o600); err != nil { |
| 48 | + return err |
| 49 | + } |
| 50 | + parentID, err := gettingStartedParentID(c) |
| 51 | + if err != nil { |
| 52 | + return err |
| 53 | + } |
| 54 | + var obj struct { |
| 55 | + ID string `json:"ID"` |
| 56 | + } |
| 57 | + if err := callBoundJSON(c, "github.com/alicoding/mill/internal/services/atlassvc.AtlasService.CreateBoardObject", |
| 58 | + []any{"diagram", map[string]any{"mirrorPath": fixturePath, "title": "smoke two-page"}, map[string]any{"X": 620, "Y": 340}, parentID}, &obj); err != nil { |
| 59 | + return err |
| 60 | + } |
| 61 | + bodySel := `[data-testid="atlas-drawio-page-body"]` |
| 62 | + if err := pollJSEval(c, fmt.Sprintf(`return !!document.querySelector('%s');`, bodySel), 15*time.Second); err != nil { |
| 63 | + return fmt.Errorf("diagram board face never rendered after CreateBoardObject: %w", err) |
| 64 | + } |
| 65 | + if err := waitForNodeStable(c, bodySel); err != nil { |
| 66 | + return fmt.Errorf("board never settled before the band click: %w", err) |
| 67 | + } |
| 68 | + return nil |
| 69 | +} |
| 70 | + |
| 71 | +// bandSel is the diagram's chrome band -- its ONLY selection surface |
| 72 | +// (the vendored viewer captures body clicks for pan/zoom). |
| 73 | +const drawioBandSel = `.react-flow__node [data-testid="atlas-board-object-frame"]` |
| 74 | + |
| 75 | +// openDrawioEditor double-clicks the band to open the embedded editor. |
| 76 | +// Two bridge clicks land faster than the OS double-click threshold |
| 77 | +// reliably here; a synthesized dblclick is the one escape hatch, |
| 78 | +// allowed because the bridge has no native dblclick primitive and the |
| 79 | +// double-click GATE itself is proven by e2e. |
| 80 | +func openDrawioEditor(c mcpCaller, dialogSel string) error { |
| 81 | + for i := 0; i < 2; i++ { |
| 82 | + if _, err := c.call("mouse_click", withWindow(map[string]any{"selector": drawioBandSel})); err != nil { |
| 83 | + return err |
| 84 | + } |
| 85 | + } |
| 86 | + if pollJSEval(c, fmt.Sprintf(`return !!document.querySelector('%s iframe');`, dialogSel), 20*time.Second) == nil { |
| 87 | + return nil |
| 88 | + } |
| 89 | + if err := pollJSEval(c, fmt.Sprintf(`const band = document.querySelector('%s'); |
| 90 | + if (!band) return false; |
| 91 | + band.dispatchEvent(new MouseEvent('dblclick', {bubbles: true, cancelable: true})); |
| 92 | + return true;`, drawioBandSel), 3*time.Second); err != nil { |
| 93 | + return fmt.Errorf("no chrome band to double-click: %w", err) |
| 94 | + } |
| 95 | + if err := pollJSEval(c, fmt.Sprintf(`return !!document.querySelector('%s iframe');`, dialogSel), 20*time.Second); err != nil { |
| 96 | + return fmt.Errorf("embedded editor dialog never opened: %w", err) |
| 97 | + } |
| 98 | + return nil |
| 99 | +} |
| 100 | + |
| 101 | +func checkDrawioEditorLayout(c mcpCaller) (string, error) { |
| 102 | + if err := landTwoPageDiagram(c); err != nil { |
| 103 | + return "", err |
| 104 | + } |
| 105 | + |
| 106 | + // Selecting the band must produce the shared NodeResizer's full |
| 107 | + // handle set in the real engine, exactly as Chromium renders it. |
| 108 | + if _, err := c.call("mouse_click", withWindow(map[string]any{"selector": drawioBandSel})); err != nil { |
| 109 | + return "", err |
| 110 | + } |
| 111 | + if err := pollJSEval(c, `return document.querySelectorAll('.react-flow__resize-control').length >= 8;`, 5*time.Second); err != nil { |
| 112 | + return "", fmt.Errorf("resize handles never appeared after selecting the diagram's chrome band: %w", err) |
| 113 | + } |
| 114 | + |
| 115 | + dialogSel := `[data-component="atlas-drawio-editor-dialog"]` |
| 116 | + if err := openDrawioEditor(c, dialogSel); err != nil { |
| 117 | + return "", err |
| 118 | + } |
| 119 | + |
| 120 | + // The clipping class this check pins (goal 0259): the editor's |
| 121 | + // bottom chrome -- where drawio's own page tabs live -- must sit |
| 122 | + // INSIDE the real window, and the tab bar itself must be present |
| 123 | + // and reachable for a multi-page file. Same-origin iframe, so its |
| 124 | + // document is directly measurable. |
| 125 | + if err := pollJSEval(c, fmt.Sprintf(`const f = document.querySelector('%s iframe'); |
| 126 | + const d = f && f.contentDocument; |
| 127 | + return !!(d && d.querySelector('.geTabContainer'));`, dialogSel), 30*time.Second); err != nil { |
| 128 | + return "", fmt.Errorf("the editor's page-tab bar (.geTabContainer) never appeared for a two-page file: %w", err) |
| 129 | + } |
| 130 | + verdict, err := c.call("js_eval", withWindow(map[string]any{ |
| 131 | + "js": fmt.Sprintf(`const dlg = document.querySelector('%s'); |
| 132 | + const f = dlg.querySelector('iframe'); |
| 133 | + const dr = dlg.getBoundingClientRect(); |
| 134 | + const fr = f.getBoundingClientRect(); |
| 135 | + const tabs = f.contentDocument.querySelector('.geTabContainer'); |
| 136 | + const tr = tabs.getBoundingClientRect(); |
| 137 | + const tabsBottomInWindow = fr.top + tr.bottom; |
| 138 | + const H = window.innerHeight; |
| 139 | + if (dr.bottom > H + 1) return "dialog-overflows:" + Math.round(dr.bottom) + ">" + H; |
| 140 | + if (fr.bottom > dr.bottom + 1) return "iframe-overflows-dialog:" + Math.round(fr.bottom) + ">" + Math.round(dr.bottom); |
| 141 | + if (tabsBottomInWindow > H + 1) return "tabs-clipped:" + Math.round(tabsBottomInWindow) + ">" + H; |
| 142 | + if (tr.height < 8) return "tabs-zero-height"; |
| 143 | + return "ok dialog=" + Math.round(dr.height) + "px iframe=" + Math.round(fr.height) + "px tabsBottom=" + Math.round(tabsBottomInWindow) + "px window=" + H + "px";`, dialogSel), |
| 144 | + })) |
| 145 | + if err != nil { |
| 146 | + return "", err |
| 147 | + } |
| 148 | + if len(verdict) < 2 || verdict[:2] != "ok" { |
| 149 | + return "", fmt.Errorf("editor layout broken in the real engine: %s", verdict) |
| 150 | + } |
| 151 | + return "board face selectable via band with full resize handles; editor dialog, iframe, and the two-page tab bar all inside the real window (" + verdict + ")", nil |
| 152 | +} |
0 commit comments