Spaces:
Sleeping
Sleeping
File size: 184,864 Bytes
6912fd3 b60973d 6912fd3 735a30f 6912fd3 93ef16f 6912fd3 b60973d 6912fd3 b60973d 6912fd3 f35d3f6 6912fd3 f35d3f6 505a1e7 6912fd3 d3283b8 6912fd3 f35d3f6 6912fd3 ec6b346 8012b6f b60973d 6912fd3 53148eb 784680a 6246de6 b60973d 784680a 53148eb 6912fd3 735a30f f361450 735a30f f361450 085e5b4 f361450 735a30f ec6b346 6912fd3 735a30f 6912fd3 11af3e7 6912fd3 784680a ec3a520 11af3e7 ec3a520 784680a 6912fd3 b60973d 93ef16f 6912fd3 a54b6e7 11af3e7 a54b6e7 b60973d 735a30f 085e5b4 735a30f 085e5b4 735a30f 784680a 6246de6 b60973d 6912fd3 8ae88e7 d3283b8 6912fd3 d3283b8 6912fd3 8ae88e7 b60973d 8ae88e7 6912fd3 ec3a520 735a30f fcffddc ec3a520 b60973d 735a30f b60973d fcffddc ec3a520 6912fd3 63bc919 085e5b4 b60973d 085e5b4 b60973d 2679e4e fcffddc 2679e4e ed7ee81 d3283b8 ed7ee81 6912fd3 735a30f 6912fd3 b60973d 735a30f b60973d 6912fd3 ec3a520 735a30f ec3a520 fcffddc 735a30f fcffddc ec3a520 fcffddc 124d540 735a30f 124d540 1c42947 21092d1 735a30f 21092d1 45ebf29 779f7df 46a2576 319c03c 1596003 779f7df 675fbd2 779f7df 46a2576 1596003 779f7df 46a2576 319c03c 1596003 319c03c 779f7df 46a2576 779f7df 1596003 779f7df 319c03c 779f7df 46a2576 779f7df 29c066e 675fbd2 29c066e 675fbd2 a404034 675fbd2 29c066e 675fbd2 29c066e 675fbd2 29c066e a404034 29c066e a404034 29c066e a404034 675fbd2 29c066e a404034 29c066e a404034 29c066e a404034 29c066e a404034 675fbd2 a404034 29c066e 675fbd2 2460317 f6cf611 f64215e 45ebf29 735a30f 45ebf29 b60973d 7efcba7 735a30f 7efcba7 80e566e 4d9b5d1 80e566e 4d9b5d1 80e566e e5e233a f35d3f6 e5e233a 94f8eb1 2ad2aff 4d9b5d1 2ad2aff 7e842fd f35d3f6 9d479ea 9b49194 9d479ea 9b49194 9d479ea aeb6b2b 3b2fb14 63bc919 3b2fb14 9d479ea 9b49194 9d479ea 8c82c03 064739c 8c82c03 064739c 8c82c03 064739c 8c82c03 e5e233a aedebe4 e5e233a 26dfc7b a54b6e7 26dfc7b 7cdc17b 26dfc7b e5e233a 085e5b4 e5e233a 735a30f e5e233a 735a30f e5e233a d3283b8 5c89c6a 735a30f 5c89c6a fbcd415 63bc919 f9ca812 e5d08da f9ca812 e5d08da f9ca812 e5d08da f9ca812 e5d08da f9ca812 e5d08da f9ca812 e5d08da f9ca812 e5d08da f9ca812 e5d08da f9ca812 e5d08da f9ca812 e5d08da f9ca812 e5d08da 5c89c6a d3283b8 e5e233a d3283b8 e5e233a 93ef16f 6912fd3 93ef16f f9ca812 93ef16f 29c066e 93ef16f 8012b6f 93ef16f aeb6b2b 93ef16f 6912fd3 | 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 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 1682 1683 1684 1685 1686 1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 1697 1698 1699 1700 1701 1702 1703 1704 1705 1706 1707 1708 1709 1710 1711 1712 1713 1714 1715 1716 1717 1718 1719 1720 1721 1722 1723 1724 1725 1726 1727 1728 1729 1730 1731 1732 1733 1734 1735 1736 1737 1738 1739 1740 1741 1742 1743 1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 1755 1756 1757 1758 1759 1760 1761 1762 1763 1764 1765 1766 1767 1768 1769 1770 1771 1772 1773 1774 1775 1776 1777 1778 1779 1780 1781 1782 1783 1784 1785 1786 1787 1788 1789 1790 1791 1792 1793 1794 1795 1796 1797 1798 1799 1800 1801 1802 1803 1804 1805 1806 1807 1808 1809 1810 1811 1812 1813 1814 1815 1816 1817 1818 1819 1820 1821 1822 1823 1824 1825 1826 1827 1828 1829 1830 1831 1832 1833 1834 1835 1836 1837 1838 1839 1840 1841 1842 1843 1844 1845 1846 1847 1848 1849 1850 1851 1852 1853 1854 1855 1856 1857 1858 1859 1860 1861 1862 1863 1864 1865 1866 1867 1868 1869 1870 1871 1872 1873 1874 1875 1876 1877 1878 1879 1880 1881 1882 1883 1884 1885 1886 1887 1888 1889 1890 1891 1892 1893 1894 1895 1896 1897 1898 1899 1900 1901 1902 1903 1904 1905 1906 1907 1908 1909 1910 1911 1912 1913 1914 1915 1916 1917 1918 1919 1920 1921 1922 1923 1924 1925 1926 1927 1928 1929 1930 1931 1932 1933 1934 1935 1936 1937 1938 1939 1940 1941 1942 1943 1944 1945 1946 1947 1948 1949 1950 1951 1952 1953 1954 1955 1956 1957 1958 1959 1960 1961 1962 1963 1964 1965 1966 1967 1968 1969 1970 1971 1972 1973 1974 1975 1976 1977 1978 1979 1980 1981 1982 1983 1984 1985 1986 1987 1988 1989 1990 1991 1992 1993 1994 1995 1996 1997 1998 1999 2000 2001 2002 2003 2004 2005 2006 2007 2008 2009 2010 2011 2012 2013 2014 2015 2016 2017 2018 2019 2020 2021 2022 2023 2024 2025 2026 2027 2028 2029 2030 2031 2032 2033 2034 2035 2036 2037 2038 2039 2040 2041 2042 2043 2044 2045 2046 2047 2048 2049 2050 2051 2052 2053 2054 2055 2056 2057 2058 2059 2060 2061 2062 2063 2064 2065 2066 2067 2068 2069 2070 2071 2072 2073 2074 2075 2076 2077 2078 2079 2080 2081 2082 2083 2084 2085 2086 2087 2088 2089 2090 2091 2092 2093 2094 2095 2096 2097 2098 2099 2100 2101 2102 2103 2104 2105 2106 2107 2108 2109 2110 2111 2112 2113 2114 2115 2116 2117 2118 2119 2120 2121 2122 2123 2124 2125 2126 2127 2128 2129 2130 2131 2132 2133 2134 2135 2136 2137 2138 2139 2140 2141 2142 2143 2144 2145 2146 2147 2148 2149 2150 2151 2152 2153 2154 2155 2156 2157 2158 2159 2160 2161 2162 2163 2164 2165 2166 2167 2168 2169 2170 2171 2172 2173 2174 2175 2176 2177 2178 2179 2180 2181 2182 2183 2184 2185 2186 2187 2188 2189 2190 2191 2192 2193 2194 2195 2196 2197 2198 2199 2200 2201 2202 2203 2204 2205 2206 2207 2208 2209 2210 2211 2212 2213 2214 2215 2216 2217 2218 2219 2220 2221 2222 2223 2224 2225 2226 2227 2228 2229 2230 2231 2232 2233 2234 2235 2236 2237 2238 2239 2240 2241 2242 2243 2244 2245 2246 2247 2248 2249 2250 2251 2252 2253 2254 2255 2256 2257 2258 2259 2260 2261 2262 2263 2264 2265 2266 2267 2268 2269 2270 2271 2272 2273 2274 2275 2276 2277 2278 2279 2280 2281 2282 2283 2284 2285 2286 2287 2288 2289 2290 2291 2292 2293 2294 2295 2296 2297 2298 2299 2300 2301 2302 2303 2304 2305 2306 2307 2308 2309 2310 2311 2312 2313 2314 2315 2316 2317 2318 2319 2320 2321 2322 2323 2324 2325 2326 2327 2328 2329 2330 2331 2332 2333 2334 2335 2336 2337 2338 2339 2340 2341 2342 2343 2344 2345 2346 2347 2348 2349 2350 2351 2352 2353 2354 2355 2356 2357 2358 2359 2360 2361 2362 2363 2364 2365 2366 2367 2368 2369 2370 2371 2372 2373 2374 2375 2376 2377 2378 2379 2380 2381 2382 2383 2384 2385 2386 2387 2388 2389 2390 2391 2392 2393 2394 2395 2396 2397 2398 2399 2400 2401 2402 2403 2404 2405 2406 2407 2408 2409 2410 2411 2412 2413 2414 2415 2416 2417 2418 2419 2420 2421 2422 2423 2424 2425 2426 2427 2428 2429 2430 2431 2432 2433 2434 2435 2436 2437 2438 2439 2440 2441 2442 2443 2444 2445 2446 2447 2448 2449 2450 2451 2452 2453 2454 2455 2456 2457 2458 2459 2460 2461 2462 2463 2464 2465 2466 2467 2468 2469 2470 2471 2472 2473 2474 2475 2476 2477 2478 2479 2480 2481 2482 2483 2484 2485 2486 2487 2488 2489 2490 2491 2492 2493 2494 2495 2496 2497 2498 2499 2500 2501 2502 2503 2504 2505 2506 2507 2508 2509 2510 2511 2512 2513 2514 2515 2516 2517 2518 2519 2520 2521 2522 2523 2524 2525 2526 2527 2528 2529 2530 2531 2532 2533 2534 2535 2536 2537 2538 2539 2540 2541 2542 2543 2544 2545 2546 2547 2548 2549 2550 2551 2552 2553 2554 2555 2556 2557 2558 2559 2560 2561 2562 2563 2564 2565 2566 2567 2568 2569 2570 2571 2572 2573 2574 2575 2576 2577 2578 2579 2580 2581 2582 2583 2584 2585 2586 2587 2588 2589 2590 2591 2592 2593 2594 2595 2596 2597 2598 2599 2600 2601 2602 2603 2604 2605 2606 2607 2608 2609 2610 2611 2612 2613 2614 2615 2616 2617 2618 2619 2620 2621 2622 2623 2624 2625 2626 2627 2628 2629 2630 2631 2632 2633 2634 2635 2636 2637 2638 2639 2640 2641 2642 2643 2644 2645 2646 2647 2648 2649 2650 2651 2652 2653 2654 2655 2656 2657 2658 2659 2660 2661 2662 2663 2664 2665 2666 2667 2668 2669 2670 2671 2672 2673 2674 2675 2676 2677 2678 2679 2680 2681 2682 2683 2684 2685 2686 2687 2688 2689 2690 2691 2692 2693 2694 2695 2696 2697 2698 2699 2700 2701 2702 2703 2704 2705 2706 2707 2708 2709 2710 2711 2712 2713 2714 2715 2716 2717 2718 2719 2720 2721 2722 2723 2724 2725 2726 2727 2728 2729 2730 2731 2732 2733 2734 2735 2736 2737 2738 2739 2740 2741 2742 2743 2744 2745 2746 2747 2748 2749 2750 2751 2752 2753 2754 2755 2756 2757 2758 2759 2760 2761 2762 2763 2764 2765 2766 2767 2768 2769 2770 2771 2772 2773 2774 2775 2776 2777 2778 2779 2780 2781 2782 2783 2784 2785 2786 2787 2788 2789 2790 2791 2792 2793 2794 2795 2796 2797 2798 2799 2800 2801 2802 2803 2804 2805 2806 2807 2808 2809 2810 2811 2812 2813 2814 2815 2816 2817 2818 2819 2820 2821 2822 2823 2824 2825 2826 2827 2828 2829 2830 2831 2832 2833 2834 2835 2836 2837 2838 2839 2840 2841 2842 2843 2844 2845 2846 2847 2848 2849 2850 2851 2852 2853 2854 2855 2856 2857 2858 2859 2860 2861 2862 2863 2864 2865 2866 2867 2868 2869 2870 2871 2872 2873 2874 2875 2876 2877 2878 2879 2880 2881 2882 2883 2884 2885 2886 2887 2888 2889 2890 2891 2892 2893 2894 2895 2896 2897 2898 2899 2900 2901 2902 2903 2904 2905 2906 2907 2908 2909 2910 2911 2912 2913 2914 2915 2916 2917 2918 2919 2920 2921 2922 2923 2924 2925 2926 2927 2928 2929 2930 2931 2932 2933 2934 2935 2936 2937 2938 2939 2940 2941 2942 2943 2944 2945 2946 2947 2948 2949 2950 2951 2952 2953 2954 2955 2956 2957 2958 2959 2960 2961 2962 2963 2964 2965 2966 2967 2968 2969 2970 2971 2972 2973 2974 2975 2976 2977 2978 2979 2980 2981 2982 2983 2984 2985 2986 2987 2988 2989 2990 2991 2992 2993 2994 2995 2996 2997 2998 2999 3000 3001 3002 3003 3004 3005 3006 3007 3008 3009 3010 3011 3012 3013 3014 3015 3016 3017 3018 3019 3020 3021 3022 3023 3024 3025 3026 3027 3028 3029 3030 3031 3032 | // Real-browser UI test (chromium via playwright-core). Drives the actual editor:
// login, typing, selection -> comment/suggest, margin cards, images, discussion.
// node test/browser.js
import { spawn } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import assert from 'node:assert'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright-core'
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
const PORT = 3299
const BASE = `http://localhost:${PORT}`
const DATA = path.join(root, '.browser-data')
const SHOT = process.env.SHOT_DIR || '/tmp/claude-1000/ui-shots'
fs.rmSync(DATA, { recursive: true, force: true })
fs.mkdirSync(SHOT, { recursive: true })
const server = spawn('node', ['server/index.js'], {
cwd: root,
env: { ...process.env, PORT: String(PORT), DATA_DIR: DATA, OAUTH_CLIENT_ID: '', OAUTH_CLIENT_SECRET: '' },
stdio: ['ignore', 'pipe', 'pipe'],
})
server.stderr.on('data', d => process.stderr.write('[server] ' + d))
// Put the caret at the end of the document without depending on where things
// happen to sit: Playwright clicks an element's centre, and if an atom node (a
// formula, an image) is there, the click opens THAT instead of placing a caret.
async function focusDocEnd(page) {
// Put the caret at the end of the document, reliably. Two traps here:
// - typing before the document has synced loses those keystrokes, because
// the remote state then replaces them (the skeleton lifting is the signal);
// - editor.commands.focus() sets the selection but does NOT take DOM focus
// in headless Chromium, so the keystrokes go to the body instead.
// Hence a real click, at a point that is always text: the title line. Clicking
// the element's centre would be a gamble — an atom (a formula, an image)
// sitting there swallows the click and every keystroke after it.
await page.waitForFunction(() => document.getElementById('doc-skeleton')?.classList.contains('hidden'), null, { timeout: 20000 })
await page.waitForFunction(() => window.__editor && !window.__editor.isDestroyed)
await page.click('.tiptap', { position: { x: 24, y: 10 } })
await page.waitForFunction(() => window.__editor?.isFocused, null, { timeout: 5000 })
await page.keyboard.press('Control+End')
}
const consoleErrors = []
async function main() {
await waitFor(async () => (await fetch(`${BASE}/healthz`)).ok, 'server')
const browser = await chromium.launch({
headless: true,
executablePath: process.env.PW_EXECUTABLE || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage', '--single-process'],
})
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
page.on('console', msg => {
if (msg.type() === 'error' && !msg.text().includes('fonts.googleapis') && !msg.text().includes('fonts.gstatic')) {
consoleErrors.push(msg.text())
}
})
page.on('pageerror', err => consoleErrors.push('PAGEERROR: ' + err.message))
// --- login + create doc, from the switcher rather than an index page ---
await page.goto(`${BASE}/auth/dev?u=alice&next=/`)
// With no documents yet, / has nowhere to send us and serves the bare shell.
// The switcher's loading state has to be in the markup the server sends, not
// built by JS — that is what makes it visible on the first frame.
const shellHtml = await (await page.request.get(`${BASE}/`)).text()
assert.ok((shellHtml.match(/sk-card/g) || []).length >= 3, 'shell ships skeleton cards for the switcher')
assert.ok(shellHtml.includes('id="docs-list" class="loading"'), 'switcher list starts in its loading state')
assert.ok(shellHtml.includes('window.__BOOT'), 'shell inlines the session')
await page.waitForSelector('#docs-pop:not(.hidden)', { timeout: 10000 })
// Nothing will ever sync here, so the skeleton the shell ships has to come
// down explicitly — left up, a first visit reads as a document stuck loading.
await waitFor(async () => await page.evaluate(
() => document.getElementById('doc-skeleton')?.classList.contains('hidden')),
'the skeleton comes down on a first visit with no documents')
assert.ok(await page.$('.empty-doc'), 'the empty sheet says how to make the first document')
await page.click('#new-doc')
await page.waitForSelector('.ui-modal input')
await page.fill('.ui-modal input', 'Browser Test Doc')
await page.keyboard.press('Enter')
await page.waitForURL('**/d/**')
await page.waitForSelector('.tiptap', { timeout: 10000 })
console.log('✓ login + doc creation from the switcher + editor mounted')
// --- the shell paints a loading state, and carries the session inline ---
const bootInline = await page.evaluate(() => !!window.__BOOT?.user)
assert.ok(bootInline, 'the session is inlined in the shell (no /api/me round trip)')
const meCalls = await page.evaluate(() =>
performance.getEntriesByType('resource').filter(r => r.name.endsWith('/api/me')).length
)
assert.equal(meCalls, 0, 'no /api/me request was needed')
const assets = await page.evaluate(() =>
performance.getEntriesByType('resource')
.filter(r => /\.(js|css)\?v=/.test(r.name))
.map(r => ({ name: r.name.split('/').pop(), enc: r.encodedBodySize, dec: r.decodedBodySize }))
)
assert.ok(assets.length >= 2, 'versioned assets were fetched: ' + JSON.stringify(assets))
for (const a of assets) {
assert.ok(a.enc > 0 && a.enc < a.dec * 0.6, `${a.name} arrived compressed (${a.enc} of ${a.dec} bytes)`)
}
console.log('✓ shell: session inlined, bundles served compressed')
// --- theme: light by default, toggle sticks across a reload ---
const themeState = () =>
page.evaluate(() => {
const sum = c => (c.match(/\d+/g) || []).slice(0, 3).reduce((t, n) => t + +n, 0)
return {
theme: document.documentElement.dataset.theme,
stored: localStorage.getItem('cw-theme'),
bg: sum(getComputedStyle(document.body).backgroundColor),
ink: sum(getComputedStyle(document.querySelector('.tiptap')).color),
page: sum(getComputedStyle(document.getElementById('editor')).backgroundColor),
}
})
const lightState = await themeState()
assert.equal(lightState.theme, 'light', 'starts light when the OS is light: ' + JSON.stringify(lightState))
assert.ok(lightState.bg > 700 && lightState.ink < 60, 'light: pale canvas, black ink: ' + JSON.stringify(lightState))
await page.click('#theme-btn')
await waitFor(async () => (await themeState()).theme === 'dark', 'toggle switches to dark')
const darkState = await themeState()
assert.ok(darkState.bg < 160 && darkState.ink > 600, 'dark: dark canvas, light ink: ' + JSON.stringify(darkState))
assert.ok(darkState.page < 200 && darkState.page > darkState.bg, 'dark: the sheet sits above the canvas: ' + JSON.stringify(darkState))
assert.equal(darkState.stored, 'dark', 'choice is stored')
await page.reload()
await page.waitForSelector('.tiptap')
const afterReload = await themeState()
assert.equal(afterReload.theme, 'dark', 'dark survives a reload (no light flash path)')
await page.click('#theme-btn')
await waitFor(async () => (await themeState()).theme === 'light', 'toggle switches back to light')
console.log('✓ theme: OS default, toggle to dark, persists, toggles back')
// register an agent handle (agents live in a header popover now)
await page.click('#agents-btn')
await page.fill('#agent-handle', 'ui-agent')
await page.click('#agent-form button')
await page.waitForSelector('.agent-row')
// registration issues a one-time agent key
await page.waitForSelector('.key-box code', { timeout: 5000 })
const shownKey = await page.textContent('.key-box code')
assert.ok(shownKey.startsWith('ak_'), 'agent key shown once: ' + shownKey.slice(0, 6))
// a second handle, so the @ menu has a list to arrow through
await page.fill('#agent-handle', 'ui-agent-two')
await page.click('#agent-form button')
await waitFor(async () => (await page.textContent('#agents-list')).includes('ui-agent-two'), 'second agent registered')
await waitFor(async () => (await page.$$('.key-box code')).length > 1, 'second agent key issued')
const shownKeyTwo = (await page.$$eval('.key-box code', els => els.map(e => e.textContent)))[1]
assert.ok(shownKeyTwo.startsWith('ak_') && shownKeyTwo !== shownKey, 'second agent has its own key')
await page.click('#agents-btn') // close
console.log('✓ agents registered via header panel, key issued')
// --- sharing UI ---
await page.click('#share-btn')
await page.waitForSelector('#share-pop:not(.hidden)')
await waitFor(async () => (await page.textContent('#share-list')).includes('owner'), 'share list shows owner')
await page.fill('#share-user', 'bob')
await page.click('#share-form button')
await waitFor(async () => (await page.textContent('#share-list')).includes('bob'), 'bob added to share list')
await page.click('#share-btn') // close
console.log('✓ share panel: add collaborator')
// --- LaTeX math: typed, typeset by KaTeX, source editable ---
await focusDocEnd(page)
await page.keyboard.press('Enter')
await page.keyboard.type('Einstein said $E = mc^2$ and meant it.')
await page.waitForSelector('.tiptap .math-inline', { timeout: 5000 })
// KaTeX is lazy: it must NOT be in the main bundle, and must arrive on demand
await waitFor(async () => await page.evaluate(() => !!window.katex), 'katex loads on demand')
const katexFetches = await page.evaluate(() =>
performance.getEntriesByType('resource').filter(r => /katex\.(js|css)/.test(r.name)).map(r => r.name.split('/').pop())
)
assert.equal(katexFetches.length, 2, 'katex js + css fetched separately from the main bundle: ' + katexFetches)
await page.waitForSelector('.tiptap .math-inline .katex', { timeout: 5000 })
const rendered = await page.textContent('.tiptap .math-inline .katex')
assert.ok(rendered.includes('E') && rendered.includes('mc'), 'formula is typeset, not raw source: ' + rendered)
assert.ok(!(await page.textContent('.tiptap')).includes('$E = mc^2$'), 'the $ delimiters are gone from the text')
// double-clicking a formula opens its LaTeX source; Enter commits the edit
await page.dblclick('.tiptap .math-inline .math-view')
await page.waitForSelector('.tiptap .math-inline.editing .math-src', { timeout: 5000 })
assert.equal(await page.inputValue('.math-inline.editing .math-src'), 'E = mc^2', 'the source is what you edit')
await page.fill('.math-inline.editing .math-src', 'E = mc^3')
await page.keyboard.press('Enter')
await waitFor(async () => (await page.textContent('.tiptap .math-inline .katex')).includes('mc'), 'edited formula re-renders')
assert.ok(!(await page.$('.math-inline.editing')), 'Enter closes the source editor')
// a display formula from $$ on its own line
await focusDocEnd(page) // the caret was in the previous formula's source input
await page.keyboard.press('Enter')
await page.keyboard.type('$$')
await page.waitForSelector('.tiptap .math-block', { timeout: 5000 })
await page.waitForSelector('.tiptap .math-block.editing .math-src', { timeout: 5000 })
await page.fill('.math-block.editing .math-src', '\\int_0^1 x^2 dx')
await page.keyboard.press('Enter')
await page.waitForSelector('.tiptap .math-block .katex-display', { timeout: 5000 })
console.log('✓ math: $x$ typesets via lazily-loaded KaTeX, source click-to-edit, $$ display block')
// --- a formula can be selected and commented on ---
// Single click selects it (that is how you comment on one) and must NOT open
// the source editor; focusing the comment box must not dismiss the selection.
await page.click('.tiptap .math-inline .math-view')
const picked = await page.evaluate(() => ({
editing: document.querySelectorAll('.math-inline.editing').length,
selNode: window.__editor.state.selection.node?.type?.name || null,
}))
assert.equal(picked.editing, 0, 'a single click does not open the source editor')
assert.equal(picked.selNode, 'mathInline', 'a single click selects the formula: ' + JSON.stringify(picked))
await page.waitForSelector('#composer:not(.hidden)', { timeout: 5000 })
await page.click('#composer-text')
await page.waitForTimeout(300)
assert.ok(await page.isVisible('#composer'), 'focusing the comment box does not dismiss the formula selection')
// the quote shows the formula source, not an empty string
const mathQuote = await page.textContent('#composer-quote')
assert.ok(mathQuote.includes('mc'), 'the comment quotes the formula source: ' + JSON.stringify(mathQuote))
await page.fill('#composer-text', '@ui-agent is this the right constant?')
await page.click('#composer-send')
await waitFor(async () => (await page.textContent('#margin-items')).includes('right constant'), 'comment on a formula posts')
// close it: later assertions count the open threads on this document
await page.click('.card.thread.expanded .close-btn')
await waitFor(async () => !(await page.$('.card.thread')), 'this test cleans up its thread')
console.log('✓ math: single click selects a formula, comment box keeps the selection and quotes the source')
// dragging a text selection through a formula must work, not open the editor
const dragBox = await page.evaluate(() => {
const p = [...document.querySelectorAll('.tiptap p')].find(n => n.querySelector('.math-inline'))
const r = p.getBoundingClientRect()
return { x1: r.left + 4, x2: r.right - 4, y: r.top + r.height / 2 }
})
await page.mouse.move(dragBox.x1, dragBox.y)
await page.mouse.down()
await page.mouse.move(dragBox.x2, dragBox.y, { steps: 20 })
await page.mouse.up()
const dragged = await page.evaluate(() => {
const sel = window.__editor.state.selection
let hasMath = false
window.__editor.state.doc.nodesBetween(sel.from, sel.to, n => {
if (n.type.name === 'mathInline') hasMath = true
})
return { empty: sel.empty, hasMath, editing: document.querySelectorAll('.editing').length }
})
assert.ok(!dragged.empty && dragged.hasMath, 'a drag selects through the formula: ' + JSON.stringify(dragged))
assert.equal(dragged.editing, 0, 'dragging over a formula does not open its editor')
console.log('✓ math: text selection drags straight through a rendered formula')
// double click is what opens the source
await page.dblclick('.tiptap .math-inline .math-view')
await page.waitForSelector('.math-inline.editing .math-src', { timeout: 5000 })
await page.keyboard.press('Escape')
await waitFor(async () => !(await page.$('.math-inline.editing')), 'Escape closes the source editor')
console.log('✓ math: double click opens the source, Escape closes it')
// it must survive a reload — i.e. it is really in the shared document
await page.reload()
await page.waitForSelector('.tiptap .math-inline .katex', { timeout: 15000 })
await page.waitForSelector('.tiptap .math-block .katex-display', { timeout: 15000 })
console.log('✓ math persists across a reload (stored in the collaborative doc)')
// --- loading skeleton ---
const docHtml = await (await page.request.get(page.url())).text()
assert.ok(docHtml.includes('id="doc-skeleton"') && (docHtml.match(/sk-line/g) || []).length > 3,
'the doc shell ships the skeleton sheet in its markup')
assert.ok(docHtml.includes('<span id="doc-title">Browser Test Doc</span>'), 'the shell carries the real title, not a placeholder')
// plain fetch: no browser cookie jar, so this is a stranger asking
const anonHtml = await (await fetch(page.url())).text()
assert.ok(!anonHtml.includes('Browser Test Doc'), 'a shell for someone without access carries no title')
const sk = await page.evaluate(() => {
const el = document.getElementById('doc-skeleton')
return el ? { exists: true, hidden: el.classList.contains('hidden'), lines: el.querySelectorAll('.sk-line').length } : { exists: false }
})
assert.ok(sk.exists && sk.lines > 3, 'the doc shell ships a skeleton sheet: ' + JSON.stringify(sk))
assert.ok(sk.hidden, 'the skeleton is lifted once the document has synced')
console.log('✓ loading: skeleton sheet ships in the shell and lifts after sync')
// --- type into the editor ---
await focusDocEnd(page)
await page.keyboard.type('The quick brown fox jumps over the lazy dog.')
await waitFor(async () => (await page.textContent('.tiptap')).includes('quick brown fox'), 'typed text')
console.log('✓ typing works')
// --- double-click a word: the comment box opens without moving the document ---
// Put the word at the bottom of a short viewport, where scrollIntoView() on
// the margin composer used to drag the whole page down to reveal the box.
await page.setViewportSize({ width: 1440, height: 500 })
const wordPoint = await page.evaluate(() => {
const needle = 'quick'
const walker = document.createTreeWalker(document.querySelector('.tiptap'), NodeFilter.SHOW_TEXT)
let node
while ((node = walker.nextNode())) {
const index = node.textContent.indexOf(needle)
if (index < 0) continue
const range = document.createRange()
range.setStart(node, index)
range.setEnd(node, index + needle.length)
const rect = range.getBoundingClientRect()
window.scrollBy(0, rect.bottom - (window.innerHeight - 20))
const placed = range.getBoundingClientRect()
return { x: placed.left + placed.width / 2, y: placed.top + placed.height / 2 }
}
throw new Error('quick not found')
})
await page.mouse.dblclick(wordPoint.x, wordPoint.y)
await page.waitForFunction(() => !window.__editor.state.selection.empty)
const scrollBeforeComposer = await page.evaluate(() => window.scrollY)
await page.waitForSelector('#composer:not(.hidden)', { timeout: 5000 })
assert.ok(await page.isHidden('#selection-menu'), 'no floating selection widget on desktop')
await page.waitForTimeout(300)
const scrollAfterComposer = await page.evaluate(() => window.scrollY)
assert.equal(scrollAfterComposer, scrollBeforeComposer, 'opening the comment box preserves document scroll')
const composerTop = parseFloat(await page.evaluate(() => document.getElementById('composer').style.top))
assert.ok(composerTop > 30, `composer aligned to anchored text, not at column top (top=${composerTop})`)
// it comes pre-mentioning my first agent, and must NOT have taken the keyboard
const prefilled = await page.inputValue('#composer-text')
assert.equal(prefilled, '@ui-agent ', 'comment box pre-mentions my first agent: ' + JSON.stringify(prefilled))
const focusStillInDoc = await page.evaluate(() => !!document.activeElement?.closest('.tiptap'))
assert.ok(focusStillInDoc, 'auto-opened box does not steal focus from the document')
await page.fill('#composer-text', '@ui-agent please review this phrase')
await page.click('#composer-send')
await page.waitForSelector('.card.thread.expanded', { timeout: 5000 })
await waitFor(async () => (await page.textContent('#margin-items')).includes('please review'), 'thread text')
await page.waitForSelector('.card.thread .chip', { timeout: 15000 })
await page.waitForSelector('.tiptap .comment-hl', { timeout: 5000 })
await page.waitForSelector('.tiptap .comment-range-active', { timeout: 5000 })
const commentLocator = await page.evaluate(() => {
const block = document.querySelector('.tiptap .comment-range-active')
const bar = getComputedStyle(block, '::after')
return { width: parseFloat(bar.width), height: parseFloat(bar.height), color: bar.backgroundColor }
})
assert.ok(commentLocator.width >= 3 && commentLocator.height > 10 && commentLocator.color !== 'rgba(0, 0, 0, 0)', 'active comment has a visible block-height gutter marker: ' + JSON.stringify(commentLocator))
await page.setViewportSize({ width: 1440, height: 900 })
console.log('✓ comment card created: chip + text underline + active gutter marker visible')
// --- Enter sends a comment, Shift+Enter adds a line ---
await selectText(page, 'brown fox jumps')
await page.waitForSelector('#composer:not(.hidden)', { timeout: 5000 })
await page.click('#composer-text')
await page.fill('#composer-text', '')
await page.keyboard.type('first line')
await page.keyboard.down('Shift')
await page.keyboard.press('Enter')
await page.keyboard.up('Shift')
await page.keyboard.type('second line')
const twoLines = await page.inputValue('#composer-text')
assert.ok(twoLines.includes('\n'), 'Shift+Enter adds a newline: ' + JSON.stringify(twoLines))
assert.ok(await page.isVisible('#composer'), 'Shift+Enter does not send')
await page.keyboard.press('Enter')
await waitFor(async () => await page.isHidden('#composer'), 'Enter sends the comment')
await waitFor(async () => (await page.textContent('#margin-items')).includes('second line'), 'both lines posted')
console.log('✓ comment composer: Enter sends, Shift+Enter adds a line')
// the reply box behaves the same way
await page.click('.card.thread')
await page.waitForSelector('.card.thread.expanded .reply-row textarea', { timeout: 5000 })
const replyBox = '.card.thread.expanded .reply-row textarea'
await page.click(replyBox)
await page.keyboard.type('reply one')
await page.keyboard.down('Shift')
await page.keyboard.press('Enter')
await page.keyboard.up('Shift')
await page.keyboard.type('reply two')
assert.ok((await page.inputValue(replyBox)).includes('\n'), 'Shift+Enter adds a line in a reply')
await page.keyboard.press('Enter')
await waitFor(async () => (await page.textContent('.card.thread')).includes('reply two'), 'Enter sends the reply')
assert.equal(await page.inputValue(replyBox), '', 'the reply box clears after sending')
// close it again: later assertions count the open threads, and this test's
// own thread would otherwise look like a second discussion
await page.click('.card.thread.expanded .close-btn')
await waitFor(async () => (await page.$$('.card.thread')).length === 1, 'this test cleans up its thread')
console.log('✓ reply box: Enter sends, Shift+Enter adds a line')
// --- @ menu: arrows pick an agent, no mouse needed ---
await selectText(page, 'over the lazy')
await page.waitForSelector('#composer:not(.hidden)', { timeout: 5000 })
await page.click('#composer-text')
await page.fill('#composer-text', '')
await page.keyboard.type('@ui')
await page.waitForSelector('#composer-mentions:not(.hidden)', { timeout: 5000 })
const menuOrder = await page.evaluate(() =>
[...document.querySelectorAll('#composer-mentions .mention-item')].map(d => d.textContent.trim())
)
assert.deepEqual(menuOrder, ['@ui-agent', '@ui-agent-two'], 'both handles offered: ' + menuOrder.join(','))
const firstSelected = await page.evaluate(
() => document.querySelector('#composer-mentions .mention-item')?.classList.contains('selected')
)
assert.ok(firstSelected, 'first agent is preselected')
await page.keyboard.press('ArrowDown')
const secondSelected = await page.evaluate(
() => [...document.querySelectorAll('#composer-mentions .mention-item')][1]?.classList.contains('selected')
)
assert.ok(secondSelected, 'ArrowDown moves the selection')
await page.keyboard.press('Enter')
const afterEnter = await page.inputValue('#composer-text')
assert.equal(afterEnter, '@ui-agent-two ', 'Enter inserts the arrowed-to handle: ' + JSON.stringify(afterEnter))
assert.ok(await page.isHidden('#composer-mentions'), '@ menu closes after picking')
await page.click('#composer-cancel')
console.log('✓ @ menu: own agents first, preselected, arrow keys + Enter pick')
// --- a connected agent is the one the box hands work to ---
// ui-agent-two starts polling; presence is derived from that long-poll, so it
// is now the only agent that would pick a mention up right away.
const pollAbort = new AbortController()
const polling = fetch(`${BASE}/api/mentions/stream?wait=20`, {
headers: { authorization: `Bearer ${shownKeyTwo}` },
signal: pollAbort.signal,
}).catch(() => {})
await waitFor(async () => {
const agents = await page.evaluate(async () => {
const r = await fetch(`/api/agents?doc=${location.pathname.split('/').pop()}`)
return (await r.json()).agents
})
return agents.find(a => a.handle === 'ui-agent-two')?.online === true
}, 'ui-agent-two shows as online')
await selectText(page, 'quick brown fox')
await page.waitForSelector('#composer:not(.hidden)', { timeout: 5000 })
// the box refreshes presence when it opens, so the mention corrects itself
await waitFor(
async () => (await page.inputValue('#composer-text')) === '@ui-agent-two ',
'comment box pre-mentions the CONNECTED agent, not merely the first one'
)
// and the @ menu puts it at the top, where Enter lands
await page.fill('#composer-text', '')
await page.type('#composer-text', '@ui-')
await page.waitForSelector('#composer-mentions:not(.hidden)', { timeout: 5000 })
const onlineFirst = await page.evaluate(() =>
[...document.querySelectorAll('#composer-mentions .mention-item')].map(d => d.textContent.trim())
)
assert.deepEqual(onlineFirst, ['@ui-agent-two', '@ui-agent'], 'connected agent ranks first: ' + onlineFirst.join(','))
await page.keyboard.press('Escape')
await page.click('#composer-cancel')
pollAbort.abort()
await polling
console.log('✓ connected agent is preferred: pre-mention + top of the @ menu')
// collapse on outside click
await page.mouse.click(40, 500)
await waitFor(async () => !(await page.$('.card.thread.expanded')), 'card collapses')
console.log('✓ cards collapse when clicking away')
// --- the human suggest UI is hidden until proposing means editing inline ---
assert.ok(await page.isHidden('#mode-switch'), 'Editing/Suggesting switch is hidden')
assert.ok(await page.isHidden('#suggest-btn'), 'Suggest button is hidden')
assert.ok(await page.evaluate(() => window.__editor.isEditable), 'the document stays directly editable')
// The Space runs in an iframe where native dialogs are suppressed — the app
// must never open one (everything goes through the in-app ui* helpers now).
page.on('dialog', d => {
throw new Error('native dialog opened: ' + d.message())
})
// suggestions themselves are unchanged — post one the way an agent does, then
// check the whole reviewing flow on top of it
const suggested = await page.evaluate(async () => {
const id = location.pathname.split('/d/')[1].split('/')[0]
const snap = await (await fetch(`/api/docs/${id}`)).json()
const idx = (snap.blocks || []).findIndex(b => (b.markdown || '').includes('lazy dog'))
const res = await fetch(`/api/docs/${id}/suggestions`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
block_index: idx,
replacement_markdown: 'The quick brown fox jumps over the **energetic** dog.',
rationale: 'more positive energy',
}),
})
return { idx, ...(await res.json()) }
})
assert.ok(suggested.idx >= 0 && suggested.suggestion_id, 'suggestion posted on the typed block: ' + JSON.stringify(suggested))
await page.waitForSelector('.card.suggestion', { timeout: 5000 })
await page.click('.card.suggestion')
await page.waitForSelector('.card.suggestion.expanded', { timeout: 5000 })
console.log('✓ suggest UI hidden; document editable; suggestion arrives from the API')
// inline track-changes rendering in the document
await page.waitForSelector('.tiptap .sugg-ins', { timeout: 5000 })
// wait for the decoration set to settle: the widgets appear as it is rebuilt,
// so reading the first paint was always a race
await waitFor(
async () => (await page.evaluate(() => [...document.querySelectorAll('.tiptap .sugg-ins')].map(n => n.textContent).join(' '))).includes('energetic'),
'inserted words shown inline'
)
await waitFor(
async () => (await page.evaluate(() => [...document.querySelectorAll('.tiptap .sugg-del')].map(n => n.textContent).join(' '))).includes('lazy'),
'deleted words struck through inline'
)
// slim card: accept/reject in the header, no diff repetition
assert.ok(await page.$('.card.suggestion .accept-btn'), 'accept button in card header')
assert.ok(!(await page.$('.card.suggestion .udiff')), 'card does not repeat the diff')
console.log('✓ suggestion rendered inline (strikethrough + insertion); card is slim')
// --- discuss the suggestion (guide the agent) ---
await page.fill('.card.suggestion .reply-row textarea', '@ui-agent could you keep the dog lazy though?')
await page.click('.card.suggestion .reply-row button')
await waitFor(async () => (await page.textContent('.card.suggestion')).includes('keep the dog lazy'), 'discussion message')
// the discussion thread must NOT appear as a separate card
const threadCards = await page.$$('.card.thread')
assert.equal(threadCards.length, 1, 'suggestion discussion is embedded, not a separate card')
console.log('✓ discussion on suggestion (embedded thread)')
// --- collapse, then accept straight from the collapsed card ---
await page.mouse.click(40, 500)
await waitFor(async () => !(await page.$('.card.suggestion.expanded')), 'suggestion card collapsed')
assert.ok(await page.locator('.card.suggestion .accept-btn').isVisible(), 'accept visible while collapsed')
// active-highlight: click the card, inline marks gain .active
await page.click('.card.suggestion .head .who')
await waitFor(async () => (await page.$('.tiptap .sugg-ins.active')) !== null, 'active suggestion emphasized inline')
await page.click('.card.suggestion .accept-btn')
// (the inline insertion widget also contains the word — wait for the real applied mark)
await waitFor(async () => (await page.innerHTML('.tiptap')).includes('<strong>energetic</strong>'), 'suggestion applied with marks')
console.log('✓ suggestion accepted -> text replaced with marks')
// closed items are hidden by default, shown with the toggle
await waitFor(async () => !(await page.$('.card.suggestion')), 'accepted suggestion hidden')
await page.check('#show-resolved')
await page.waitForSelector('.card.suggestion', { timeout: 5000 })
assert.ok((await page.textContent('.card.suggestion')).includes('accepted'), 'status visible when shown')
await page.uncheck('#show-resolved')
console.log('✓ closed items hidden by default, toggle shows them')
// --- undo the accept: text reverts AND the suggestion reopens ---
await page.click('#undo-btn')
await waitFor(async () => !(await page.innerHTML('.tiptap')).includes('<strong>energetic</strong>'), 'undo reverts accepted text')
await page.waitForSelector('.card.suggestion', { timeout: 5000 }) // visible again => open
await waitFor(async () => (await page.$('.tiptap .sugg-ins')) !== null, 'inline diff back after reopen')
console.log('✓ undo reverts the accept and reopens the suggestion')
// accept again to continue (straight from the collapsed card)
await page.click('.card.suggestion .accept-btn')
await waitFor(async () => (await page.innerHTML('.tiptap')).includes('<strong>energetic</strong>'), 're-accept applies')
console.log('✓ re-accept after undo works')
// --- accepting while the collaboration socket is down ---
// The accept endpoint is plain HTTP: it still answers when the websocket is
// gone. Trusting a dead tab's "I applied it locally" marked suggestions
// accepted while their content was silently dropped, which is how six
// accepted entries vanished from a real document.
const offlineSugg = await page.evaluate(async () => {
const id = location.pathname.split('/').filter(Boolean)[1]
const snap = await (await fetch(`/api/docs/${id}`)).json()
const bi = snap.blocks.findIndex(b => b.markdown.includes('paragraph'))
return await (await fetch(`/api/docs/${id}/suggestions`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ block_index: bi < 0 ? 1 : bi, replacement_markdown: 'Written while the socket was down.' }),
})).json()
})
assert.ok(offlineSugg.suggestion_id, 'offline-case suggestion posted')
await page.waitForSelector('.card.suggestion .accept-btn', { timeout: 8000 })
await page.evaluate(() => window.__provider.disconnect())
await waitFor(async () => !(await page.evaluate(() => document.getElementById('conn').classList.contains('on'))), 'connection indicator goes off')
await page.click('.card.suggestion .accept-btn')
await page.waitForSelector('.ui-modal-scrim', { timeout: 8000 })
assert.match(await page.textContent('.ui-modal-msg'), /not connected/i, 'accepting offline warns instead of silently failing')
await page.keyboard.press('Escape') // dismiss, or the modal blocks the clicks below
const stillOpen = await page.evaluate(async sid => {
const id = location.pathname.split('/').filter(Boolean)[1]
const snap = await (await fetch(`/api/docs/${id}`)).json()
return snap.suggestions.find(x => x.id === sid)?.status
}, offlineSugg.suggestion_id)
assert.equal(stillOpen, 'open', 'a suggestion is not marked accepted while the tab cannot save')
assert.ok(await page.$('#offline-notice'), 'a disconnected tab says so')
// reconnect: the same accept now works end to end
await page.evaluate(() => window.__provider.connect())
await waitFor(async () => !!(await page.evaluate(() => document.getElementById('conn').classList.contains('on'))), 'reconnected')
await waitFor(async () => !(await page.$('#offline-notice')), 'offline notice clears on reconnect')
await page.click('.card.suggestion .accept-btn')
await waitFor(async () => (await page.textContent('.tiptap')).includes('Written while the socket was down.'), 'accept applies after reconnect')
console.log('✓ accepts are refused (not silently lost) while the socket is down, and work after reconnect')
// --- image upload ---
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAF0lEQVR4nGP8z8Dwn4EIwESMolGFtFEIAK5+AxGmizXcAAAAAElFTkSuQmCC',
'base64'
)
fs.writeFileSync(`${SHOT}/test.png`, png)
const [chooser] = await Promise.all([page.waitForEvent('filechooser'), page.click('#image-btn')])
await chooser.setFiles(`${SHOT}/test.png`)
await page.waitForSelector('.tiptap img:not(.ProseMirror-separator)', { timeout: 10000 })
const src = await page.getAttribute('.tiptap img:not(.ProseMirror-separator)', 'src')
assert.ok(src.startsWith('/files/'), 'image served from /files: ' + src)
const imgRes = await page.evaluate(async u => (await fetch(u)).status, src)
assert.equal(imgRes, 200, 'image fetchable')
console.log('✓ image upload + render')
// --- reply + resolve on the standalone thread ---
await page.click('.card.thread')
await page.waitForSelector('.card.thread.expanded', { timeout: 5000 })
await page.fill('.card.thread .reply-row textarea', 'looks good, thanks!')
await page.click('.card.thread .reply-row button')
await waitFor(async () => (await page.textContent('.card.thread')).includes('looks good'), 'reply visible')
console.log('✓ reply works')
// --- list scenario: agent adds one item, only that item shows as inserted ---
await focusDocEnd(page)
await page.keyboard.press('Enter')
await page.keyboard.type('- alpha')
await page.keyboard.press('Enter')
await page.keyboard.type('beta')
await page.keyboard.press('Enter')
await page.keyboard.type('gamma')
await page.waitForTimeout(400)
const listSugg = await page.evaluate(async () => {
const id = location.pathname.split('/').pop()
const snap = await (await fetch(`/api/docs/${id}`)).json()
const li = snap.blocks.findIndex(b => b.markdown.includes('alpha'))
return (await (await fetch(`/api/docs/${id}/suggestions`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ block_index: li, replacement_markdown: '- alpha\n- beta\n- gamma\n- delta' }),
})).json())
})
assert.ok(listSugg.ok, 'list suggestion: ' + JSON.stringify(listSugg))
await waitFor(async () => {
const spans = await page.$$eval('.tiptap .sugg-ins', els => els.map(e => e.textContent))
return spans.some(t => t.includes('delta'))
}, 'list insertion inline')
const delSpans = await page.$$eval('.tiptap .sugg-del', els => els.map(e => e.textContent).filter(t => ['alpha', 'beta', 'gamma'].some(w => t.includes(w))))
assert.equal(delSpans.length, 0, 'unchanged list items are NOT struck through: ' + JSON.stringify(delSpans))
// typing a new block right after the suggested range must not get highlighted
await focusDocEnd(page)
await page.keyboard.press('Enter')
await page.keyboard.press('Enter') // second Enter exits the list into a fresh paragraph
await page.keyboard.type('untouched trailing line')
await page.waitForTimeout(400)
const trailingCls = await page.evaluate(() => {
const blocks = [...document.querySelectorAll('.tiptap > *')]
const t = blocks.find(b => b.textContent.includes('untouched trailing line'))
return t ? t.className || '' : 'MISSING'
})
assert.ok(!trailingCls.includes('suggestion-hl') && !trailingCls.includes('sugg-del') && trailingCls !== 'MISSING', 'new trailing block not highlighted: ' + trailingCls)
await page.evaluate(async sid => {
const id = location.pathname.split('/').pop()
await fetch(`/api/docs/${id}/suggestions/${sid}/reject`, { method: 'POST' })
}, listSugg.suggestion_id)
console.log('✓ list suggestion: only the added item renders as inserted')
// --- new blocks (heading + list) render FORMATTED in the insertion panel ---
const blockSugg = await page.evaluate(async () => {
const id = location.pathname.split('/').pop()
const snap = await (await fetch(`/api/docs/${id}`)).json()
const li = snap.blocks.findIndex(b => b.markdown.includes('alpha'))
return (await (await fetch(`/api/docs/${id}/suggestions`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
block_index: li,
replacement_markdown: snap.blocks[li].markdown + '\n\n## Sources\n\n- one **bold** source',
}),
})).json())
})
assert.ok(blockSugg.ok, 'block suggestion: ' + JSON.stringify(blockSugg))
await page.waitForSelector('.tiptap .sugg-ins-block', { timeout: 5000 })
assert.ok(await page.$('.tiptap .sugg-ins-block h2'), 'inserted heading renders as a heading')
assert.ok(await page.$('.tiptap .sugg-ins-block ul li strong'), 'inserted list renders as bullets with marks')
await page.evaluate(async sid => {
const id = location.pathname.split('/').pop()
await fetch(`/api/docs/${id}/suggestions/${sid}/reject`, { method: 'POST' })
}, blockSugg.suggestion_id)
console.log('✓ new blocks render formatted (heading, bullets, marks) in the insertion panel')
// --- formatting-only changes are visible, and inserted runs keep their marks ---
// The inline diff used to compare mark-stripped text: a suggestion that only
// linked (or bolded) an existing word looked completely identical, so the
// document showed no diff at all and the card seemed to propose nothing.
const markCases = [
{ label: 'link', md: 'A paragraph to [link up](https://example.com/ref) here.', sel: '.tiptap .sugg-ins a[href="https://example.com/ref"]' },
{ label: 'bold', md: 'A paragraph to **link up** here.', sel: '.tiptap .sugg-ins strong' },
{ label: 'code', md: 'A paragraph to `link up` here.', sel: '.tiptap .sugg-ins code' },
]
// its own paragraph, verified to be a standalone block: the point of these
// cases is that ONLY the formatting differs from the replacement markdown
await focusDocEnd(page)
await page.keyboard.press('Enter')
await page.keyboard.type('A paragraph to link up here.')
await waitFor(async () => (await page.textContent('.tiptap')).includes('A paragraph to link up here.'), 'mark-case paragraph typed')
await waitFor(async () => {
const md = await page.evaluate(async () => {
const id = location.pathname.split('/').filter(Boolean)[1]
const snap = await (await fetch(`/api/docs/${id}`)).json()
const b = snap.blocks.find(x => x.markdown.includes('A paragraph to link up here.'))
return b?.markdown
})
return md === 'A paragraph to link up here.'
}, 'the paragraph is its own block (formatting-only cases need an exact match)')
for (const c of markCases) {
const sugg = await page.evaluate(async md => {
const id = location.pathname.split('/').filter(Boolean)[1]
const snap = await (await fetch(`/api/docs/${id}`)).json()
const bi = snap.blocks.findIndex(b => b.markdown.includes('A paragraph to link up here.'))
return await (await fetch(`/api/docs/${id}/suggestions`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ block_index: bi, replacement_markdown: md, rationale: 'formatting only' }),
})).json()
}, c.md)
assert.ok(sugg.suggestion_id, `${c.label} suggestion posted: ` + JSON.stringify(sugg))
// the old run is struck AND the new run is inserted with its formatting
await page.waitForSelector(c.sel, { timeout: 8000 })
const marked = await page.evaluate(sel => {
const ins = document.querySelector(sel)
return { text: ins.textContent, deleted: [...document.querySelectorAll('.tiptap .sugg-del')].map(n => n.textContent) }
}, c.sel)
assert.equal(marked.text, 'link up', `${c.label}: inserted run carries the text`)
assert.ok(marked.deleted.some(t => t.includes('link up')), `${c.label}: the unformatted run is struck through: ${JSON.stringify(marked.deleted)}`)
await page.evaluate(async sid => {
const id = location.pathname.split('/').filter(Boolean)[1]
await fetch(`/api/docs/${id}/suggestions/${sid}/reject`, { method: 'POST' })
}, sugg.suggestion_id)
await waitFor(async () => !(await page.$(c.sel)), `${c.label} suggestion cleared`)
}
console.log('✓ formatting-only suggestions (link / bold / code) render as visible diffs')
// a link inside a *proposed* change must not navigate away on a plain click
const linkSugg = await page.evaluate(async () => {
const id = location.pathname.split('/').filter(Boolean)[1]
const snap = await (await fetch(`/api/docs/${id}`)).json()
const bi = snap.blocks.findIndex(b => b.markdown.includes('A paragraph to link up here.'))
return await (await fetch(`/api/docs/${id}/suggestions`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ block_index: bi, replacement_markdown: 'A paragraph to [link up](https://example.com/ref) here.' }),
})).json()
})
assert.ok(linkSugg.suggestion_id, 'preview-link suggestion posted')
await page.waitForSelector('.tiptap .sugg-ins a[href]', { timeout: 8000 })
const urlBefore = page.url()
await page.click('.tiptap .sugg-ins a[href]')
await page.waitForTimeout(500)
assert.equal(page.url(), urlBefore, 'clicking a suggested link stays on the page')
assert.ok(await page.$('.card.suggestion.expanded'), 'clicking a suggested link opens its card instead')
// accepting turns it into a real link in the document
await page.click('.card.suggestion.expanded .accept-btn')
await waitFor(async () => !!(await page.$('.tiptap a[href="https://example.com/ref"]')), 'accepted link is a real link')
await waitFor(async () => {
const md = await page.evaluate(async () => {
const id = location.pathname.split('/').filter(Boolean)[1]
return (await (await fetch(`/api/docs/${id}`)).json()).markdown
})
return md.includes('[link up](https://example.com/ref)')
}, 'accepted link round-trips to markdown')
console.log('✓ suggested links preview safely, then accept into real links')
// --- dissimilar rewrite: struck old block + formatted panel, never a flat text blob ---
const dis = await page.evaluate(async () => {
const id = location.pathname.split('/').pop()
const snap = await (await fetch(`/api/docs/${id}`)).json()
const li = snap.blocks.findIndex(b => b.markdown.includes('untouched trailing line'))
return (await (await fetch(`/api/docs/${id}/suggestions`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ block_index: li, replacement_markdown: '## Appendix\n\nCompletely different content here.' }),
})).json())
})
assert.ok(dis.ok, 'dissimilar suggestion: ' + JSON.stringify(dis))
await waitFor(async () => (await page.$$('.tiptap .sugg-ins-block')).length >= 1, 'panel for dissimilar rewrite')
assert.ok(await page.$('.tiptap .sugg-del-block'), 'unrelated old block struck wholesale')
const flatIns = await page.$$eval('.tiptap .sugg-ins', els => els.map(e => e.textContent).filter(t => t.includes('Appendix')))
assert.equal(flatIns.length, 0, 'heading is not flat inline text')
assert.ok(await page.$$eval('.tiptap .sugg-ins-block', els => els.some(e => e.querySelector('h2'))), 'panel contains a real heading')
await page.evaluate(async sid => {
const id = location.pathname.split('/').pop()
await fetch(`/api/docs/${id}/suggestions/${sid}/reject`, { method: 'POST' })
}, dis.suggestion_id)
console.log('✓ dissimilar rewrites: whole-block strike + formatted panel (no raw blob)')
// --- typing at a suggestion boundary pushes the insertion panel down ---
await focusDocEnd(page)
await page.keyboard.press('Enter')
await page.keyboard.type('ordering base line')
await page.waitForTimeout(400)
const push = await page.evaluate(async () => {
const id = location.pathname.split('/').pop()
const snap = await (await fetch(`/api/docs/${id}`)).json()
const li = snap.blocks.findIndex(b => b.markdown.includes('ordering base line'))
return (await (await fetch(`/api/docs/${id}/suggestions`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ block_index: li, replacement_markdown: 'ordering base line\n\n## Pushed Panel' }),
})).json())
})
assert.ok(push.ok, JSON.stringify(push))
await waitFor(async () => (await page.textContent('.tiptap')).includes('Pushed Panel'), 'panel appears')
await page.evaluate(() => {
const e = window.__editor
let pos = null
e.state.doc.descendants((node, p) => {
if (node.isText && node.text.includes('ordering base line')) pos = p + node.text.length
})
e.chain().focus().setTextSelection(pos).run()
})
await page.keyboard.press('Enter')
await page.keyboard.type('pushes the panel')
await page.waitForTimeout(500)
const domOrder = await page.evaluate(() => {
const kids = [...document.querySelector('.tiptap').children].map(el =>
el.className.includes('sugg-ins-block') ? 'PANEL' : el.textContent.slice(0, 25)
)
return kids.slice(kids.findIndex(t => t.includes('ordering base')))
})
assert.ok(
domOrder.indexOf('PANEL') > domOrder.findIndex(t => t.includes('pushes the panel')),
'typed line lands ABOVE the panel (pushes it down): ' + JSON.stringify(domOrder)
)
await page.evaluate(async sid => {
const id = location.pathname.split('/').pop()
await fetch(`/api/docs/${id}/suggestions/${sid}/reject`, { method: 'POST' })
}, push.suggestion_id)
console.log('✓ typing at the boundary pushes the insertion panel down')
// --- zoom ---
const baseSize = await page.evaluate(() => parseFloat(getComputedStyle(document.querySelector('.tiptap')).fontSize))
await page.click('#zoom-in')
const zoomedSize = await page.evaluate(() => parseFloat(getComputedStyle(document.querySelector('.tiptap')).fontSize))
assert.ok(zoomedSize > baseSize, `zoom-in grows text (${baseSize} -> ${zoomedSize})`)
await page.click('#zoom-out')
console.log('✓ zoom controls')
// --- the sheet is a page: fixed width, panels fold for it, zoom magnifies it ---
// Regression guards for three things that used to be wrong: the sheet shrank
// with the window (so the measure changed as you resized), zoom moved the type
// but not the page, and nothing gave way before the document did.
const geom = () =>
page.evaluate(() => {
const col = document.getElementById('editor-col')
return {
sheet: Math.round(document.getElementById('editor').getBoundingClientRect().width),
font: parseFloat(getComputedStyle(document.querySelector('.tiptap')).fontSize),
pages: !document.getElementById('sidebar').classList.contains('hidden'),
comments: !document.getElementById('margin-col').classList.contains('hidden'),
pageArrow: document.getElementById('sidebar-toggle').getBoundingClientRect().width > 0,
sidebarWidth: Math.round(document.getElementById('sidebar').getBoundingClientRect().width),
sidebarRight: Math.round(document.getElementById('sidebar').getBoundingClientRect().right),
sheetLeft: Math.round(document.getElementById('editor').getBoundingClientRect().left),
sheetRight: Math.round(document.getElementById('editor').getBoundingClientRect().right),
textRight: Math.round(document.getElementById('editor').getBoundingClientRect().right -
parseFloat(getComputedStyle(document.getElementById('editor')).paddingRight)),
commentsLeft: Math.round(document.getElementById('margin-col').getBoundingClientRect().left),
appliedOverlap: Math.round(parseFloat(getComputedStyle(document.documentElement)
.getPropertyValue('--margin-applied-overlap')) || 0),
colScroll: col.scrollWidth - col.clientWidth,
colWidth: col.clientWidth,
bodyOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
}
})
const resizeTo = async width => {
await page.setViewportSize({ width, height: 900 })
await page.waitForTimeout(250)
return geom()
}
const wide = await resizeTo(1440)
assert.ok(wide.pages && wide.comments, 'both side panels fit at 1440')
assert.ok(!wide.pageArrow, 'the Pages arrow is absent while the sidebar fits')
assert.ok(Math.abs(wide.sheetLeft - wide.sidebarRight - 20) <= 2,
`the sidebar fills the full gutter up to the page (${wide.sidebarRight} -> ${wide.sheetLeft})`)
assert.ok(Math.abs(wide.commentsLeft - wide.sheetRight - 20) <= 2 && wide.appliedOverlap === 0,
`comments stay outside the page while there is room (${wide.sheetRight} -> ${wide.commentsLeft})`)
const sheetW = wide.sheet
// The page stays fixed while every additional wide-screen pixel belongs to
// Pages — there is no dead strip between a fixed nav and the document.
const extraWide = await resizeTo(1600)
assert.ok(extraWide.sidebarWidth >= wide.sidebarWidth + 150,
`the sidebar grows with the gutter (${wide.sidebarWidth} -> ${extraWide.sidebarWidth})`)
// Borrowing the page's right margin lets Pages survive at 1280, where it
// previously disappeared even though the comments could safely move inward.
const resilient = await resizeTo(1280)
assert.strictEqual(resilient.sheet, sheetW, `the sheet keeps its width at 1280 (${resilient.sheet} vs ${sheetW})`)
assert.ok(resilient.pages && resilient.comments, 'Pages survives at 1280 by bringing comments inward')
assert.ok(!resilient.pageArrow, 'no Pages arrow while the overlapped three-column layout fits')
assert.ok(resilient.appliedOverlap > 0, 'overlap appears only when the full-width columns run short')
assert.ok(resilient.textRight - resilient.commentsLeft >= 18 && resilient.textRight - resilient.commentsLeft <= 30,
`the maximum overlap touches only a little text (${resilient.textRight - resilient.commentsLeft}px)`)
// Below that, Pages is still the first column to yield.
const mid = await resizeTo(1240)
assert.ok(!mid.pages, 'the Pages sidebar folds away first')
assert.ok(mid.pageArrow, 'the Pages arrow appears only after the sidebar auto-collapses')
assert.ok(mid.comments, 'the comments margin is still there when only the sidebar had to go')
assert.equal(mid.appliedOverlap, 0, 'comments stop overlapping as soon as Pages collapses')
assert.ok(mid.commentsLeft >= mid.sheetRight, 'comments return outside the page after Pages collapses')
// narrower still: the comments follow, and the page is STILL the same width
const tight = await resizeTo(1000)
assert.strictEqual(tight.sheet, sheetW, `the sheet keeps its width at 1000 (${tight.sheet} vs ${sheetW})`)
assert.ok(!tight.pages && !tight.comments, 'both columns fold before the page is touched')
// growing back restores them, in reverse
const back = await resizeTo(1440)
assert.ok(back.pages && back.comments, 'panels come back when the room does')
assert.ok(!back.pageArrow, 'the Pages arrow disappears when the sidebar returns')
// zoom magnifies the page itself: sheet and type take the same factor
await page.click('#zoom-in')
await page.click('#zoom-in')
await page.waitForTimeout(250)
const zoomed = await geom()
const sheetRatio = zoomed.sheet / wide.sheet
const fontRatio = zoomed.font / wide.font
assert.ok(zoomed.sheet > wide.sheet, `zoom widens the sheet (${wide.sheet} -> ${zoomed.sheet})`)
assert.ok(
Math.abs(sheetRatio - fontRatio) < 0.02,
`sheet and type scale together (sheet x${sheetRatio.toFixed(3)}, type x${fontRatio.toFixed(3)})`
)
// a page too wide for the window scrolls sideways INSIDE its column — the
// window itself must never gain a horizontal scrollbar (that unsticks the header)
const overflowing = await resizeTo(1000)
assert.ok(overflowing.colScroll > 0, 'an over-wide page scrolls inside the editor column')
assert.ok(overflowing.bodyOverflow <= 0, `the window itself does not scroll sideways (${overflowing.bodyOverflow}px)`)
await page.click('#zoom-out')
await page.click('#zoom-out')
await resizeTo(1440)
console.log('✓ fixed-width page: folds the side panels, zooms whole, scrolls in place')
// --- the same document at every width: reflow only as late as it must ---
// The document used to keep its page geometry at every width, on the grounds
// that reflowing it moved the line breaks. It did — and it also made a 390px
// phone show 68% of each line off-screen with no zoom that both fitted the
// page and left legible type. So the sheet now goes fluid below 840px, in two
// stages, and this walks the stages:
// >840 nothing changes at all — the page fits, so it stays a page
// ≤840 the sheet is fluid but the text column is still capped at the page's
// own 624px, so the SAME words break on the SAME lines with no scroll
// ≤700 a handset cannot hold 624px, so the measure and the type both give
const shape = () =>
page.evaluate(() => {
const t = document.querySelector('.tiptap')
const para = [...t.querySelectorAll('p')].find(e => e.textContent.trim().length > 60)
const col = document.getElementById('editor-col')
return {
sheet: Math.round(document.getElementById('editor').getBoundingClientRect().width),
measure: Math.round(t.getBoundingClientRect().width),
padLeft: Math.round(parseFloat(getComputedStyle(document.getElementById('editor')).paddingLeft)),
font: getComputedStyle(t).fontSize,
// a dragged table carries inline geometry that ignores the measure
table: Math.round(document.querySelector('.tiptap table')?.getBoundingClientRect().width || 0),
// if the lines broke differently, the paragraph and the document change height
paraHeight: para ? Math.round(para.getBoundingClientRect().height) : 0,
docHeight: Math.round(t.getBoundingClientRect().height),
colScroll: col.scrollWidth - col.clientWidth,
colWidth: col.clientWidth,
windowOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
}
})
// A resize lands in the renderer asynchronously and the header's own
// ResizeObserver can move --hdr-h a frame later, so a fixed sleep is a
// coin toss at these boundaries: read the shape until two reads agree.
const shapeAfterResize = async (width, height) => {
await page.setViewportSize({ width, height })
let prev = null
for (let i = 0; i < 25; i++) {
await page.waitForTimeout(100)
const now = await shape()
if (prev && now.sheet === prev.sheet && now.measure === prev.measure && now.docHeight === prev.docHeight) return now
prev = now
}
return prev
}
// Put a table with a DRAGGED column in the document first. A pristine table is
// width:100% and divides the measure, so it can never fail the no-scroll checks
// below — the assertions were sound but pointed at content that could not fail
// them. prosemirror-tables records a drag as an inline min-width on the table
// and an inline width on a <col>, and that is what follows the reader onto a
// phone and puts the sideways pan back. Reviewed and reproduced: 562px of table
// inside a 354px measure, 190px of scroll on #editor-col.
await page.click('.tiptap p')
await page.keyboard.press('End')
await page.click('button[title="Insert table"]')
await page.waitForSelector('.tiptap table th')
await page.waitForTimeout(400)
const tblBorder = await page.evaluate(() => {
const c = document.querySelector('.tiptap table:first-of-type tr:first-child > *')
const r = c.getBoundingClientRect()
return { x: r.right, y: r.top + r.height / 2 }
})
await page.mouse.move(tblBorder.x - 1, tblBorder.y)
await page.waitForSelector('.tiptap .column-resize-handle')
await page.mouse.down()
await page.mouse.move(tblBorder.x + 200, tblBorder.y, { steps: 10 })
await page.mouse.up()
await waitFor(
async () => await page.evaluate(() => /min-width/.test(document.querySelector('.tiptap table').getAttribute('style') || '')),
'the drag writes an inline min-width onto the table (the thing that overflows)'
)
await page.waitForTimeout(400)
const onDesktop = await shapeAfterResize(1440, 900)
assert.ok(onDesktop.table > 0, 'the resized table is in the document under test')
// above the reflow width the page is untouched, down to the pixel
for (const width of [1100, 900, 860]) {
const wideEnough = await shapeAfterResize(width, 844)
assert.strictEqual(wideEnough.sheet, onDesktop.sheet, `at ${width}px the page keeps its width`)
assert.strictEqual(wideEnough.measure, onDesktop.measure, `at ${width}px the text column is unchanged`)
assert.strictEqual(wideEnough.font, onDesktop.font, `at ${width}px the document is set at the same size`)
assert.strictEqual(wideEnough.paraHeight, onDesktop.paraHeight, `at ${width}px the lines break identically`)
assert.strictEqual(wideEnough.docHeight, onDesktop.docHeight, `at ${width}px nothing above or below moved`)
assert.strictEqual(wideEnough.colScroll, 0, `at ${width}px the page fits, so nothing scrolls sideways`)
}
// 701-840: the sheet gives up being a page, and NOTHING else gives. This is
// the assertion that keeps the reflow honest — a tablet is not allowed to
// re-break a single line just because the canvas around the page went away.
for (const width of [840, 768, 720]) {
const fluid = await shapeAfterResize(width, 1024)
assert.equal(fluid.sheet, fluid.colWidth, `at ${width}px the sheet is fluid, not a page (${JSON.stringify(fluid)})`)
assert.ok(fluid.sheet <= width, `at ${width}px the sheet fits the window (${fluid.sheet})`)
assert.strictEqual(fluid.measure, onDesktop.measure,
`at ${width}px the text column is still the page's 624px (${fluid.measure})`)
assert.strictEqual(fluid.font, onDesktop.font, `at ${width}px the type is untouched`)
assert.strictEqual(fluid.paraHeight, onDesktop.paraHeight, `at ${width}px the lines break identically`)
assert.strictEqual(fluid.colScroll, 0, `at ${width}px there is nothing left to scroll to (${fluid.colScroll})`)
assert.ok(fluid.windowOverflow <= 0, `at ${width}px the window does not scroll sideways`)
assert.ok(fluid.table <= fluid.measure + 1,
`at ${width}px the dragged table is inside the measure (${fluid.table} in ${fluid.measure})`)
}
// ≤700: a handset. Now the measure and the type both move — the point of the
// whole change — and the one thing that must not move is the fit.
const desktopFont = parseFloat(onDesktop.font)
for (const width of [700, 390, 360, 320]) {
const phone = await shapeAfterResize(width, 844)
assert.strictEqual(phone.sheet, phone.colWidth, `at ${width}px the sheet is exactly the column (${phone.sheet})`)
assert.ok(phone.measure <= width - 32, `at ${width}px the text column fits with margins (${phone.measure})`)
assert.strictEqual(phone.colScroll, 0, `at ${width}px nothing scrolls sideways (${phone.colScroll})`)
// the inline geometry from a column drag is the one thing that reaches this
// far and ignores the measure entirely
assert.ok(phone.table <= phone.measure + 1,
`at ${width}px the dragged table is inside the measure (${phone.table} in ${phone.measure})`)
assert.ok(phone.windowOverflow <= 0, `at ${width}px the window does not scroll sideways`)
// the legibility floor: 16px is what iOS and Android set body text at, and
// what the old "use zoom to fit it" answer arrived at 7px of
assert.ok(parseFloat(phone.font) >= 16, `at ${width}px body text is at least 16px (${phone.font})`)
assert.ok(parseFloat(phone.font) > desktopFont,
`at ${width}px the type is larger than the page's 11pt, not smaller (${phone.font})`)
// margins are a proportion of a small screen now, not a printed inch
assert.ok(phone.padLeft >= 16 && phone.padLeft <= width * 0.09,
`at ${width}px the page margin is 16px-9% of the screen, not 24.6% (${phone.padLeft})`)
}
// and back: the page returns exactly as it was, so this is a view of the
// document and not an edit to it
// named for this block specifically: #14 (agent/reader-text-size) declares its
// own `restored` in this same function scope, and git merges the two files
// without a conflict into something Node then refuses to parse
const restoredPage = await shapeAfterResize(1440, 900)
assert.deepStrictEqual(
[restoredPage.sheet, restoredPage.measure, restoredPage.font, restoredPage.paraHeight, restoredPage.docHeight],
[onDesktop.sheet, onDesktop.measure, onDesktop.font, onDesktop.paraHeight, onDesktop.docHeight],
'the page comes back identical after a round trip through phone widths'
)
// A rotation is how a handset crosses 840, and the zoom floor is different
// either side of it. Read at apply time only, a 40% zoom set while the sheet
// was a page stayed 40% after the rotation — 6.4px of body text, held until
// the next reload.
await shapeAfterResize(1000, 900)
for (let i = 0; i < 8; i++) await page.click('#zoom-out')
await page.waitForTimeout(300)
assert.equal(await page.textContent('#zoom-label'), '40%', 'a fixed-width page still zooms to 40%')
await shapeAfterResize(390, 844)
const afterRotate = await page.evaluate(() => ({
zoom: getComputedStyle(document.documentElement).getPropertyValue('--doc-zoom').trim(),
font: parseFloat(getComputedStyle(document.querySelector('.tiptap')).fontSize),
}))
assert.equal(afterRotate.zoom, '0.8', `rotating into the fluid column re-clamps the floor (${afterRotate.zoom})`)
assert.ok(afterRotate.font >= 12, `and the type is readable rather than 6.4px (${afterRotate.font}px)`)
// back to a width where the zoom buttons are in the toolbar rather than folded
// into the ⋯ sheet, then put the setting back for the checks that follow
await shapeAfterResize(1440, 900)
for (let i = 0; i < 12 && (await page.textContent('#zoom-label')) !== '100%'; i++) await page.click('#zoom-in')
await page.waitForTimeout(300)
assert.equal(await page.textContent('#zoom-label'), '100%', 'zoom restored to 100%')
console.log('✓ rotating across 840 re-clamps the zoom floor (40% -> 80%, not 6.4px type)')
console.log('✓ reflow arrives in two stages: geometry at 840, type at 700, nothing above')
// --- document style: Docs (Arial 11pt/1.15) <-> Reading (serif 22px/1.45) ---
const typeset = () =>
page.evaluate(() => {
const t = getComputedStyle(document.querySelector('.tiptap'))
const p = [...document.querySelectorAll('.tiptap p')].find(e => e.textContent.trim().length > 40)
return {
style: document.documentElement.dataset.docStyle,
family: t.fontFamily.split(',')[0].replace(/"/g, ''),
size: parseFloat(t.fontSize),
line: parseFloat(t.lineHeight),
paraGap: p ? parseFloat(getComputedStyle(p).marginBottom) : null,
measure: Math.round(document.querySelector('.tiptap').getBoundingClientRect().width),
}
})
const docsSet = await typeset()
assert.strictEqual(docsSet.style, 'docs', 'default document style is docs')
assert.strictEqual(docsSet.family, 'Arial', 'docs style sets the document in Arial')
assert.strictEqual(docsSet.paraGap, 0, 'docs style has no space between paragraphs')
await page.click('#docstyle-btn')
await waitFor(async () => (await typeset()).family === 'Libre Baskerville', 'reading style swaps in the serif')
const readingSet = await typeset()
// The settled numbers, pinned: 16px on 28.8px with 17.6px between paragraphs.
// Chosen by eye against real text rather than derived, which is exactly why
// they are worth pinning — nothing else records why these and not others.
assert.strictEqual(readingSet.size, 16, `body is set at 16px (got ${readingSet.size})`)
assert.strictEqual(Math.round(readingSet.line * 10) / 10, 28.8, `leading is 28.8px (got ${readingSet.line})`)
assert.strictEqual(Math.round(readingSet.paraGap * 10) / 10, 17.6, `paragraph gap is 17.6px (got ${readingSet.paraGap})`)
const head = await page.evaluate(() => {
const h = document.querySelector('.tiptap h2')
if (!h) return null
const s = getComputedStyle(h)
return { size: parseFloat(s.fontSize), line: parseFloat(s.lineHeight), caps: s.fontVariantCaps,
ls: parseFloat(s.letterSpacing), mt: parseFloat(s.marginTop) }
})
if (head) {
assert.strictEqual(Math.round(head.size * 10) / 10, 25.6, `section heads are 25.6px (got ${head.size})`)
assert.strictEqual(head.caps, 'all-small-caps', 'section heads are small caps')
assert.ok(Math.abs(head.ls / head.size - 0.075) < 0.005, `section heads keep the article's 0.075em tracking (got ${(head.ls / head.size).toFixed(4)}em)`)
assert.ok(Math.abs(head.mt / head.size - 2) < 0.02, `section heads take 2em of air above (got ${(head.mt / head.size).toFixed(2)}em)`)
}
// the switch is typography only — the sheet keeps Docs' letter geometry
assert.strictEqual(readingSet.measure, docsSet.measure, 'the text column is untouched by the document style')
// (no words-per-line guard against Docs: this face at this size fits 10.69
// to Docs' 13.54, so the two deliberately differ and switching reflows)
// and it survives a reload, like the theme
await page.reload({ waitUntil: 'domcontentloaded' })
await waitFor(async () => !!(await page.$('.tiptap p')), 'doc reloads')
assert.strictEqual((await typeset()).family, 'Libre Baskerville', 'document style persists across a reload')
await page.click('#docstyle-btn')
await waitFor(async () => (await typeset()).family === 'Arial', 'switches back to docs')
console.log('✓ document style switch (Docs <-> Reading), persisted, page width untouched')
// --- reader text size: the half of the pair that DOES reflow ---
// Zoom above is guarded on "the sheet and the type take the same factor, so
// the lines never move". This is the exact complement and needs its own guard,
// because the failure mode is the two controls quietly becoming one: a text
// size that also widened the page would leave the words-per-line alone, which
// is the one thing a reader reaches for it to change.
//
// The document this suite has built up by now is all short lines, and a line
// that never wrapped cannot re-break — so the measurement needs a paragraph
// long enough to wrap. It is appended here and taken out again at the end of
// the block, with the document's text asserted back to what it was.
const LONG =
'A reader who asks for larger type is not asking for a larger page: the sheet should ' +
'stay exactly where it is on the screen while the words inside it find new lines, ' +
'which is the one thing zoom can never do and the whole reason this control exists ' +
'next to it.'
const beforeText = await page.textContent('.tiptap')
const anchor = await page.evaluate(text => {
const e = window.__editor
const at = e.state.doc.content.size
e.chain().focus().insertContentAt(at, { type: 'paragraph', content: [{ type: 'text', text }] }).run()
return at
}, LONG)
await page.waitForTimeout(300)
const sized = () =>
page.evaluate(marker => {
const t = document.querySelector('.tiptap')
const p = [...t.querySelectorAll('p')].find(e => e.textContent.startsWith(marker))
const lh = p ? parseFloat(getComputedStyle(p).lineHeight) : 1
return {
scale: parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--doc-text-scale')),
stored: localStorage.getItem('doc-text-scale'),
label: document.getElementById('docsize-label').textContent,
size: parseFloat(getComputedStyle(t).fontSize),
sheet: Math.round(document.getElementById('editor').getBoundingClientRect().width),
measure: Math.round(t.getBoundingClientRect().width),
lines: p ? Math.round(p.getBoundingClientRect().height / lh) : 0,
}
}, LONG.slice(0, 30))
const base = await sized()
assert.strictEqual(base.scale, 1, 'text size starts at 1x')
assert.ok(base.lines >= 3, `the measured paragraph wraps to begin with (${base.lines} lines)`)
assert.strictEqual(base.label, '15px', `the readout is the document's own size, in px (got ${base.label})`)
for (let i = 0; i < 3; i++) await page.click('#text-larger')
await page.waitForTimeout(250)
const bigger = await sized()
assert.ok(Math.abs(bigger.size / base.size - 1.3) < 0.01,
`three steps up is 1.3x the type (${base.size} -> ${bigger.size})`)
assert.strictEqual(bigger.sheet, base.sheet, `the page keeps its width (${base.sheet} -> ${bigger.sheet})`)
assert.strictEqual(bigger.measure, base.measure, `and so does the text column (${base.measure} -> ${bigger.measure})`)
assert.ok(bigger.lines > base.lines, `so the paragraph re-breaks onto more lines (${base.lines} -> ${bigger.lines})`)
assert.strictEqual(bigger.label, '19px', `the readout follows (got ${bigger.label})`)
assert.strictEqual(bigger.stored, '1.3', 'the choice is stored')
// it is a factor, not a size: a style switch changes what 100% means and the
// reader's own step up rides along on top of whatever the style asked for
await page.click('#docstyle-btn')
await waitFor(async () => (await typeset()).family === 'Libre Baskerville', 'reading style swaps in the serif')
await page.waitForTimeout(250)
const readingBig = await sized()
assert.strictEqual(readingBig.scale, 1.3, 'the reader\'s step up survives a document style switch')
assert.ok(Math.abs(readingBig.size - 16 * 1.3) < 0.05,
`Reading's own 16px takes the same 1.3x (got ${readingBig.size})`)
assert.strictEqual(readingBig.label, '21px', `the px readout re-reads the new style's base (got ${readingBig.label})`)
assert.strictEqual(readingBig.sheet, base.sheet, 'the page is still the same width in the other style')
await page.click('#docstyle-btn')
await waitFor(async () => (await typeset()).family === 'Arial', 'switches back to docs')
await page.waitForTimeout(250)
// …and it survives a reload, like zoom and the style itself
await page.reload({ waitUntil: 'domcontentloaded' })
await waitFor(async () => !!(await page.$('.tiptap p')), 'doc reloads')
await page.waitForTimeout(400)
const reloaded = await sized()
assert.strictEqual(reloaded.scale, 1.3, 'text size persists across a reload')
assert.strictEqual(reloaded.label, '19px', 'and so does the readout')
// the two knobs do not touch: zoom on top of a text size still freezes the
// line breaks it was given and magnifies the page whole
await page.click('#zoom-in')
await page.click('#zoom-in')
await page.waitForTimeout(250)
const zoomedToo = await sized()
assert.ok(zoomedToo.sheet > reloaded.sheet, `zoom still widens the page (${reloaded.sheet} -> ${zoomedToo.sheet})`)
assert.strictEqual(zoomedToo.lines, reloaded.lines, 'zoom on top of a text size does not re-break the lines')
assert.strictEqual(zoomedToo.scale, 1.3, 'and does not disturb the text size')
await page.click('#zoom-out')
await page.click('#zoom-out')
await page.waitForTimeout(250)
for (let i = 0; i < 3; i++) await page.click('#text-smaller')
await page.waitForTimeout(250)
const restored = await sized()
assert.strictEqual(restored.scale, 1, 'three steps back down returns to 1x')
assert.strictEqual(restored.size, base.size, `and to the size it started at (${restored.size})`)
await page.evaluate(from => {
const e = window.__editor
e.chain().focus().deleteRange({ from, to: e.state.doc.content.size }).run()
}, anchor)
await page.waitForTimeout(300)
assert.strictEqual(await page.textContent('.tiptap'), beforeText,
'the measuring paragraph is taken back out, leaving the document as it was')
console.log('✓ reader text size: page width holds, lines re-break, rides on top of the style, persists')
// --- the toolbar always fits its row ---
// Added because it did not. #header-tools scrolls with its scrollbar hidden
// (app.css: scrollbar-width: none), so a toolbar too wide for its track does
// not announce itself — it just ends, and the buttons past the edge are gone
// with nothing on screen saying they exist. Adding the text-size pair took the
// toolbar from 721px to 818px and pushed the image button off a 1280px window.
//
// The width the toolbar needs is not a function of the window width, which is
// the whole reason this is measured rather than fixed at a breakpoint: the
// title is up to 260px of the row (#doc-title max-width) and a full-length one
// moves the point where it stops fitting by ~180px. So the sweep runs at both
// title lengths, and asserts the header grew a row instead of clipping.
const fits = () =>
page.evaluate(() => {
const bar = document.getElementById('header-tools')
const track = bar.getBoundingClientRect()
const within = id => {
const e = document.getElementById(id)
if (!e) return null
const b = e.getBoundingClientRect()
return b.width > 0 && b.left >= track.left - 1 && b.right <= track.right + 1
}
return {
clipped: Math.max(0, bar.scrollWidth - bar.clientWidth),
ownRow: document.body.classList.contains('tools-own-row'),
image: within('image-btn'),
smaller: within('text-smaller'),
larger: within('text-larger'),
label: document.getElementById('docsize-label')?.textContent,
}
})
const LONG_TITLE = 'Quarterly Planning and Roadmap Review Document 2026'
const shortTitle = await page.textContent('#doc-title')
for (const title of [shortTitle, LONG_TITLE]) {
// the title is layout here, not content — set it directly rather than
// renaming the document out from under every later test
await page.evaluate(t => { document.getElementById('doc-title').textContent = t }, title)
for (const width of [1600, 1440, 1366, 1280, 1200, 1100, 1024]) {
await page.setViewportSize({ width, height: 800 })
await page.waitForTimeout(200)
const f = await fits()
const at = `${width}px, title "${title.slice(0, 18)}"`
assert.strictEqual(f.clipped, 0, `the toolbar is not cut off at ${at} (${f.clipped}px lost)`)
assert.ok(f.image, `the image button is reachable at ${at}`)
assert.ok(f.smaller && f.larger, `both text-size buttons are reachable at ${at}`)
assert.ok(/^\d+px$/.test(f.label), `the readout is whole, not truncated, at ${at} (got "${f.label}")`)
}
}
// and the way it wins is by taking a row, not by shrinking anything
await page.setViewportSize({ width: 1280, height: 800 })
await page.waitForTimeout(250)
const narrowFit = await fits()
assert.ok(narrowFit.ownRow, 'at 1280 with a long title the toolbar takes a row of its own')
const headerGrew = await page.evaluate(() => ({
topbar: Math.round(document.getElementById('topbar').getBoundingClientRect().height),
hdrVar: parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--hdr-h')),
}))
assert.ok(headerGrew.topbar > 52, `the header grew a row rather than clipping (${headerGrew.topbar}px)`)
assert.ok(Math.abs(headerGrew.hdrVar - headerGrew.topbar) <= 1,
`--hdr-h follows it, so the drawer and popovers stay under the header (${headerGrew.hdrVar} vs ${headerGrew.topbar})`)
// a wide window puts it back on one row
await page.evaluate(t => { document.getElementById('doc-title').textContent = t }, shortTitle)
await page.setViewportSize({ width: 1440, height: 900 })
await page.waitForTimeout(250)
const roomy = await fits()
assert.ok(!roomy.ownRow, 'and gives the row back when the window can hold it')
assert.strictEqual(roomy.clipped, 0, 'still nothing clipped at 1440')
console.log('✓ the toolbar fits its row at every desktop width, or takes a row of its own')
// --- undo / redo ---
await focusDocEnd(page)
await page.waitForTimeout(600)
await page.keyboard.type(' UNDOME')
await waitFor(async () => (await page.textContent('.tiptap')).includes('UNDOME'), 'typed marker')
await page.click('#undo-btn')
await waitFor(async () => !(await page.textContent('.tiptap')).includes('UNDOME'), 'undo removes marker')
await page.click('#redo-btn')
await waitFor(async () => (await page.textContent('.tiptap')).includes('UNDOME'), 'redo restores marker')
await page.click('#undo-btn')
console.log('✓ undo / redo buttons')
await page.screenshot({ path: `${SHOT}/redesign-doc.png`, fullPage: false })
// "- [ ] " markdown shortcut converts to a real task list (done last so it
// doesn't perturb the earlier suggestion tests on this doc)
await focusDocEnd(page)
await page.keyboard.press('Enter')
await page.keyboard.type('- [ ] shortcut task')
await waitFor(async () => !!(await page.$('.tiptap ul[data-type="taskList"] input')), '"- [ ] " creates a task list')
assert.ok(!(await page.textContent('.tiptap')).includes('[ ] shortcut'), 'no literal bracket text left')
console.log('✓ "- [ ] " shortcut makes a task list')
// live HTML embed: insert via toolbar, render in a sandboxed iframe, persist
await page.click('button[title="Embed live HTML (sandboxed)"]')
await waitFor(async () => !!(await page.$('.embed-overlay-ta')), 'embed overlay opens')
await page.fill('.embed-overlay-ta', '<div id="embed-probe" style="height:220px">hello <b>embed</b></div>')
await page.click('.embed-overlay .btn.primary')
await waitFor(async () => !!(await page.$('.tiptap iframe.html-embed')), 'embed iframe renders')
const sandbox = await page.getAttribute('.tiptap iframe.html-embed', 'sandbox')
assert.ok(/\ballow-scripts\b/.test(sandbox), 'iframe allows scripts')
assert.ok(!/allow-same-origin/.test(sandbox), 'iframe is NOT same-origin (sandbox intact)')
// content actually rendered inside the frame
await waitFor(async () => {
const fr = page.frames().find(f => f.parentFrame())
try { return !!(fr && (await fr.$('#embed-probe'))) } catch { return false }
}, 'embed content mounted inside frame')
// iframe auto-sizes to content — no stray inner scrollbar (frame not scrollable)
await waitFor(async () => {
const fr = page.frames().find(f => f.parentFrame())
try {
const m = await fr.evaluate(() => ({ s: document.documentElement.scrollHeight, c: document.documentElement.clientHeight }))
return m.c >= 200 && m.s <= m.c + 1
} catch { return false }
}, 'embed frame fits its content (no vertical scrollbar)')
// survives a reload (round-trips through Yjs + markdown)
await page.reload({ waitUntil: 'networkidle' })
await waitFor(async () => !!(await page.$('.tiptap iframe.html-embed')), 'embed persists after reload')
console.log('✓ sandboxed HTML embed renders + persists')
// --- playground: sidebar, strips, chains, image bubble, reset ---
await page.click('#docs-btn')
await page.waitForSelector('#docs-pop:not(.hidden)')
await page.click('#playground-btn')
await page.waitForURL('**/d/**')
await page.waitForSelector('.tiptap')
await waitFor(async () => (await page.$$('.card')).length >= 8, 'playground cards render')
await page.waitForTimeout(800)
const treeLinks = await page.$$eval('#page-tree a', as => as.map(a => a.textContent))
assert.ok(treeLinks.length >= 2, 'sidebar tree has pages: ' + JSON.stringify(treeLinks))
console.log('✓ playground: sidebar tree')
// --- REGRESSION: suggestions and comments must work on a SUBPAGE too ---
// Anchors are relative positions into the page's OWN fragment. The client
// resolved them against 'default' — the home page's fragment — so on every
// other page a suggestion's range came back null: the card appeared in the
// margin, the inline preview never did, and there was nothing to review.
{
const target = await page.$$eval('#page-tree a', as => {
const link = as.find(a => /\/d\/[^/]+\/.+/.test(a.getAttribute('href') || ''))
return link ? link.getAttribute('href') : null
})
assert.ok(target, 'playground has a subpage to open')
await page.click(`#page-tree a[href="${target}"]`)
await page.waitForURL(`**${target}`)
await page.waitForSelector('.tiptap')
await page.waitForTimeout(600)
// baseline on THIS page: the home page has highlights of its own
const before = (await page.$$('.tiptap .comment-hl')).length
const posted = await page.evaluate(async () => {
const [id, slug] = location.pathname.split('/d/')[1].split('/')
const snap = await (await fetch(`/api/docs/${id}?page=${slug}`)).json()
const idx = (snap.blocks || []).findIndex(b => !/^#/.test(b.markdown || '') && (b.markdown || '').split(/\s+/).length > 3)
// keep the replacement CLOSE to the original: an unrelated rewrite renders
// as an insertion panel by design, and it is the in-place word diff that
// the anchor range is needed for
const original = snap.blocks[idx].markdown
const words = original.split(/\s+/)
const swapped = words.map(w => (w === words[2] ? 'zwitschernd' : w)).join(' ')
const sugg = await (
await fetch(`/api/docs/${id}/suggestions`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ page: slug, block_index: idx, replacement_markdown: swapped, rationale: 'subpage preview check' }),
})
).json()
const loose = await (
await fetch(`/api/docs/${id}/threads`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ page: slug, text: 'Page-level note: this belongs to the page, not to one sentence.' }),
})
).json()
return { slug, idx, sugg, loose, replaced: words[2] }
})
assert.ok(posted.idx >= 0 && posted.sugg.suggestion_id, 'suggestion posted on the subpage: ' + JSON.stringify(posted))
await waitFor(
async () => (await page.evaluate(() => [...document.querySelectorAll('.tiptap .sugg-ins')].map(n => n.textContent).join(' '))).includes('zwitschernd'),
'a subpage suggestion previews inline, not only as a card'
)
await waitFor(
async () => (await page.evaluate(() => [...document.querySelectorAll('.tiptap .sugg-del')].map(n => n.textContent).join(' '))).includes(posted.replaced),
'the replaced word is struck through on a subpage'
)
// an unanchored comment: its own card, marked as page-level, marking no text
assert.equal(posted.loose.anchored, false, 'thread posted without an anchor: ' + JSON.stringify(posted.loose))
await waitFor(async () => (await page.$$('.card.thread')).length > 0, 'page-level comment gets a card')
await page.click('.card.thread')
await waitFor(async () => !!(await page.$('.card.thread.expanded .page-level')), 'page-level comment says it is about the page')
assert.equal((await page.$$('.tiptap .comment-hl')).length, before, 'a page-level comment highlights no text')
console.log('✓ subpages: suggestions preview inline, page-level comments show as their own card')
// back to the playground home: everything below expects the seeded page
await page.click(`#page-tree a[href$="/d/${target.split('/d/')[1].split('/')[0]}"]`)
await page.waitForSelector('.tiptap')
await waitFor(async () => !!(await page.$('.tiptap ul[data-type="taskList"] input')), 'back on the seeded home page')
}
// task lists + tables render natively in the seeded home page
await waitFor(async () => !!(await page.$('.tiptap ul[data-type="taskList"] input')), 'task list renders with checkboxes')
await waitFor(async () => !!(await page.$('.tiptap table th')), 'table renders with a header row')
await waitFor(async () => !!(await page.$('.tiptap iframe.html-embed')), 'seeded HTML embed renders in the editor')
console.log('✓ task lists + tables + HTML embed render in the editor')
// --- table columns resize by dragging a cell border, and the width sticks ---
const colWidths = () => page.evaluate(() =>
[...document.querySelectorAll('.tiptap table:first-of-type tr:first-child > *')]
.map(c => ({ px: Math.round(c.getBoundingClientRect().width), attr: c.getAttribute('colwidth') })))
const tableWidth = () => page.evaluate(() =>
Math.round(document.querySelector('.tiptap table:first-of-type').getBoundingClientRect().width))
const startCols = await colWidths()
const startTable = await tableWidth()
assert.ok(startCols.length >= 2, 'seeded table has columns to resize')
const border = await page.evaluate(() => {
const c = document.querySelector('.tiptap table:first-of-type tr:first-child > *')
const r = c.getBoundingClientRect()
return { x: r.right, y: r.top + r.height / 2 }
})
await page.mouse.move(border.x - 1, border.y)
await waitFor(async () => !!(await page.$('.tiptap .column-resize-handle')), 'resize handle appears on the border')
assert.ok(await page.evaluate(() => document.querySelector('.tiptap').classList.contains('resize-cursor')),
'the editor shows a col-resize cursor while the border is under the pointer')
await page.mouse.down()
await page.mouse.move(border.x - 90, border.y, { steps: 10 })
await page.mouse.up()
await waitFor(async () => (await colWidths())[0].attr !== null, 'drag writes a colwidth onto the column')
const narrowedCols = await colWidths()
assert.ok(narrowedCols[0].px < startCols[0].px - 40, `first column narrowed (${startCols[0].px} -> ${narrowedCols[0].px})`)
// the last column is deliberately elastic, so the table itself must not grow
assert.ok(Math.abs(await tableWidth() - startTable) <= 2,
`table stays pinned to the page width (${startTable} -> ${await tableWidth()})`)
// and the width is an edit like any other: it survives a reload through the doc
await page.reload({ waitUntil: 'domcontentloaded' })
await waitFor(async () => !!(await page.$('.tiptap table')), 'doc reloads with the table')
await waitFor(async () => (await colWidths())[0].attr !== null, 'column width persisted across the reload')
const colsAfterReload = await colWidths()
assert.strictEqual(colsAfterReload[0].attr, narrowedCols[0].attr,
`same colwidth after reload (${narrowedCols[0].attr} vs ${colsAfterReload[0].attr})`)
console.log('✓ table columns resize by drag, table width pinned, widths persist')
// --- the switcher is the whole of navigation now: list, switch, and / resolves ---
const firstDocUrl = page.url()
await page.click('#docs-btn')
await page.waitForSelector('#docs-pop:not(.hidden)')
await waitFor(async () => (await page.$$('#docs-pop .doc-row')).length >= 2, 'switcher lists the documents')
const rows = await page.$$eval('#docs-pop .doc-row', rs => rs.map(r => ({
title: r.querySelector('.title')?.textContent, current: r.classList.contains('current') })))
assert.strictEqual(rows.filter(r => r.current).length, 1, 'exactly one row is marked as the one you are in: ' + JSON.stringify(rows))
// switching is a navigation, so the URL is the document — a link worth sharing
const other = rows.find(r => !r.current)
await page.click(`#docs-pop .doc-row:not(.current) .doc-row-main`)
await page.waitForURL(u => u.toString() !== firstDocUrl, { timeout: 15000 })
await page.waitForSelector('.tiptap', { timeout: 20000 })
await waitFor(async () => (await page.textContent('#doc-title')).trim() === other.title,
`header shows the document switched to (${other.title})`)
assert.ok(await page.isHidden('#docs-pop'), 'the switcher closes behind the navigation')
console.log('✓ switcher lists documents, marks the current one, switches by navigation')
// / is no longer a page of its own: it resolves to the most recent document
const landed = await page.goto(`${BASE}/`, { waitUntil: 'domcontentloaded' })
assert.ok(/\/d\//.test(page.url()), 'a signed-in visitor at / lands in a document: ' + page.url())
assert.strictEqual(landed.request().redirectedFrom() ? 302 : landed.status(), landed.request().redirectedFrom() ? 302 : 200)
await page.waitForSelector('.tiptap', { timeout: 20000 })
console.log('✓ / resolves to the most recent document, no index page')
// new-page suggestion: the seeded proposal shows as pending; preview + accept creates it
await page.evaluate(() => { window.__projectCollabProbe = { provider: window.__provider, ydoc: window.__ydoc } })
await waitFor(async () => !!(await page.$('#page-tree a.pending-page')), 'proposed page shows in sidebar')
await page.click('#page-tree a.pending-page')
await page.waitForSelector('#proposal-view:not(.hidden)')
await waitFor(async () => !!(await page.$('#proposal-view table')), 'proposal preview renders content')
await page.click('#proposal-view .proposal-actions .primary')
await waitFor(async () => (await page.$$eval('#page-tree a', as => as.map(a => a.textContent))).some(t => t.includes('Proposed Page') && !t.includes('proposed')), 'accepted page joins the tree')
await waitFor(async () => !(await page.$('#page-tree a.pending-page')), 'no longer pending after accept')
assert.ok(await page.evaluate(() => window.__provider === window.__projectCollabProbe.provider && window.__ydoc === window.__projectCollabProbe.ydoc),
'opening a page keeps the project collaboration connection')
console.log('✓ new-page suggestion: pending → preview → accept creates the page')
// --- a proposed page shows where it would land ---
// Proposals used to be listed under a "proposed" heading below everything, so
// an agent's placement was invisible until someone accepted it. They are drawn
// in the tree at their {parent, index} instead.
const placed = await page.evaluate(async () => {
const id = location.pathname.split('/')[2]
const post = (u, b) => fetch(u, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) }).then(r => r.json())
await post(`/api/docs/${id}/structure/groups`, { label: 'Handbook' })
await post(`/api/docs/${id}/page-suggestions`, { title: 'Filed Proposal', content_markdown: '# Filed\n\nbody', parent: ['"Handbook"'], index: 0 })
return id
})
await page.reload({ waitUntil: 'domcontentloaded' }) // the sidebar polls every 15s; a reload is deterministic
await page.waitForSelector('#page-tree a', { timeout: 20000 })
await page.waitForFunction(() => !!window.__provider, { timeout: 20000 })
// the reload replaced the provider this run compares against later
await page.evaluate(() => { window.__projectCollabProbe = { provider: window.__provider, ydoc: window.__ydoc } })
await waitFor(async () => (await page.$$eval('#page-tree .proposal-row a', as => as.map(a => a.textContent))).some(t => t.includes('Filed Proposal')), 'the proposal appears in the tree')
const proposalShape = await page.evaluate(() => {
const rows = [...document.querySelectorAll('#page-tree .tree-row')]
const groupIdx = rows.findIndex(r => r.querySelector('.group-label')?.textContent.trim() === 'Handbook')
const propIdx = rows.findIndex(r => r.classList.contains('proposal-row') && r.textContent.includes('Filed Proposal'))
return {
rightAfterItsGroup: propIdx === groupIdx + 1,
indentedDeeper: rows[propIdx].getBoundingClientRect().left > rows[groupIdx].getBoundingClientRect().left,
sectionHeadings: [...document.querySelectorAll('#page-tree .unfiled-label')].map(n => n.textContent.trim()),
stillClickable: !!rows[propIdx].querySelector('a.pending-page'),
}
})
assert.ok(proposalShape.rightAfterItsGroup, 'the proposal sits directly under the group it was proposed for: ' + JSON.stringify(proposalShape))
assert.ok(proposalShape.indentedDeeper, 'and is indented as that group\'s child')
assert.ok(!proposalShape.sectionHeadings.includes('proposed'), 'no separate "proposed" section remains: ' + JSON.stringify(proposalShape.sectionHeadings))
assert.ok(proposalShape.stillClickable, 'and it still opens its preview')
console.log('✓ proposed pages render in place in the tree, not in a list below it')
// --- the open page is marked by its highlight alone ---
const currentRow = await page.evaluate(() => {
const row = document.querySelector('#page-tree .page-row:has(a.current)')
if (!row) return null
const cs = getComputedStyle(row)
const alpha = c => Number((c.match(/[\d.]+\)$/) || ['1)'])[0].slice(0, -1))
return {
leftRuleVisible: cs.borderLeftStyle !== 'none' && parseFloat(cs.borderLeftWidth) > 0 && alpha(cs.borderLeftColor) > 0,
hasTint: cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== 'transparent',
}
})
assert.ok(currentRow, 'the current page has a row in the tree')
assert.ok(!currentRow.leftRuleVisible, 'no accent rule down the left of the current row: ' + JSON.stringify(currentRow))
assert.ok(currentRow.hasTint, 'the highlight still marks it: ' + JSON.stringify(currentRow))
console.log('✓ current page: highlight only, no left accent rule')
// --- download: page vs project, markdown vs pdf ---
await page.click('#download-btn')
await page.waitForSelector('#download-pop:not(.hidden)')
const dlPanel = await page.evaluate(() => ({
scope: [...document.querySelectorAll('#dl-scope .dl-opt')].map(b => ({ label: b.textContent, on: b.classList.contains('on') })),
format: [...document.querySelectorAll('#dl-format .dl-opt')].map(b => ({ label: b.textContent, on: b.classList.contains('on') })),
action: document.getElementById('dl-go').textContent,
}))
assert.deepEqual(dlPanel.scope.map(o => o.label), ['This page', 'Whole project'], 'scope choices: ' + JSON.stringify(dlPanel))
assert.deepEqual(dlPanel.format.map(o => o.label), ['Markdown', 'PDF'], 'format choices: ' + JSON.stringify(dlPanel))
assert.ok(dlPanel.scope[0].on && dlPanel.format[0].on, 'defaults to this page as markdown: ' + JSON.stringify(dlPanel))
// markdown, this page: a real download with the server's filename
const [pageZip] = await Promise.all([page.waitForEvent('download'), page.click('#dl-go')])
assert.match(pageZip.suggestedFilename(), /\.zip$/, 'page download is a zip: ' + pageZip.suggestedFilename())
// markdown, whole project
await page.click('#download-btn')
await page.click('#dl-scope .dl-opt:nth-child(2)')
const [projZip] = await Promise.all([page.waitForEvent('download'), page.click('#dl-go')])
assert.match(projZip.suggestedFilename(), /-project\.zip$/, 'project download is named for the project: ' + projZip.suggestedFilename())
// pdf: goes through the browser's print dialog, which the test must stand in for
await page.evaluate(() => {
window.__print = { calls: 0, seen: null }
window.print = () => {
window.__print.calls++
window.__print.seen = {
sheets: document.querySelectorAll('#print-view .print-page').length,
printingClass: document.body.classList.contains('printing'),
headings: [...document.querySelectorAll('#print-view h1, #print-view h2')].map(h => h.textContent.trim()).slice(0, 6),
emptyMath: document.querySelectorAll('#print-view .math-view:empty').length,
}
}
})
await page.click('#download-btn')
await page.click('#dl-format .dl-opt:nth-child(2)')
assert.match(await page.textContent('#dl-go'), /print/i, 'the action says what will happen for PDF')
await page.click('#dl-go')
await page.waitForFunction(() => window.__print?.calls > 0, { timeout: 30000 })
const printed = await page.evaluate(() => window.__print.seen)
const pageCount = await page.evaluate(() => document.querySelectorAll('#page-tree .page-row:not(.proposal-row) a:not(.ghost-page)').length)
assert.ok(printed.sheets > 1 && printed.sheets <= pageCount + 1, `every project page gets a sheet (${printed.sheets} sheets, ${pageCount} pages in the tree)`)
assert.ok(printed.printingClass, 'the print-only body class is on while printing')
assert.equal(printed.emptyMath, 0, 'formulas are typeset, not left as empty placeholders')
// and on paper the app itself must not appear
await page.emulateMedia({ media: 'print' })
const onPaper = await page.evaluate(() => {
const painted = id => { const n = document.getElementById(id); if (!n) return false; const r = n.getBoundingClientRect(); return r.width > 0 && r.height > 0 }
return { topbar: painted('topbar'), sidebar: painted('sidebar'), editor: painted('editor'), printView: painted('print-view') }
})
await page.emulateMedia({ media: null })
assert.ok(onPaper.printView, 'the print rendition is what gets printed: ' + JSON.stringify(onPaper))
assert.ok(!onPaper.topbar && !onPaper.sidebar && !onPaper.editor, 'no app chrome on the page: ' + JSON.stringify(onPaper))
// the print view is scaffolding: it must not outlive the dialog
await page.evaluate(() => window.dispatchEvent(new Event('afterprint')))
await waitFor(async () => page.evaluate(() => !document.getElementById('print-view') && !document.body.classList.contains('printing')), 'print view is torn down afterwards')
console.log('✓ download: page/project × markdown/pdf, print shows the document alone')
// --- the home star must not indent home's title ---
// top-level real pages only: a nested page or a proposal is indented on purpose
const titleXs = await page.evaluate(() =>
[...document.querySelectorAll('#page-tree .page-row[data-depth="0"]:not(.proposal-row)')].map(r => ({
title: r.querySelector('a')?.textContent.trim(),
left: Math.round(r.querySelector('a')?.getBoundingClientRect().left),
star: !!r.querySelector('.home-star'),
}))
)
const starred = titleXs.find(t => t.star)
assert.ok(starred, 'the front page still carries its star: ' + JSON.stringify(titleXs))
// and it must not land on the caret when home has children of its own
const starClash = await page.evaluate(() => {
const row = document.querySelector('#page-tree .page-row:has(.home-star)')
const star = row?.querySelector('.home-star')
const caret = row?.querySelector('.tree-caret')
if (!star || !caret) return { bothPresent: false }
const l = n => n.getBoundingClientRect().left
return { bothPresent: true, apart: Math.abs(l(star) - l(caret)) >= 12 }
})
if (starClash.bothPresent) assert.ok(starClash.apart, 'star and caret do not sit on top of each other: ' + JSON.stringify(starClash))
const others = titleXs.filter(t => !t.star)
assert.ok(others.length > 0, 'there are sibling pages to compare against: ' + JSON.stringify(titleXs))
for (const other of others) {
assert.strictEqual(starred.left, other.left, `"${starred.title}" starts where "${other.title}" does: ` + JSON.stringify(titleXs))
}
console.log('✓ the star sits in the gutter; every sidebar title starts at the same x')
// return to the home page for the remaining playground sub-tests
await page.evaluate(() => [...document.querySelectorAll('#page-tree a')].find(a => a.textContent.trim() === 'Playground')?.click())
await waitFor(async () => (await page.$$('.card.suggestion')).length >= 1, 'back on home with suggestions')
assert.ok(await page.evaluate(() => window.__provider === window.__projectCollabProbe.provider && window.__ydoc === window.__projectCollabProbe.ydoc),
'returning home keeps the same project collaboration connection')
console.log('✓ page switches reuse one synced project document')
// --- search: page text, not just titles -------------------------------------
// The sidebar box indexes every page of the project out of the one synced Yjs
// document, so "suggestion" (which is in the prose, not in any title) has to
// find home, list each occurrence with the heading it sits under, and mark
// them in the open document.
const playgroundHome = page.url()
// hits under one page's row — by default the open one, whose marks we can also
// count in the document; '*' for every page, since more than one of them
// mentions "suggestion"
const hitRows = (scope = 'li:has(> .page-row > a.current)') =>
page.$$eval(`#page-tree ${scope} > .tree-hits .tree-hit:not(.more)`, rs => rs.map(r => ({
heading: r.querySelector('.tree-hit-sec')?.textContent || '',
snippet: r.querySelector('.tree-hit-text')?.textContent || '',
href: r.getAttribute('href'),
})))
await page.fill('#sidebar-search', 'suggestion')
await waitFor(async () => (await hitRows()).length > 0, 'typing text finds hits inside a page body')
const suggestionHits = await hitRows()
const marked = await page.$$eval('#editor mark.search-hl', ms => ms.map(m => m.textContent))
assert.ok(marked.length >= 5, `every occurrence is marked in the document (${marked.length}): ` + JSON.stringify(marked))
assert.ok(marked.every(t => t.toLowerCase() === 'suggestion'), 'marks cover the query and nothing else: ' + JSON.stringify(marked))
// the pill is the page's TRUE count, which is more than the snippets listed
const trueCount = Number(await page.$eval('#page-tree .page-row:has(a.current) .tree-hit-count', n => n.textContent))
assert.strictEqual(trueCount, marked.length, `the count on the row is the real number of hits (${trueCount} vs ${marked.length} marks)`)
assert.ok(suggestionHits.every(h => /suggestion/i.test(h.snippet)), 'each snippet quotes the match in context: ' + JSON.stringify(suggestionHits))
assert.ok(suggestionHits.some(h => h.heading === 'Checklist') && suggestionHits.some(h => h.heading === 'Table'),
'each hit is tagged with the heading it sits under: ' + JSON.stringify(suggestionHits.map(h => h.heading)))
// hits are links: /d/<id>/<page>?q=<term>&n=<hit>, shareable and openable in a tab
assert.match(suggestionHits[0].href, /\?q=suggestion$/, 'the first hit links to the plain query: ' + suggestionHits[0].href)
assert.match(suggestionHits[1].href, /\?q=suggestion&n=1$/, 'later hits carry their ordinal: ' + suggestionHits[1].href)
// Clicking a hit is a place you can come back from, on the open page as much as
// on another one — typing the query is what must not litter the history, not
// deciding to go and look at something.
await page.click('#page-tree .tree-hits .tree-hit:nth-child(2)')
await page.waitForURL(u => /\?q=suggestion&n=1$/.test(u.toString()), { timeout: 15000 })
await page.evaluate(() => history.back())
await page.waitForURL(u => /\?q=suggestion$/.test(u.toString()), { timeout: 15000 })
assert.ok(await page.$('#editor mark.search-hl'), 'going back from a hit keeps the search running')
// a common word overruns the per-page snippet list, and the ones it did not
// print are still counted — the pill and the "+n more" row have to add up
await page.fill('#sidebar-search', 'the')
await waitFor(async () => !!(await page.$('#page-tree .tree-hit.more')), 'a common word overruns the snippet list')
const capped = await page.evaluate(() => {
const li = document.querySelector('#page-tree li:has(> .page-row > a.current)')
return {
count: Number(li.querySelector('.tree-hit-count').textContent),
listed: li.querySelectorAll(':scope > .tree-hits .tree-hit:not(.more)').length,
more: li.querySelector(':scope > .tree-hits .tree-hit.more').textContent,
marks: document.querySelectorAll('#editor mark.search-hl').length,
}
})
assert.ok(capped.listed < capped.count, `the snippet list is capped (${capped.listed}) where the count is not (${capped.count})`)
assert.strictEqual(capped.count, capped.marks, 'the count still matches what is marked in the document: ' + JSON.stringify(capped))
assert.strictEqual(capped.more, `+${capped.count - capped.listed} more matches`, 'the unlisted hits are accounted for: ' + JSON.stringify(capped))
// Two occurrences in one sentence produce the same excerpt, and each must bold
// its OWN one — re-finding the query inside the excerpt would bold the first
// occurrence twice and send the second hit to the first hit's words.
const bolded = await page.$$eval('#page-tree .tree-hit-text', ts => ts.map(t => ({
text: t.textContent,
at: t.firstChild.textContent.length, // the excerpt is [before, <b>, after]
})))
const twins = bolded.filter(a => bolded.some(b => b !== a && b.text === a.text))
assert.ok(twins.length >= 2, 'the fixture has two hits sharing one excerpt: ' + JSON.stringify(bolded.map(b => b.text)))
assert.ok(new Set(twins.map(t => t.at)).size >= 2, 'they bold their own occurrence, not the same one twice: ' + JSON.stringify(twins))
console.log('✓ search: body hits with per-hit headings, true counts, in-document marks')
// a title match bolds the matched part of the title and stays quiet otherwise:
// there is no body hit to list for it
await page.fill('#sidebar-search', 'note')
await waitFor(async () => !!(await page.$('#page-tree .page-row a b')), 'a title query bolds what it matched')
const titleMatches = await page.$$eval('#page-tree .page-row a:has(b)', as => as.map(a => ({
title: a.querySelector('.tree-title')?.textContent,
bold: a.querySelector('b')?.textContent,
})))
assert.ok(titleMatches.some(m => m.title === 'Notes' && m.bold === 'Note'),
'the matched part of the title is bolded: ' + JSON.stringify(titleMatches))
// a query that only exists on another page pulls that page up, and clicking the
// hit navigates there and scrolls to it
await page.fill('#sidebar-search', 'defines the tree')
await waitFor(async () => (await hitRows('*')).length === 1, 'a phrase only on the Notes page finds only that page')
assert.deepStrictEqual(
await page.$$eval('#page-tree .page-row a .tree-title', ts => ts.map(t => t.textContent.trim())),
['Notes'],
'the tree is filtered down to the page that contains the phrase'
)
await page.click('#page-tree .tree-hit')
await page.waitForURL(u => /\/notes\?q=/.test(u.toString()), { timeout: 15000 })
await waitFor(async () => (await page.$$('#editor mark.search-hl')).length === 1, 'the hit is marked on the page it navigated to')
const onScreen = await page.evaluate(() => {
const m = document.querySelector('#editor mark.search-hl')
const r = m.getBoundingClientRect()
return r.top > 0 && r.bottom < window.innerHeight
})
assert.ok(onScreen, 'the clicked hit is scrolled into view')
console.log('✓ search: clicking a hit on another page navigates and lands on it')
// ?q= is a deep link: it starts the search, marks the document and jumps to the
// named hit — this is what the hrefs above resolve to for someone you send one to
await page.goto(`${playgroundHome}?q=greet`, { waitUntil: 'domcontentloaded' })
await page.waitForSelector('.tiptap')
await waitFor(async () => (await page.inputValue('#sidebar-search')) === 'greet', 'a ?q= link arrives with the search box filled in')
await waitFor(async () => (await page.$$('#editor mark.search-hl')).length > 0, 'a ?q= link marks the document once it has synced')
// the hit list can only fill in once the socket has synced the text, which on a
// cold load lands after the shell has already painted the tree
await waitFor(async () => JSON.stringify((await hitRows()).map(h => h.heading)) === '["Code"]',
'the deep-linked hit is listed under its heading')
await waitFor(
async () => page.evaluate(() => {
const r = document.querySelector('#editor mark.search-hl').getBoundingClientRect()
return r.top > 0 && r.bottom < window.innerHeight
}),
'a ?q= link scrolls its first hit into view'
)
// `n=` is whatever the URL said, so it can name a hit that does not exist. That
// must not leave the reader on an unscrolled page while the frame budget drains
// (~10s of it) — once the marks stop arriving, the last hit is the answer.
await page.goto(`${playgroundHome}?q=greet&n=99999`, { waitUntil: 'domcontentloaded' })
await page.waitForSelector('.tiptap')
await waitFor(
async () => page.evaluate(() => {
const ms = document.querySelectorAll('#editor mark.search-hl')
if (!ms.length) return false
const r = ms[ms.length - 1].getBoundingClientRect()
return r.top > 0 && r.bottom < window.innerHeight
}),
'an out-of-range n= settles on the last hit instead of spinning',
6000
)
// The index is the live CRDT, not a snapshot the server took: text typed now is
// searchable now. Done on Notes, which carries none of home's seeded
// suggestions — typing into a block one of those is anchored to is a different
// test, and not this one's business.
await page.goto(`${playgroundHome}/notes`, { waitUntil: 'domcontentloaded' })
await page.waitForSelector('.tiptap')
await page.fill('#sidebar-search', 'quokka')
await waitFor(async () => !!(await page.$('#page-tree .tree-empty')), 'a query that matches nothing says so instead of emptying the sidebar')
await page.evaluate(() => window.__editor.chain().focus('end').insertContent(' quokka ').run())
await waitFor(async () => (await hitRows()).length === 1, 'text typed while the search is running becomes a hit')
await waitFor(async () => (await page.$$('#editor mark.search-hl')).length === 1, 'and is marked as it is typed')
await page.evaluate(() => window.__editor.commands.undo())
await waitFor(async () => (await page.$$('#editor mark.search-hl')).length === 0, 'undoing the text takes its hit away again')
// The invariant both walks rest on: a non-text inline node counts as exactly
// one character, because that is what it costs in a ProseMirror position. Get
// that wrong and every mark after an atom in the same block slides left by one
// per atom — so this paragraph puts a mark AND a formula ahead of the hit.
// Nothing in the seeded fixture does (its images are their own blocks), which
// is why this is built here rather than searched for. Built as explicit JSON
// because the markdown seeder does not parse inline `$…$` — it survives as
// literal text, and literal text would not test anything: a marked text node
// still costs one position per character, so only a real atom bites.
await page.evaluate(() =>
window.__editor
.chain()
.focus('end')
.insertContent({
type: 'paragraph',
content: [
{ type: 'text', marks: [{ type: 'bold' }], text: 'Bold' },
{ type: 'text', text: ' and ' },
{ type: 'mathInline', attrs: { latex: 'x^2' } },
{ type: 'text', text: ' then wombat here, but wom' },
{ type: 'mathInline', attrs: { latex: 'y' } },
{ type: 'text', text: 'bat is two words with a formula in it.' },
],
})
.run()
)
await page.waitForSelector('.tiptap .math-inline', { timeout: 5000 })
await page.fill('#sidebar-search', 'wombat')
await waitFor(async () => (await page.$$('#editor mark.search-hl')).length > 0, 'a hit sitting after an atom is still marked')
// Two things at once, and each fails on a different slip. The count: a formula
// interrupts a word, so `wom`+formula+`bat` is NOT the query — count the atom
// as zero characters and it becomes a spurious hit the sidebar never counted.
// The text: the mark has to land ON the query, which is what the position
// arithmetic after two atoms is for.
const searchAtomHit = await page.evaluate(() => ({
marks: [...document.querySelectorAll('#editor mark.search-hl')].map(m => m.textContent),
pill: Number(document.querySelector('#page-tree .page-row:has(a.current) .tree-hit-count')?.textContent || 1),
}))
assert.deepStrictEqual(searchAtomHit.marks, ['wombat'],
'an atom is one unmatchable character: the query is marked once, exactly, and does not span a formula: ' + JSON.stringify(searchAtomHit))
assert.strictEqual(searchAtomHit.pill, searchAtomHit.marks.length,
'and the sidebar counted the same hits the document marked: ' + JSON.stringify(searchAtomHit))
// put the page back for the sub-tests after this one (seeded text predates this
// session, so it is not in the undo history and cannot be walked back by mistake)
await page.evaluate(() => { for (let i = 0; i < 60; i++) window.__editor.commands.undo() })
await waitFor(async () => !(await page.$('.tiptap .math-inline')), 'the probe paragraph is undone again')
assert.ok(!(await page.textContent('.tiptap')).includes('wombat'), 'and takes its text with it')
// clearing puts everything back, including drag-and-drop
await page.click('#sidebar-search-key')
await waitFor(async () => (await page.$$('#editor mark.search-hl')).length === 0, 'clearing the box drops the marks')
assert.strictEqual(await page.inputValue('#sidebar-search'), '', 'the ✕ badge empties the box')
assert.ok(!/\?q=/.test(page.url()), 'and takes ?q= out of the URL: ' + page.url())
assert.ok(await page.evaluate(() => document.querySelectorAll('#page-tree [data-td-row]').length > 0), 'rows are draggable again')
console.log('✓ search: ?q= deep links, live index, empty state, clear restores the tree')
// back where the rest of the playground sub-tests expect to be
await page.goto(playgroundHome, { waitUntil: 'domcontentloaded' })
await waitFor(async () => (await page.$$('.card.suggestion')).length >= 1, 'back on the playground home page')
// revision chain: ‹ 2/3 shows generation two inline
assert.ok(await page.$('.chain-nav'), 'chain nav present')
assert.equal(await page.textContent('.chain-pos'), '3/3', 'chain defaults to latest')
await page.click('.chain-nav .iconbtn:first-child')
await page.waitForTimeout(400)
assert.equal(await page.textContent('.chain-pos'), '2/3', 'nav moves to generation 2')
const gen2Text = await page.textContent('.tiptap')
assert.ok(gen2Text.includes('generation') && gen2Text.includes('sharper'), 'inline diff follows the displayed generation')
await page.click('.chain-nav .iconbtn:last-child')
await page.waitForTimeout(300)
console.log('✓ revision chain navigation ‹ n/N ›')
// Feedback derived from one request stays as standalone cards. Focusing the
// source comment highlights every related card and its inline change.
const family = await page.evaluate(() => {
const origin = [...document.querySelectorAll('.card.thread')].find(c => c.textContent.includes('Can we end on something stronger?'))
const related = origin && document.querySelector(`.card.suggestion[data-origin="${origin.dataset.item}"]`)
return { origin: origin?.dataset.item || null, related: related?.dataset.item || null }
})
assert.ok(family.origin && family.related, 'origin and related suggestion are separate cards: ' + JSON.stringify(family))
const relatedCardLayout = await page.evaluate(id => {
const card = document.querySelector(`.card.suggestion[data-item="${id}"]`)
const head = card.querySelector('.head')
const cardRect = card.getBoundingClientRect()
const actionsRect = card.querySelector('.head-actions').getBoundingClientRect()
return {
head: [head.clientWidth, head.scrollWidth],
cardRight: Math.round(cardRect.right),
actionsRight: Math.round(actionsRect.right),
}
}, family.related)
assert.ok(relatedCardLayout.head[1] <= relatedCardLayout.head[0] + 1, 'suggestion header does not overflow: ' + JSON.stringify(relatedCardLayout))
assert.ok(relatedCardLayout.actionsRight <= relatedCardLayout.cardRight, 'suggestion actions remain inside the card: ' + JSON.stringify(relatedCardLayout))
await page.click(`.card.thread[data-item="${family.origin}"]`)
await waitFor(async () => !!(await page.$(`.card.suggestion[data-item="${family.related}"].origin-related`)), 'related suggestion highlighted from origin')
assert.ok(await page.$(`.tiptap .suggestion-hl[data-sugg="${family.related}"].active`), 'related inline change highlighted from origin')
// Closing the source must not sever the trail. The derived card names the
// closed source and can reveal/focus it again through the existing resolved
// items view.
await page.click(`.card.thread[data-item="${family.origin}"] .close-btn`)
await waitFor(async () => await page.evaluate(id => {
const card = document.querySelector(`.card.thread[data-item="${id}"]`)
return !card || card.textContent.includes('closed')
}, family.origin), 'origin closes')
await waitFor(async () => (await page.textContent(`.card.suggestion[data-item="${family.related}"] .origin-link`)).includes('closed'), 'derived card identifies its closed origin')
await page.click(`.card.suggestion[data-item="${family.related}"] .origin-link`)
await waitFor(async () => !!(await page.$(`.card.thread[data-item="${family.origin}"].expanded`)), 'closed origin revealed and focused')
assert.ok(await page.$(`.card.suggestion[data-item="${family.related}"].origin-related`), 'related card remains highlighted for a closed origin')
// Restore the seeded source so the independent close-button regression below
// still has an open comment to exercise.
await page.evaluate(id => window.__ydoc.getMap('threads').get(id).set('resolved', false), family.origin)
await waitFor(async () => !!(await page.$(`.card.thread[data-item="${family.origin}"] .close-btn`)), 'origin restored for remaining playground checks')
await page.click(`.card.suggestion[data-item="${family.related}"] .accept-btn`)
await waitFor(async () => (await page.textContent('.tiptap')).includes('go break things'), 'standalone accept applies')
console.log('✓ standalone feedback cards retain and highlight their common origin')
// comment closable without opening the card
assert.equal(await page.textContent('.card.thread .close-btn'), 'Close', 'collapsed thread has a Close button')
await page.click('.card.thread .close-btn')
await waitFor(async () => !(await page.$('.card.thread:not(.expanded) .close-btn')), 'thread closed from collapsed head')
console.log('✓ comment closes without opening')
// image bubble: width + alignment
const img = await page.$('.tiptap img:not(.ProseMirror-separator)')
await img.scrollIntoViewIfNeeded()
await img.click()
await page.waitForTimeout(400)
assert.ok(await page.evaluate(() => !document.getElementById('image-bubble').classList.contains('hidden')), 'bubble appears on image selection')
for (const title of ['Width 50%', 'Align center']) {
await page.evaluate(t => {
;[...document.querySelectorAll('#image-bubble .iconbtn')].find(b => b.title === t).dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
}, title)
await page.waitForTimeout(300)
}
const imgAttrs = await page.evaluate(() => {
const i = document.querySelector('.tiptap img:not(.ProseMirror-separator)')
return { style: i.getAttribute('style') || '', align: i.getAttribute('data-align') }
})
assert.ok(imgAttrs.style.includes('50%') && imgAttrs.align === 'center', 'image resized + centered: ' + JSON.stringify(imgAttrs))
console.log('✓ image layout bubble (resize + align)')
// the structure page left the sidebar but stays reachable by URL
assert.ok(!(await page.$('#structure-link')), 'no structure link in the sidebar')
{
const docPath = await page.evaluate(() => location.pathname.split('/').slice(0, 3).join('/'))
await page.goto(BASE + docPath + '/_structure')
await page.waitForSelector('#structure-hint', { timeout: 8000 })
await page.goBack()
await page.waitForSelector('.tiptap', { timeout: 8000 })
}
// [[ cross-reference: autocomplete inserts a page link
await focusDocEnd(page)
await page.keyboard.press('Enter')
await page.keyboard.type('See also [[not')
await waitFor(async () => !(await page.evaluate(() => document.getElementById('pagelink-menu').classList.contains('hidden'))), 'pagelink menu appears')
await page.keyboard.press('Enter')
await waitFor(async () => page.evaluate(() => [...document.querySelectorAll('.tiptap a')].some(a => a.textContent === 'Notes' && a.getAttribute('href').endsWith('/notes'))), 'cross-reference link inserted')
console.log('✓ [[ page cross-references')
// --- a link you can actually click ---
// In an editor a plain click has to place the caret, so following a link
// needed ctrl/cmd+click — with nothing on screen saying so, a cross-reference
// just looked broken. A plain click now raises a bubble naming the target,
// with an Open button for a mouse to press.
const linkSel = '.tiptap a[href$="/notes"]'
await page.click(linkSel)
await waitFor(async () => !(await page.evaluate(() => document.getElementById('link-bubble').classList.contains('hidden'))), 'a plain click raises the link bubble')
const bubble = await page.evaluate(() => {
const b = document.getElementById('link-bubble')
const link = document.querySelector('.tiptap a[href$="/notes"]').getBoundingClientRect()
const box = b.getBoundingClientRect()
return {
target: b.querySelector('.lb-target').textContent,
actions: [...b.querySelectorAll('button')].map(x => x.textContent.trim() || x.dataset.tip),
nearTheLink: Math.abs(box.left - link.left) < 80 && box.top > link.top,
onScreen: box.left >= 0 && box.right <= innerWidth && box.bottom <= innerHeight,
}
})
assert.strictEqual(bubble.target, 'Notes', 'the bubble names the page it goes to, not a raw URL')
assert.ok(bubble.actions.includes('Open'), 'and offers Open: ' + JSON.stringify(bubble.actions))
assert.ok(bubble.nearTheLink && bubble.onScreen, 'anchored to the link, inside the window: ' + JSON.stringify(bubble))
const urlBeforeOpen = page.url()
await page.click('#link-bubble .btn')
await waitFor(async () => page.url().endsWith('/notes'), 'Open follows the link')
assert.notStrictEqual(page.url(), urlBeforeOpen, 'and it actually moved')
// ctrl+click must keep working for anyone who already knows it
await page.goBack()
await page.waitForSelector('.tiptap', { timeout: 10000 })
await page.waitForTimeout(800)
await page.click(linkSel, { modifiers: ['Control'] })
await waitFor(async () => page.url().endsWith('/notes'), 'ctrl+click still goes straight there')
await page.goBack()
await page.waitForSelector('.tiptap', { timeout: 10000 })
await page.waitForTimeout(800)
console.log('✓ link bubble: a plain click offers Open, ctrl+click still follows')
// --- what you are commenting on stays marked while you type about it ---
// Focusing the composer moves focus out of the editor, which drops the
// browser's own selection highlight: the text you picked went unmarked while
// the box asked you about it.
await focusDocEnd(page)
await page.keyboard.press('Enter')
await page.keyboard.type('A line worth discussing at some length.')
await page.waitForTimeout(400)
const pickLine = () => page.evaluate(() => {
const target = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith('A line worth discussing'))
const pos = window.__editor.view.posAtDOM(target, 0)
window.__editor.commands.setTextSelection({ from: pos, to: pos + target.textContent.length })
})
await pickLine()
await page.waitForSelector('#composer:not(.hidden)', { timeout: 10000 })
await page.click('#composer-text')
await waitFor(async () => page.evaluate(() => document.querySelectorAll('.tiptap .pending-hl').length === 1), 'the pending range is marked once the composer has focus')
const pending = await page.evaluate(() => {
const n = document.querySelector('.tiptap .pending-hl')
const cs = getComputedStyle(n)
return { text: n.textContent, bg: cs.backgroundColor, style: cs.borderBottomStyle, nativeSelectionGone: getSelection().isCollapsed }
})
assert.ok(pending.text.startsWith('A line worth discussing'), 'it marks the text the composer is about: ' + JSON.stringify(pending))
assert.notStrictEqual(pending.bg, 'rgba(0, 0, 0, 0)', 'and it is actually visible: ' + JSON.stringify(pending))
assert.strictEqual(pending.style, 'dashed', 'dashed, to read as not-yet-committed: ' + JSON.stringify(pending))
// and it is scaffolding: cancelling must not leave it behind
await page.evaluate(() => [...document.querySelectorAll('#composer button')].find(b => /cancel/i.test(b.textContent))?.click())
await waitFor(async () => page.evaluate(() => document.querySelectorAll('.tiptap .pending-hl').length === 0), 'cancelling clears the mark')
console.log('✓ composer marks the text it is about, and clears it on cancel')
// --- a comment and a suggestion on the same words ---
// Two filled highlights over one range cancelled each other out: the
// suggestion's insert/delete spans were invisible under the comment tint.
// the box opens when the selection CHANGES, so move the caret before picking
// the same line again
await focusDocEnd(page)
await page.waitForTimeout(200)
await pickLine()
await page.waitForSelector('#composer:not(.hidden)', { timeout: 10000 })
await page.fill('#composer-text', 'can this be tightened?')
await page.click('#composer .primary, #composer button:has-text("Comment")')
await page.waitForSelector('.card.thread', { timeout: 10000 })
await page.click('.card.thread') // active, as it is right after commenting
await page.waitForTimeout(300)
await page.evaluate(async () => {
const id = location.pathname.split('/d/')[1].split('/')[0]
const snap = await (await fetch(`/api/docs/${id}`)).json()
const idx = (snap.blocks || []).findIndex(b => (b.markdown || '').includes('A line worth discussing'))
await fetch(`/api/docs/${id}/suggestions`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ block_index: idx, replacement_markdown: 'A line worth debating at some length.', rationale: 'sharper verb' }),
})
})
await waitFor(async () => page.evaluate(() => document.querySelectorAll('.tiptap .sugg-ins').length > 0), 'the suggestion reaches the document')
const overlap = await page.evaluate(() => {
const opaque = c => c !== 'rgba(0, 0, 0, 0)' && c !== 'transparent'
const commentHl = [...document.querySelectorAll('.tiptap .comment-hl')]
const ins = [...document.querySelectorAll('.tiptap .sugg-ins')]
return {
commentMarked: commentHl.every(n => n.classList.contains('with-sugg')),
commentUnderlined: commentHl.every(n => ['dotted', 'dashed', 'solid'].includes(getComputedStyle(n).borderBottomStyle)),
commentFilled: commentHl.some(n => opaque(getComputedStyle(n).backgroundColor)),
insFilled: ins.length > 0 && ins.every(n => opaque(getComputedStyle(n).backgroundColor)),
insText: ins.map(n => n.textContent.trim()),
}
})
assert.ok(overlap.commentMarked, 'the comment knows it is sharing text with a suggestion: ' + JSON.stringify(overlap))
assert.ok(!overlap.commentFilled, 'so it gives up its fill: ' + JSON.stringify(overlap))
assert.ok(overlap.commentUnderlined, 'but stays marked by its underline: ' + JSON.stringify(overlap))
assert.ok(overlap.insFilled, 'and the inserted words keep a visible fill of their own: ' + JSON.stringify(overlap))
console.log('✓ a suggestion stays visible on text that already carries a comment')
// --- hover the right of the page: a comment anchored to a place, not a span ---
// The Google-Docs affordance. Everything here is about it staying out of the
// way: no gutter, nothing visible until the pointer is in the right third,
// and nothing at all while a selection owns the comment flow.
// Clear the margin first. Cards are stacked, never overlapped: a card whose
// anchor is already occupied gets pushed down the panel, and the alignment this
// feature promises could not be measured through the pile the tests above left.
// (The playground reset further down puts the seeded cards back.)
await waitFor(
async () =>
(await page.evaluate(() => {
document.querySelectorAll('#margin-items .close-btn, #margin-items .reject-btn').forEach(b => b.click())
return document.querySelectorAll('#margin-items .card').length
})) === 0,
'margin cleared for the hover checks',
20000
)
// appended through the editor rather than typed, so it is definitely the LAST
// block on the page — which is what makes the deletion case below deterministic
await page.evaluate(() => {
const e = window.__editor
e.commands.insertContentAt(e.state.doc.content.size, {
type: 'paragraph',
content: [{ type: 'text', text: 'Bees navigate by polarised light, which is the part nobody expects.' }],
})
})
await page.waitForTimeout(400)
const hlBefore = (await page.$$('.tiptap .comment-hl')).length
// measure fresh each time — typing at the end of the document scrolls
const beeGeom = () =>
page.evaluate(() => {
const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith('Bees navigate'))
p.scrollIntoView({ block: 'center' })
const r = p.getBoundingClientRect()
const sheet = document.getElementById('editor').getBoundingClientRect()
const text = document.querySelector('.tiptap').getBoundingClientRect()
return { top: r.top, mid: r.top + r.height / 2, sheet, textRight: text.right }
})
let g = await beeGeom()
const hoverBtn = () =>
page.evaluate(() => {
const b = document.getElementById('hover-comment')
const r = b.getBoundingClientRect()
return { hidden: b.classList.contains('hidden'), left: r.left, right: r.right, mid: r.top + r.height / 2 }
})
// arming the zone must not move the page: the button lives in the page's own
// right margin, absolutely positioned, so nothing reflows
const widthBefore = await page.evaluate(() => document.getElementById('editor').getBoundingClientRect().width)
await page.mouse.move(g.sheet.left + g.sheet.width * 0.2, g.mid)
await page.waitForTimeout(120)
assert.ok((await hoverBtn()).hidden, 'no button while the pointer is in the left two thirds')
await page.mouse.move(g.sheet.left + g.sheet.width * 0.85, g.mid)
await waitFor(async () => !(await hoverBtn()).hidden, 'the right third reveals the comment button')
const btn = await hoverBtn()
const widthAfter = await page.evaluate(() => document.getElementById('editor').getBoundingClientRect().width)
assert.equal(widthAfter, widthBefore, 'revealing it does not add a gutter')
assert.ok(btn.left > g.textRight, `it sits out in the page margin, clear of the text (${btn.left} > ${g.textRight})`)
assert.ok(btn.right < g.sheet.right, 'and still on the paper, not off its edge')
assert.ok(Math.abs(btn.mid - (g.top + 9)) < 14, `level with the paragraph it is beside (btn ${btn.mid}, para top ${g.top})`)
// A live selection owns the comment flow, and TWO separate rules enforce that.
// They have to be checked separately: the button is armed by a real pointer move
// above, and then nothing but the selection changes, so the first assertion can
// only be satisfied by the selectionUpdate drop in bindSelectionMenu. Dispatching
// a mousemove first — which is what this test used to do — re-enters
// updateHoverComment and is caught by its own empty-selection bail instead, so
// the keyboard rule went uncovered and deleting it left the suite green.
// Both run inside the 220ms settle, before the selection composer opens, so
// neither is really testing "a box is open".
const hoverSelRules = await page.evaluate(async () => {
const btnHidden = () => document.getElementById('hover-comment').classList.contains('hidden')
const frame = () => new Promise(done => requestAnimationFrame(() => requestAnimationFrame(done)))
const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith('Bees navigate'))
const armed = !btnHidden() // the pointer move above really did arm it
// rule 1: a keyboard selection, with no pointer event anywhere near it
const pos = window.__editor.view.posAtDOM(p, 0)
window.__editor.commands.setTextSelection({ from: pos, to: pos + 12 })
await frame()
const afterKeyboardSelection = btnHidden()
// rule 2: a pointer event arriving while that selection still stands must not
// re-arm it — the drag that ends in the right third
const sheet = document.getElementById('editor').getBoundingClientRect()
const r = p.getBoundingClientRect()
document.getElementById('editor-col').dispatchEvent(
new MouseEvent('mousemove', { clientX: sheet.left + sheet.width * 0.85, clientY: r.top + r.height / 2, bubbles: true })
)
await frame()
return {
armed,
afterKeyboardSelection,
afterPointerDuringSelection: btnHidden(),
composerOpen: !document.getElementById('composer').classList.contains('hidden'),
}
})
assert.ok(hoverSelRules.armed, 'the pointer had really armed the button first: ' + JSON.stringify(hoverSelRules))
assert.ok(hoverSelRules.afterKeyboardSelection, 'a keyboard selection drops it with no pointer event to notice: ' + JSON.stringify(hoverSelRules))
assert.ok(hoverSelRules.afterPointerDuringSelection, 'and a pointer move cannot re-arm it while the selection stands: ' + JSON.stringify(hoverSelRules))
// and it stays away over the box that selection opens
await page.waitForSelector('#composer:not(.hidden)', { timeout: 10000 })
g = await beeGeom()
await page.mouse.move(g.sheet.left + g.sheet.width * 0.85, g.mid)
await page.waitForTimeout(150)
assert.ok((await hoverBtn()).hidden, 'and hidden while the selection composer is open')
await page.evaluate(() => [...document.querySelectorAll('#composer button')].find(b => /cancel/i.test(b.textContent))?.click())
await focusDocEnd(page)
await page.waitForTimeout(250)
// click it: a composer with a locator instead of a quote, and the paragraph
// marked as where the comment is about to land
g = await beeGeom()
await page.mouse.move(g.sheet.left + g.sheet.width * 0.85, g.mid)
await waitFor(async () => !(await hoverBtn()).hidden, 'button back after the selection is gone')
await page.click('#hover-comment')
await page.waitForSelector('#composer:not(.hidden)', { timeout: 10000 })
const opened = await page.evaluate(() => {
const mark = document.querySelector('.tiptap .comment-anchor.pending')
return {
quoteHidden: document.getElementById('composer-quote').classList.contains('hidden'),
loc: document.getElementById('composer-loc').textContent,
locShown: !document.getElementById('composer-loc').classList.contains('hidden'),
markText: mark?.textContent || null,
pendingSpans: document.querySelectorAll('.tiptap .pending-hl').length,
composerTop: document.getElementById('composer').getBoundingClientRect().top,
}
})
assert.ok(opened.quoteHidden, 'no blockquote: there is nothing quoted: ' + JSON.stringify(opened))
assert.ok(opened.locShown && opened.loc.includes('Bees navigate'), 'the locator says where instead: ' + JSON.stringify(opened))
assert.equal(opened.pendingSpans, 0, 'and no span is underlined as if the words were the subject')
assert.ok(opened.markText?.startsWith('Bees navigate'), 'the paragraph it will land beside is marked: ' + JSON.stringify(opened))
assert.ok(Math.abs(opened.composerTop - g.top) < 40, `the box opens level with that paragraph (${opened.composerTop} vs ${g.top})`)
await page.fill('#composer-text', 'Worth a footnote here?')
await page.click('#composer .primary, #composer button:has-text("Comment")')
await waitFor(async () => (await page.$$('.card.thread .anchor-loc')).length === 1, 'a position-anchored card lands in the margin')
const posted = await page.evaluate(async () => {
const card = document.querySelector('.card.thread .anchor-loc').closest('.card')
const id = location.pathname.split('/d/')[1].split('/')[0]
const snap = await (await fetch(`/api/docs/${id}`)).json()
const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith('Bees navigate'))
return {
loc: card.querySelector('.anchor-loc').textContent,
quotes: card.querySelectorAll('blockquote').length,
orphaned: card.querySelector('.anchor-loc').classList.contains('orphaned'),
cardTop: card.getBoundingClientRect().top,
paraTop: p.getBoundingClientRect().top,
activeMark: document.querySelector('.tiptap .comment-anchor.active')?.textContent || null,
hls: document.querySelectorAll('.tiptap .comment-hl').length,
thread: (snap.threads || []).find(t => t.anchor_kind === 'position') || null,
}
})
assert.ok(posted.loc.includes('Bees navigate'), 'the card shows the paragraph it sits beside: ' + JSON.stringify(posted))
assert.equal(posted.quotes, 0, 'and quotes nothing')
assert.ok(!posted.orphaned, 'the paragraph is still there, so it is not orphaned')
assert.ok(Math.abs(posted.cardTop - posted.paraTop) < 40, `the card sits next to that paragraph (${posted.cardTop} vs ${posted.paraTop})`)
assert.ok(posted.activeMark?.startsWith('Bees navigate'), 'the open card marks its paragraph: ' + JSON.stringify(posted))
assert.equal(posted.hls, hlBefore, 'and adds no comment underline — nothing here is about those words')
assert.ok(posted.thread, 'the API reports the anchor kind: ' + JSON.stringify(posted.thread))
assert.equal(posted.thread.excerpt, null, 'excerpt stays null — no words are quoted')
assert.ok(posted.thread.anchor_context.startsWith('Bees navigate'), 'anchor_context carries the place: ' + JSON.stringify(posted.thread))
console.log('✓ hover comment: right third only, anchored to a place, quotes nothing')
// --- the anchored paragraph is deleted ---
// Kept and never auto-closed: deleting text must not silently end a conversation
// about it. The anchor resolves to the gap the paragraph left, which is not
// inside any block, so the card reads as orphaned — and because that gap is
// still where the paragraph was, it holds its row in the panel rather than
// dropping to the bottom.
const paraTopBefore = posted.paraTop
await page.evaluate(() => {
const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith('Bees navigate'))
const pos = window.__editor.view.posAtDOM(p, 0)
const $p = window.__editor.state.doc.resolve(pos)
window.__editor.view.dispatch(window.__editor.state.tr.delete($p.before($p.depth), $p.after($p.depth)))
})
// the editor, not the body: the card's locator still carries those words, which
// is the whole point of the assertions below
await waitFor(async () => page.evaluate(() => !document.querySelector('.tiptap').textContent.includes('Bees navigate by polarised')), 'the paragraph is gone')
await waitFor(
async () => page.evaluate(() => document.querySelector('.card.thread .anchor-loc')?.classList.contains('orphaned') === true),
'the card notices its paragraph was deleted'
)
const afterDelete = await page.evaluate(() => {
const loc = document.querySelector('.card.thread .anchor-loc')
return {
words: loc.textContent,
struck: getComputedStyle(loc.querySelector('.words')).textDecorationLine,
title: loc.title,
closed: loc.closest('.card').textContent.includes('closed'),
cardTop: loc.closest('.card').getBoundingClientRect().top,
marks: document.querySelectorAll('.tiptap .comment-anchor').length,
}
})
assert.ok(afterDelete.words.includes('Bees navigate'), 'the words are all that is left of the place, so they stay: ' + JSON.stringify(afterDelete))
assert.equal(afterDelete.struck, 'line-through', 'struck through to say the paragraph is gone')
assert.ok(/deleted/.test(afterDelete.title), 'and it says so: ' + JSON.stringify(afterDelete))
assert.ok(!afterDelete.closed, 'the thread is NOT auto-closed')
assert.equal(afterDelete.marks, 0, 'nothing in the document is marked any more: ' + JSON.stringify(afterDelete))
assert.ok(Math.abs(afterDelete.cardTop - paraTopBefore) < 90, `the card stays where the paragraph was (${afterDelete.cardTop} vs ${paraTopBefore})`)
// close only this one, so the margin the later checks see is the one they left
await page.evaluate(() => document.querySelector('.card.thread .anchor-loc')?.closest('.card').querySelector('.close-btn')?.click())
await waitFor(async () => (await page.$$('.card.thread .anchor-loc')).length === 0, 'an orphaned thread still closes normally')
console.log('✓ a deleted paragraph orphans its position comment, keeps it, and keeps its place')
// Same again with content AFTER the anchored paragraph, which is the case that
// could plausibly differ (y-prosemirror's diff is free to reuse a surviving
// sibling element instead of deleting one, and then the anchor would still be
// inside a live block). Measured, it does not differ here — but rather than
// assert a branch of someone else's diff, the assertion below is the invariant
// that has to hold whichever branch runs: the card reads as orphaned exactly
// when the words really are gone from the page.
await page.evaluate(() => {
const e = window.__editor
e.commands.insertContentAt(e.state.doc.content.size, {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Wasps, by contrast, remember faces for weeks on end.' }] },
{ type: 'paragraph', content: [{ type: 'text', text: 'Trailing paragraph so the one above is not the last.' }] },
],
})
})
await page.waitForTimeout(400)
const waspGeom = () =>
page.evaluate(() => {
const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith('Wasps, by contrast'))
p.scrollIntoView({ block: 'center' })
const r = p.getBoundingClientRect()
const sheet = document.getElementById('editor').getBoundingClientRect()
return { top: r.top, mid: r.top + r.height / 2, sheet }
})
const wg = await waspGeom()
await page.mouse.move(wg.sheet.left + wg.sheet.width * 0.85, wg.mid)
await waitFor(async () => !(await hoverBtn()).hidden, 'button arms beside the middle paragraph')
await page.click('#hover-comment')
await page.waitForSelector('#composer:not(.hidden)', { timeout: 10000 })
await page.fill('#composer-text', 'Source for this?')
await page.click('#composer .primary, #composer button:has-text("Comment")')
await waitFor(async () => (await page.$$('.card.thread .anchor-loc')).length === 1, 'second position comment posted')
const waspTopBefore = (await waspGeom()).top
await page.evaluate(() => {
const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith('Wasps, by contrast'))
const pos = window.__editor.view.posAtDOM(p, 0)
const $p = window.__editor.state.doc.resolve(pos)
window.__editor.view.dispatch(window.__editor.state.tr.delete($p.before($p.depth), $p.after($p.depth)))
})
await waitFor(async () => page.evaluate(() => !document.querySelector('.tiptap').textContent.includes('Wasps, by contrast')), 'the middle paragraph is gone')
await page.waitForTimeout(400)
const midDelete = await page.evaluate(() => {
const loc = document.querySelector('.card.thread .anchor-loc')
if (!loc) return null
return {
words: loc.textContent,
orphaned: loc.classList.contains('orphaned'),
docHasWords: document.querySelector('.tiptap').textContent.includes('Wasps, by contrast'),
closed: loc.closest('.card').textContent.includes('closed'),
top: loc.closest('.card').getBoundingClientRect().top,
}
})
assert.ok(midDelete, 'the comment survives its paragraph being deleted')
assert.ok(midDelete.words.includes('Wasps, by contrast'), 'and still names the paragraph it was left beside: ' + JSON.stringify(midDelete))
assert.ok(!midDelete.closed, 'deleting text never closes the conversation about it')
assert.equal(midDelete.orphaned, !midDelete.docHasWords, 'orphaned exactly when the words really are gone: ' + JSON.stringify(midDelete))
assert.ok(Math.abs(midDelete.top - waspTopBefore) < 90, `and it stays where that paragraph was (${midDelete.top} vs ${waspTopBefore})`)
await page.evaluate(() => document.querySelector('.card.thread .anchor-loc')?.closest('.card').querySelector('.close-btn')?.click())
await waitFor(async () => (await page.$$('.card.thread .anchor-loc')).length === 0, 'and it closes normally')
console.log('✓ deleting a paragraph mid-page keeps the comment, its label and its place')
// --- a MERGE is not a deletion ---
// Backspace at the start of the anchored paragraph joins it into the one above:
// the most ordinary way a paragraph stops existing. The block boundary goes, so
// the anchor lands outside any text block exactly as it does for a real
// deletion — but every word survives, one line up. The first cut of this feature
// read "not inside a block" as "deleted" and struck the card through while
// pointing straight at the text it claimed was gone. Same anchor, same resolve,
// opposite verdict, decided by whether the remembered words are still beside the
// boundary.
const mergeHost = 'Hoverfly larvae eat aphids, which is why gardeners like them.'
const mergeAnchored = 'Their wing beat is close to two hundred hertz in level flight.'
await page.evaluate(texts => {
const e = window.__editor
e.commands.insertContentAt(e.state.doc.content.size, {
type: 'doc',
content: texts.map(t => ({ type: 'paragraph', content: [{ type: 'text', text: t }] })),
})
}, [mergeHost, mergeAnchored])
await page.waitForTimeout(400)
const mergeGeom = () =>
page.evaluate(t => {
const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith(t.slice(0, 18)))
p.scrollIntoView({ block: 'center' })
const r = p.getBoundingClientRect()
const sheet = document.getElementById('editor').getBoundingClientRect()
return { top: r.top, mid: r.top + r.height / 2, sheet }
}, mergeAnchored)
const mg = await mergeGeom()
await page.mouse.move(mg.sheet.left + mg.sheet.width * 0.85, mg.mid)
await waitFor(async () => !(await hoverBtn()).hidden, 'button arms beside the paragraph about to be merged')
await page.click('#hover-comment')
await page.waitForSelector('#composer:not(.hidden)', { timeout: 10000 })
await page.fill('#composer-text', 'Is two hundred right?')
await page.click('#composer .primary, #composer button:has-text("Comment")')
await waitFor(async () => (await page.$$('.card.thread .anchor-loc')).length === 1, 'comment posted beside it')
const mergeProbe = () =>
page.evaluate(t => {
const loc = document.querySelector('.card.thread .anchor-loc')
if (!loc) return { collapsed: true, paraCount: document.querySelectorAll('.tiptap p').length }
return {
orphaned: loc.classList.contains('orphaned'),
title: loc.title,
struck: getComputedStyle(loc.querySelector('.words')).textDecorationLine,
words: loc.textContent,
docHasWords: document.querySelector('.tiptap').textContent.includes(t),
paraCount: document.querySelectorAll('.tiptap p').length,
}
}, mergeAnchored)
const beforeMerge = await mergeProbe()
assert.ok(!beforeMerge.orphaned, 'live before the merge: ' + JSON.stringify(beforeMerge))
// Caret to the very start of the anchored paragraph, then Backspace, as a person
// would. The click is what takes DOM focus — commands.focus() sets the selection
// but leaves the keystrokes going to the body in headless Chromium (see
// focusDocEnd) — and setTextSelection then makes the position exact.
const mergeParaBox = await page.evaluate(t => {
const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith(t.slice(0, 18)))
p.scrollIntoView({ block: 'center' })
const r = p.getBoundingClientRect()
return { x: r.left + 3, y: r.top + r.height / 2 }
}, mergeAnchored)
await page.mouse.click(mergeParaBox.x, mergeParaBox.y)
await page.waitForFunction(() => window.__editor?.isFocused, null, { timeout: 5000 })
await page.evaluate(t => {
const e = window.__editor
const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith(t.slice(0, 18)))
e.commands.setTextSelection(e.view.posAtDOM(p, 0))
}, mergeAnchored)
await page.keyboard.press('Backspace')
await waitFor(
async () => page.evaluate(n => document.querySelectorAll('.tiptap p').length === n - 1, beforeMerge.paraCount),
'the two paragraphs really did join into one'
)
// Clicking into the document to place the caret deselected the card, and a
// collapsed card has no locator to read — reopen it.
await page.click('.card.thread .head')
await waitFor(async () => (await page.$$('.card.thread .anchor-loc')).length === 1, 'the card is open again after the merge')
await page.waitForTimeout(400)
const afterMerge = await mergeProbe()
assert.ok(afterMerge.docHasWords, 'the words are still on the page after the merge: ' + JSON.stringify(afterMerge))
assert.ok(!afterMerge.orphaned, 'so the card must NOT claim the paragraph was deleted: ' + JSON.stringify(afterMerge))
assert.equal(afterMerge.struck, 'none', 'and must not strike the words through')
assert.ok(!/deleted/.test(afterMerge.title), 'and the tooltip still offers to jump to it: ' + JSON.stringify(afterMerge))
assert.ok(afterMerge.words.includes('Their wing beat'), 'still naming the place it was left: ' + JSON.stringify(afterMerge))
// and it still resolves to somewhere sane — the locator jumps without throwing
const mergeJump = await page.evaluate(() => {
document.querySelector('.card.thread .anchor-loc').click()
const e = window.__editor
return { pos: e.state.selection.from, size: e.state.doc.content.size }
})
assert.ok(mergeJump.pos > 0 && mergeJump.pos <= mergeJump.size, 'the locator still resolves: ' + JSON.stringify(mergeJump))
await page.evaluate(() => document.querySelector('.card.thread .anchor-loc')?.closest('.card').querySelector('.close-btn')?.click())
await waitFor(async () => (await page.$$('.card.thread .anchor-loc')).length === 0, 'and closes normally')
console.log('✓ merging the anchored paragraph away is not reported as a deletion')
// --- the reason the anchor is a relative position and not an offset ---
// Insert whole blocks ABOVE a position comment. An offset would slide the
// comment down the document; the relative position must keep naming the same
// paragraph and travel with it.
const driftAnchored = 'Bumblebees can fly in colder air than honeybees manage.'
await page.evaluate(t => {
const e = window.__editor
e.commands.insertContentAt(e.state.doc.content.size, {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: t }] },
{ type: 'paragraph', content: [{ type: 'text', text: 'A trailing paragraph, so the one above is not the last.' }] },
],
})
}, driftAnchored)
await page.waitForTimeout(400)
const driftGeom = () =>
page.evaluate(t => {
const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith(t.slice(0, 18)))
p.scrollIntoView({ block: 'center' })
const r = p.getBoundingClientRect()
const sheet = document.getElementById('editor').getBoundingClientRect()
return { top: r.top, mid: r.top + r.height / 2, sheet }
}, driftAnchored)
const dg = await driftGeom()
await page.mouse.move(dg.sheet.left + dg.sheet.width * 0.85, dg.mid)
await waitFor(async () => !(await hoverBtn()).hidden, 'button arms beside the paragraph that must not drift')
await page.click('#hover-comment')
await page.waitForSelector('#composer:not(.hidden)', { timeout: 10000 })
await page.fill('#composer-text', 'Worth a citation.')
await page.click('#composer .primary, #composer button:has-text("Comment")')
await waitFor(async () => (await page.$$('.card.thread .anchor-loc')).length === 1, 'comment posted beside it')
await page.waitForTimeout(300)
// where the anchor resolves BEFORE anything is inserted above it
const driftPosBefore = await page.evaluate(() => {
document.querySelector('.card.thread .anchor-loc').click()
return window.__editor.state.selection.from
})
// three whole paragraphs, inserted above it
await page.evaluate(t => {
const e = window.__editor
const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith(t.slice(0, 18)))
const $p = e.state.doc.resolve(e.view.posAtDOM(p, 0))
e.commands.insertContentAt($p.before($p.depth), {
type: 'doc',
content: [0, 1, 2].map(i => ({ type: 'paragraph', content: [{ type: 'text', text: `An inserted paragraph, number ${i}.` }] })),
})
}, driftAnchored)
await page.waitForTimeout(600)
const afterInsertAbove = await page.evaluate(t => {
const loc = document.querySelector('.card.thread .anchor-loc')
const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.startsWith(t.slice(0, 18)))
// where the anchor actually points now, via the app's own resolve
loc.click()
const $sel = window.__editor.state.doc.resolve(window.__editor.state.selection.from)
return {
orphaned: loc.classList.contains('orphaned'),
words: loc.textContent,
cardTop: loc.closest('.card').getBoundingClientRect().top,
paraTop: p.getBoundingClientRect().top,
landedIn: $sel.parent.isTextblock ? $sel.parent.textContent.slice(0, 30) : null,
pos: window.__editor.state.selection.from,
}
}, driftAnchored)
assert.ok(!afterInsertAbove.orphaned, 'inserting blocks above does not orphan it: ' + JSON.stringify(afterInsertAbove))
assert.ok(afterInsertAbove.words.includes('Bumblebees can fly'), 'and it still names the same paragraph: ' + JSON.stringify(afterInsertAbove))
assert.ok(
afterInsertAbove.landedIn?.startsWith('Bumblebees can fly'),
'the anchor still points INSIDE that paragraph: ' + JSON.stringify(afterInsertAbove)
)
// The pair of assertions is the whole point, and neither alone would do it: the
// resolved position MOVED (an offset would have stayed put and landed in one of
// the inserted paragraphs) and it moved to the same paragraph as before.
assert.ok(
afterInsertAbove.pos > driftPosBefore,
`the anchor moved down by what was inserted above it (${driftPosBefore} -> ${afterInsertAbove.pos})`
)
assert.ok(
Math.abs(afterInsertAbove.cardTop - afterInsertAbove.paraTop) < 90,
`and the card travelled down with it (${afterInsertAbove.cardTop} vs ${afterInsertAbove.paraTop})`
)
await page.evaluate(() => document.querySelector('.card.thread .anchor-loc')?.closest('.card').querySelector('.close-btn')?.click())
await waitFor(async () => (await page.$$('.card.thread .anchor-loc')).length === 0, 'and closes normally')
console.log('✓ blocks inserted above a position comment do not move it off its paragraph')
// --- scrolling drops the button ---
// It is positioned inside #editor-col, so it travels with the text and keeps
// pointing at its own paragraph — but the pointer that armed it has not moved,
// so it ends up offering to comment on something nowhere near the cursor.
const scrollGeom = await page.evaluate(() => {
const p = [...document.querySelectorAll('.tiptap p')].find(n => n.textContent.length > 40)
p.scrollIntoView({ block: 'center' })
const r = p.getBoundingClientRect()
const sheet = document.getElementById('editor').getBoundingClientRect()
return { mid: r.top + r.height / 2, sheet }
})
await page.mouse.move(scrollGeom.sheet.left + scrollGeom.sheet.width * 0.85, scrollGeom.mid)
await waitFor(async () => !(await hoverBtn()).hidden, 'armed before scrolling')
await page.mouse.wheel(0, 350)
await waitFor(async () => (await hoverBtn()).hidden, 'a scroll drops the button rather than leaving it beside a paragraph the pointer has left')
console.log('✓ scrolling without moving the pointer drops the hover button')
// --- a table row anchors to its leftmost cell ---
// Every cell in a row shares one Y band, so document order picks the first.
// Documented at textblockAtHeight; asserted here so it stays a decision.
await page.evaluate(() => {
const e = window.__editor
e.commands.insertContentAt(e.state.doc.content.size, {
type: 'doc',
content: [{
type: 'table',
content: [{
type: 'tableRow',
content: ['Leftmost cell of the row', 'Middle cell', 'Rightmost cell'].map(t => ({
type: 'tableCell',
content: [{ type: 'paragraph', content: [{ type: 'text', text: t }] }],
})),
}],
}],
})
})
await page.waitForTimeout(500)
const cellGeom = await page.evaluate(() => {
const c = [...document.querySelectorAll('.tiptap td, .tiptap th')].find(n => n.textContent.startsWith('Rightmost cell'))
c.scrollIntoView({ block: 'center' })
const r = c.getBoundingClientRect()
const sheet = document.getElementById('editor').getBoundingClientRect()
return { mid: r.top + r.height / 2, sheet }
})
await page.mouse.move(cellGeom.sheet.left + cellGeom.sheet.width * 0.85, cellGeom.mid)
await waitFor(async () => !(await hoverBtn()).hidden, 'button arms beside a table row')
await page.click('#hover-comment')
await page.waitForSelector('#composer:not(.hidden)', { timeout: 10000 })
const rowLoc = await page.evaluate(() => document.getElementById('composer-loc').textContent)
assert.ok(rowLoc.includes('Leftmost cell of the row'), 'level with the last cell, it anchors to the row\'s first: ' + JSON.stringify(rowLoc))
await page.evaluate(() => [...document.querySelectorAll('#composer button')].find(b => /cancel/i.test(b.textContent))?.click())
await page.waitForTimeout(200)
console.log('✓ hovering beside a table row anchors to the row\'s leftmost cell')
// ghost page: visiting a slug that does not exist creates it, titled by slug
const projId = page.url().split('/d/')[1].split('/')[0]
await page.goto(`${BASE}/d/${projId}/road-map`)
await page.waitForSelector('.tiptap')
await waitFor(async () => (await page.textContent('.tiptap')).includes('Road map'), 'ghost page seeded with slug title')
await waitFor(async () => (await page.$$eval('#page-tree a', as => as.map(a => a.textContent))).some(t => t.includes('Road map')), 'ghost page in sidebar')
console.log('✓ ghost pages auto-created from the structure')
await page.goto(`${BASE}/d/${projId}`)
await page.waitForSelector('.tiptap')
await page.waitForTimeout(1000)
// reset restores the seeded state (through the in-app confirm modal)
await page.click('#reset-btn')
await page.waitForSelector('.ui-modal')
await page.click('.ui-modal .btn.primary, .ui-modal .ui-danger')
await page.waitForSelector('.tiptap')
await waitFor(async () => (await page.textContent('.tiptap')).includes('happy testing'), 'reset restores content')
console.log('✓ playground reset')
// --- phone layout (same doc, iPhone-sized touch viewport) ---
// its own browser: chromium runs --single-process here and tears down when
// its last page closes, so contexts are not swapped mid-run
await mobileChecks(page.url())
assert.deepEqual(consoleErrors, [], 'no console errors: ' + JSON.stringify(consoleErrors))
console.log('✓ zero console errors')
await browser.close()
server.kill()
fs.rmSync(DATA, { recursive: true, force: true })
console.log('\nBROWSER TEST PASSED — screenshots in ' + SHOT)
process.exit(0)
}
// The phone shell is a different layout, not just narrower: compact header with
// a ⋯ menu and a toggleable formatting row, the page tree as a drawer, the
// comments as a bottom sheet. Everything below runs at 390x844 with touch.
async function mobileChecks(docUrl) {
const browser = await chromium.launch({
headless: true,
executablePath: process.env.PW_EXECUTABLE || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage', '--single-process'],
})
const ctx = await browser.newContext({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
deviceScaleFactor: 2,
})
const page = await ctx.newPage()
page.on('console', msg => {
if (msg.type() === 'error' && !msg.text().includes('fonts.g')) consoleErrors.push('mobile: ' + msg.text())
})
page.on('pageerror', err => consoleErrors.push('mobile PAGEERROR: ' + err.message))
// fresh browser => fresh cookie jar: sign in, landing on the same document
const docPath = new URL(docUrl).pathname
await page.goto(`${BASE}/auth/dev?u=alice&next=${encodeURIComponent(docPath)}`)
await page.waitForSelector('.tiptap')
await waitFor(async () => (await page.$$('.card')).length >= 1, 'mobile cards render')
await page.waitForTimeout(800)
const shot = name => page.screenshot({ path: path.join(SHOT, `mobile-${name}.png`) })
// nothing may widen the page: a phone browser would zoom out to fit
const width = await page.evaluate(() => ({ inner: window.innerWidth, scroll: document.documentElement.scrollWidth }))
assert.equal(width.scroll, width.inner, `no horizontal overflow (${JSON.stringify(width)})`)
// The hover comment affordance does not exist on touch, on purpose: there is
// no hover to reveal it with, and a button parked in the margin is exactly the
// gutter the desktop design avoids. Selecting text and tapping Comment is the
// whole flow here, and it is exercised further down.
const hoverOnTouch = await page.evaluate(async () => {
const sheet = document.getElementById('editor').getBoundingClientRect()
const p = document.querySelector('.tiptap p').getBoundingClientRect()
document.getElementById('editor-col').dispatchEvent(
new MouseEvent('mousemove', { clientX: sheet.left + sheet.width * 0.85, clientY: p.top + p.height / 2, bubbles: true })
)
await new Promise(done => requestAnimationFrame(() => requestAnimationFrame(done)))
const b = document.getElementById('hover-comment')
return {
noHover: matchMedia('(hover: none)').matches,
classHidden: b.classList.contains('hidden'),
display: getComputedStyle(b).display,
}
})
assert.ok(hoverOnTouch.noHover, 'the phone viewport really is a no-hover one: ' + JSON.stringify(hoverOnTouch))
assert.ok(hoverOnTouch.classHidden, 'a synthetic hover cannot arm it: ' + JSON.stringify(hoverOnTouch))
assert.equal(hoverOnTouch.display, 'none', 'and CSS keeps it gone even if something did')
// the header must stay put at any scroll depth (regression: body{height:100%}
// made the sticky containing block one viewport tall)
for (const y of [900, 2500]) {
await page.evaluate(scrollY => window.scrollTo(0, scrollY), y)
await page.waitForTimeout(250)
const top = await page.evaluate(() => Math.round(document.getElementById('topbar').getBoundingClientRect().top))
assert.equal(top, 0, `header pinned at scrollY=${y} (top=${top})`)
}
await page.evaluate(() => window.scrollTo(0, 0))
await page.waitForTimeout(200)
await shot('01-doc')
console.log('✓ mobile: no horizontal overflow, header stays pinned while scrolling')
// --- the document surface itself, at 390px ---
// The two symptoms this replaced: an 816px sheet in a 390px window put 68% of
// every line off screen, and a 1in page margin spent 24.6% of the viewport
// before the first letter. Both are measured here, not asserted by eye.
const surface = () =>
page.evaluate(() => {
const ed = document.getElementById('editor')
const t = document.querySelector('.tiptap')
const col = document.getElementById('editor-col')
const para = [...t.querySelectorAll('p')].find(e => e.textContent.trim().length > 120)
const lh = para ? parseFloat(getComputedStyle(para).lineHeight) : 1
const lines = para ? Math.max(1, Math.round(para.getBoundingClientRect().height / lh)) : 0
return {
sheet: Math.round(ed.getBoundingClientRect().width),
pad: Math.round(parseFloat(getComputedStyle(ed).paddingLeft)),
measure: Math.round(t.getBoundingClientRect().width),
font: parseFloat(getComputedStyle(t).fontSize),
line: parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--doc-line')),
titleFont: Math.round(parseFloat(getComputedStyle(t.querySelector('h1')).fontSize)),
colScroll: col.scrollWidth - col.clientWidth,
// the whole point: how much of a line a reader can actually see
offScreen: Math.max(0, Math.round(ed.getBoundingClientRect().right - window.innerWidth)),
charsPerLine: lines ? Math.round(para.textContent.trim().length / lines) : 0,
}
})
const phone = await surface()
assert.equal(phone.sheet, 390, `the sheet is the screen, edge to edge (${phone.sheet})`)
assert.equal(phone.offScreen, 0, `no part of the page is off screen (${phone.offScreen}px)`)
assert.equal(phone.colScroll, 0, `and there is nothing to scroll sideways to (${phone.colScroll}px)`)
assert.ok(phone.pad >= 16 && phone.pad <= 34,
`the margin is a phone margin, not a printed inch (${phone.pad}px = ${Math.round((phone.pad / 390) * 100)}% a side)`)
assert.ok(phone.font >= 16, `body text is at least 16px, iOS and Android's own body size (${phone.font})`)
assert.ok(phone.charsPerLine >= 32 && phone.charsPerLine <= 52,
`a phone line, not a page line (${phone.charsPerLine} characters)`)
// the Docs Title is 2.364em against a 624px column; at 354px that was 37.8px
assert.ok(phone.titleFont < 34, `the display title is re-struck for the column (${phone.titleFont}px)`)
console.log(`✓ mobile: the page reflows into the screen — ${phone.measure}px column, ${phone.font}px type, ${phone.charsPerLine} chars/line`)
// No reflow jitter: the drawer and the comment sheet are position:fixed, so
// revealing either one must not resize the column and re-break a line. This
// was the real risk in making the sheet fluid — a sheet sized off a container
// that panels can shrink would re-set the text every time one opened.
const stable = []
for (const [what, sel] of [['pages drawer', '#m-pages'], ['comment sheet', '#m-comments']]) {
await page.click(sel)
await page.waitForTimeout(400)
stable.push([what, await surface()])
await page.click(sel)
await page.waitForTimeout(400)
}
for (const [what, open] of stable) {
assert.equal(open.measure, phone.measure, `the ${what} does not re-break a line (${open.measure} vs ${phone.measure})`)
}
// typing must not move the column either
await page.click('.tiptap', { position: { x: 40, y: 12 } })
await page.keyboard.type('x')
await page.waitForTimeout(300)
const typed = await surface()
assert.equal(typed.measure, phone.measure, `typing does not re-break the column (${typed.measure})`)
await page.keyboard.press('Backspace')
console.log('✓ mobile: opening the drawer, the sheet, or the keyboard reflows nothing')
// zoom is now a type control here, not a fit control: the sheet is already the
// screen, so ± only reaches the words — and its floor lifts, because 40% of a
// fluid column is 6.4px of text and buys nothing
await page.evaluate(() => { document.body.classList.add('fmt-open') })
await page.waitForTimeout(200)
for (let i = 0; i < 6; i++) await page.click('#zoom-out')
await page.waitForTimeout(350)
const zoomedOut = await surface()
assert.equal(zoomedOut.sheet, phone.sheet, `zooming out does not narrow the sheet (${zoomedOut.sheet})`)
assert.ok(zoomedOut.font >= 12.8,
`the zoom floor is 80% here, so the type stays readable (${zoomedOut.font}px at ${await page.textContent('#zoom-label')})`)
// back to 100% by the readout, not by counting clicks: the floor swallows the
// extra ones, so six out and six in would land at 140%
for (let i = 0; i < 12 && (await page.textContent('#zoom-label')) !== '100%'; i++) await page.click('#zoom-in')
await page.waitForTimeout(350)
assert.equal(await page.textContent('#zoom-label'), '100%', 'zoom back to 100% for the rest of the checks')
await page.evaluate(() => { document.body.classList.remove('fmt-open') })
console.log('✓ mobile: zoom sets the type instead of trying to fit a page that already fits')
// the Reading style is measured against a 624px column (10.69 words a line);
// on a phone it gets 354px and only its leading is allowed to give
// the ⋯ sheet is a toggle, and the style button lives inside it — so open it
// by its state rather than by clicking and hoping it was closed
const moreOpen = () => page.evaluate(() => !document.getElementById('more-pop').classList.contains('hidden'))
const openMore = async () => {
if (!(await moreOpen())) await page.click('#m-more')
await page.waitForSelector('#more-pop:not(.hidden)')
await page.waitForTimeout(150)
}
// and put it back down afterwards: the header-chrome checks measure whether
// the desktop buttons are visible, and on a phone they live inside this sheet
const closeMore = async () => {
if (await moreOpen()) await page.click('#m-more')
await waitFor(async () => !(await moreOpen()), 'the ⋯ sheet is back down')
}
await openMore()
await page.click('#docstyle-btn')
await waitFor(async () => await page.evaluate(() => document.documentElement.dataset.docStyle === 'reading'), 'reading style on')
await page.waitForTimeout(700)
const reading = await surface()
assert.equal(reading.measure, phone.measure, 'Reading gets the same phone column as Docs')
assert.equal(reading.colScroll, 0, 'Reading does not overflow it either')
assert.ok(reading.font >= 16, `Reading keeps the 16px base it was measured at (${reading.font})`)
assert.ok(reading.line < 1.8 && reading.line >= 1.5,
`only the leading gives: 1.8 was struck for a long line (${reading.line})`)
assert.ok(reading.titleFont < 40, `the display serif headline is re-struck too (${reading.titleFont}px)`)
await shot('02-reading')
await openMore()
await page.click('#docstyle-btn')
await waitFor(async () => await page.evaluate(() => document.documentElement.dataset.docStyle === 'docs'), 'docs style back')
await closeMore()
console.log(`✓ mobile: Reading holds its 16px base and gives its leading instead (${reading.line})`)
// desktop-only chrome is gone; phone controls are real 40px tap targets
const targets = await page.evaluate(() => {
const out = {}
for (const sel of ['#m-pages', '#m-comments', '#m-fmt', '#m-more']) {
const r = document.querySelector(sel).getBoundingClientRect()
out[sel] = [Math.round(r.width), Math.round(r.height)]
}
out.shareVisible = document.getElementById('share-btn').getBoundingClientRect().height > 0
out.edgeTabs = document.getElementById('sidebar-toggle').getBoundingClientRect().height > 0
return out
})
assert.ok(!targets.shareVisible, 'desktop Share button hidden on phones (moved into ⋯)')
assert.ok(!targets.edgeTabs, 'desktop edge tabs hidden on phones')
for (const sel of ['#m-pages', '#m-comments', '#m-fmt', '#m-more']) {
const [w, h] = targets[sel]
assert.ok(w >= 40 && h >= 40, `${sel} is a 40px+ tap target (got ${w}x${h})`)
}
// ⋯ menu holds Share / Agents / identity and still works from there
await page.click('#m-more')
await page.waitForSelector('#more-pop:not(.hidden)')
const inMenu = await page.evaluate(() => [...document.getElementById('more-pop').children].map(c => c.id))
assert.ok(inMenu.includes('share-btn') && inMenu.includes('agents-btn') && inMenu.includes('whoami'), 'more menu: ' + inMenu)
assert.ok(inMenu.includes('theme-btn'), 'the theme toggle rides the ⋯ sheet on a phone: ' + inMenu)
await shot('02-more-menu')
await page.click('#more-pop #share-btn')
await page.waitForSelector('#share-pop:not(.hidden)')
const pop = await page.evaluate(() => {
const r = document.getElementById('share-pop').getBoundingClientRect()
return { left: Math.round(r.left), right: Math.round(r.right), width: Math.round(r.width) }
})
assert.ok(pop.left >= 0 && pop.right <= 390 && pop.width > 300, 'share sheet fits the screen: ' + JSON.stringify(pop))
await page.click('#m-more') // dismiss
await page.waitForTimeout(200)
console.log('✓ mobile: ⋯ menu carries Share/Agents/identity, popovers fit the screen')
// formatting toolbar: hidden by default, revealed as a scrollable second row
assert.ok(await page.evaluate(() => document.getElementById('header-tools').getBoundingClientRect().height === 0), 'toolbar collapsed by default')
await page.click('#m-fmt')
await page.waitForTimeout(300)
const tools = await page.evaluate(() => {
const t = document.getElementById('header-tools')
const r = t.getBoundingClientRect()
return { h: Math.round(r.height), top: Math.round(r.top), scrollable: t.scrollWidth > t.clientWidth + 10, hdr: document.getElementById('topbar').offsetHeight }
})
assert.ok(tools.h > 25 && tools.top < tools.hdr, 'formatting row shows inside the header: ' + JSON.stringify(tools))
assert.ok(tools.scrollable, 'formatting row scrolls horizontally instead of squeezing')
await shot('03-fmt-row')
// and it actually formats
await selectText(page, 'quick brown fox')
await page.evaluate(() => [...document.querySelectorAll('#header-tools button')].find(b => b.textContent === 'B')?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })))
await waitFor(async () => await page.evaluate(() => window.__editor.isActive('bold')), 'bold applied from the phone toolbar')
await page.evaluate(() => [...document.querySelectorAll('#header-tools button')].find(b => b.textContent === 'B')?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })))
await page.click('#m-fmt')
await page.waitForTimeout(200)
console.log('✓ mobile: formatting row toggles, scrolls and applies marks')
// pages drawer: scrim, dismiss, and closing itself when a page is chosen
await page.click('#m-pages')
await page.waitForTimeout(350)
const drawer = await page.evaluate(() => {
const s = document.getElementById('sidebar').getBoundingClientRect()
return {
onScreen: s.left >= 0 && s.width > 200,
belowHeader: Math.round(s.top) >= document.getElementById('topbar').offsetHeight,
scrim: !document.getElementById('scrim').classList.contains('hidden'),
}
})
assert.ok(drawer.onScreen && drawer.belowHeader && drawer.scrim, 'drawer slides over with a scrim: ' + JSON.stringify(drawer))
await shot('04-drawer')
// tap the exposed part of the scrim, to the right of the 300px drawer
await page.mouse.click(360, 620)
await waitFor(async () => await page.evaluate(() => document.getElementById('sidebar').classList.contains('hidden')), 'scrim dismisses the drawer')
await page.click('#m-pages')
await page.waitForTimeout(300)
await page.click('#page-tree a:not(.current):not(.pending-page)') // a real page, not a proposal
await waitFor(async () => await page.evaluate(() => document.getElementById('sidebar').classList.contains('hidden')), 'drawer closes after picking a page')
await page.waitForSelector('.tiptap')
await page.waitForTimeout(600)
console.log('✓ mobile: pages drawer — scrim dismiss + closes on navigation')
// comments bottom sheet: closed by default, badge shows the count, cards land
// on screen (they used to stack below the whole document, out of sight)
await page.goto(`${BASE}${docPath}`)
await page.waitForSelector('.tiptap')
await waitFor(async () => (await page.$$('.card')).length >= 1, 'cards back on the main page')
await page.waitForTimeout(700)
assert.ok(await page.evaluate(() => document.getElementById('margin-col').classList.contains('hidden')), 'sheet starts closed')
const badge = await page.evaluate(() => {
const b = document.querySelector('#m-comments .count')
return { text: b.textContent, shown: !b.classList.contains('hidden') }
})
assert.ok(badge.shown && Number(badge.text) > 0, 'comment count badge: ' + JSON.stringify(badge))
await page.click('#m-comments')
await page.waitForTimeout(500)
const sheet = await page.evaluate(() => {
const r = document.getElementById('margin-col').getBoundingClientRect()
const cards = [...document.querySelectorAll('.card')]
return {
box: [Math.round(r.left), Math.round(r.top), Math.round(r.width), Math.round(r.bottom)],
vh: window.innerHeight,
visibleCards: cards.filter(c => { const b = c.getBoundingClientRect(); return b.top < window.innerHeight && b.bottom > 0 }).length,
minHeightGap: document.getElementById('margin-items').style.minHeight,
}
})
assert.ok(sheet.box[3] >= sheet.vh - 1 && sheet.box[1] > 0, 'sheet is anchored to the bottom edge: ' + JSON.stringify(sheet))
assert.ok(sheet.box[2] === 390, 'sheet spans the width')
assert.ok(sheet.visibleCards >= 1, 'cards are visible inside the sheet')
assert.ok(!sheet.minHeightGap, 'no leftover desktop min-height gap')
await shot('05-comments-sheet')
console.log('✓ mobile: comments open as a bottom sheet with the cards on screen')
// tapping a comment highlight in the text opens the sheet on that card
await page.click('#margin-close')
await waitFor(async () => await page.evaluate(() => document.getElementById('margin-col').classList.contains('hidden')), 'sheet closes')
// a real tap: the reveal runs through ProseMirror's click handling
await page.evaluate(() => document.querySelector('.tiptap .comment-hl, .tiptap .sugg-ins')?.scrollIntoView({ block: 'center' }))
await page.waitForTimeout(400)
const hl = await page.locator('.tiptap .comment-hl, .tiptap .sugg-ins').first().boundingBox()
await page.mouse.click(hl.x + hl.width / 2, hl.y + hl.height / 2)
await page.waitForTimeout(600)
const revealed = await page.evaluate(() => ({
open: !document.getElementById('margin-col').classList.contains('hidden'),
expanded: !!document.querySelector('.card.expanded'),
}))
assert.ok(revealed.open, 'tapping highlighted text opens the sheet')
console.log('✓ mobile: tapping highlighted text opens the sheet' + (revealed.expanded ? ' on that card' : ''))
// selection -> Comment must open the composer inside the sheet
await page.click('#margin-close').catch(() => {})
await page.waitForTimeout(200)
await selectText(page, 'lazy dog')
await page.waitForSelector('#selection-menu:not(.hidden)')
const menuBtn = await page.evaluate(() => {
const r = document.getElementById('comment-btn').getBoundingClientRect()
return [Math.round(r.width), Math.round(r.height)]
})
assert.ok(menuBtn[1] >= 40, `selection menu buttons are touch-sized (${menuBtn})`)
await page.click('#comment-btn')
await page.waitForSelector('#composer:not(.hidden)')
await page.waitForTimeout(500) // the sheet slides up
const composer = await page.evaluate(() => {
const r = document.getElementById('composer').getBoundingClientRect()
return {
sheetOpen: !document.getElementById('margin-col').classList.contains('hidden'),
rect: [Math.round(r.top), Math.round(r.bottom), Math.round(r.width)],
vh: window.innerHeight,
scrollTop: document.getElementById('margin-items').scrollTop,
onScreen: r.top < window.innerHeight && r.bottom > 0 && r.width > 100,
fontSize: getComputedStyle(document.getElementById('composer-text')).fontSize,
}
})
assert.ok(composer.sheetOpen, 'commenting opens the sheet holding the composer')
assert.ok(composer.onScreen, 'composer is on screen: ' + JSON.stringify(composer))
assert.equal(composer.fontSize, '16px', 'inputs are 16px so iOS does not zoom on focus')
await shot('06-composer')
await page.fill('#composer-text', 'from a phone')
await page.click('#composer-send')
await waitFor(async () => (await page.textContent('#margin-items')).includes('from a phone'), 'phone comment posted')
console.log('✓ mobile: selection → comment posts from the sheet, inputs are zoom-safe')
// long agent replies are clamped with a Show more toggle
await page.evaluate(() => {
const long = 'A very long agent reply that should not be allowed to run on forever. '.repeat(40)
const thread = [...window.__ydoc.getMap('threads').values()].find(t => t.get('messages'))
thread.get('messages').push([{ id: 'long1', author: 'ui-agent', authorType: 'agent', text: long, ts: Date.now() }])
})
await page.waitForTimeout(600)
await page.evaluate(() => {
const card = [...document.querySelectorAll('.card.thread')].find(c => c.textContent.includes('long agent reply')) || document.querySelector('.card.thread')
card?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
})
await waitFor(async () => !!(await page.$('.card.expanded .more-btn')), 'clamped long comment offers Show more')
const clamp = await page.evaluate(() => {
const el = [...document.querySelectorAll('.card.expanded .msg .text')].find(n => n.textContent.includes('run on forever'))
return { clamped: el.classList.contains('clamped'), shown: Math.round(el.getBoundingClientRect().height), full: el.scrollHeight }
})
assert.ok(clamp.clamped && clamp.shown < clamp.full / 2, 'long comment is clamped: ' + JSON.stringify(clamp))
await shot('07-long-comment')
await page.click('.card.expanded .more-btn')
await page.waitForTimeout(300)
const expandedText = await page.evaluate(() => {
const el = [...document.querySelectorAll('.card.expanded .msg .text')].find(n => n.textContent.includes('run on forever'))
return { clamped: el.classList.contains('clamped'), shown: Math.round(el.getBoundingClientRect().height), label: document.querySelector('.card.expanded .more-btn').textContent }
})
assert.ok(!expandedText.clamped && expandedText.shown > clamp.shown, 'Show more reveals the rest: ' + JSON.stringify(expandedText))
assert.equal(expandedText.label, 'Show less', 'toggle flips to Show less')
console.log('✓ mobile: long comments clamp to a max height with Show more / Show less')
await browser.close()
}
async function selectText(page, needle) {
await page.evaluate(text => {
const editor = window.__editor
let found = null
editor.state.doc.descendants((node, pos) => {
if (found || !node.isText) return
const idx = node.text.indexOf(text)
if (idx !== -1) found = { from: pos + idx, to: pos + idx + text.length }
})
if (!found) throw new Error('text not found: ' + text)
editor.chain().focus().setTextSelection(found).run()
}, needle)
await page.waitForTimeout(250)
}
async function waitFor(fn, what, ms = 8000) {
const t0 = Date.now()
while (Date.now() - t0 < ms) {
try {
if (await fn()) return
} catch {}
await new Promise(r => setTimeout(r, 150))
}
throw new Error('timeout: ' + what)
}
main().catch(async err => {
console.error('\nBROWSER TEST FAILED:', err.message)
console.error('console errors:', consoleErrors)
server.kill()
process.exit(1)
})
|