Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ export default defineConfig({
"**/channel-head-restart.spec.ts",
"**/live-broadcast-reply-timeline.spec.ts",
"**/markdown-parse-cache.spec.ts",
"**/markdown-tables.spec.ts",
"**/overscroll-boundary.spec.ts",
"**/terminal-wheel.spec.ts",
"**/cold-switch-longtask.perf.ts",
Expand Down
4 changes: 2 additions & 2 deletions desktop/src/shared/ui/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1563,12 +1563,12 @@ export function createMarkdownComponents(
),
table: ({ children }) => <MarkdownTable>{children}</MarkdownTable>,
td: ({ children }) => (
<td className="border-t border-border/70 px-3 py-2 align-top">
<td className="min-w-24 border-t border-border/70 px-3 py-2 align-top">
{children}
</td>
),
th: ({ children }) => (
<th className="bg-muted/60 px-3 py-2 font-semibold text-foreground">
<th className="min-w-24 bg-muted/60 px-3 py-2 align-top font-semibold text-foreground">
{children}
</th>
),
Expand Down
4 changes: 3 additions & 1 deletion desktop/src/shared/ui/markdown/MarkdownTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ export function MarkdownTable({ children }: { children?: React.ReactNode }) {
className="overflow-x-auto rounded-2xl border border-border/70"
data-table-block=""
>
<table className="w-max min-w-full border-collapse text-left text-sm">
{/* Inherit message wrap-anywhere for long tokens. The cells' minimum
widths keep short labels readable; many-column tables scroll locally. */}
<table className="w-full border-collapse text-left text-sm">
{children}
</table>
</div>
Expand Down
154 changes: 154 additions & 0 deletions desktop/tests/e2e/markdown-tables.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";

const token = "0123456789abcdef".repeat(8);
const url = `https://example.com/reports/${token}`;
const prose =
"Review the rollout notes and confirm that each owner can read the complete status without scrolling sideways. Keep the next action beside its owner, even when this description spans several lines.";
const content = `Table readability fixture

| Owner | Status and next action with enough detail to span multiple lines in a narrow pane |
| --- | --- |
| Alice | ${prose} |
| Bob | [Read the complete rollout notes and review checklist](${url}) and then confirm the next step. |
| Token | ${token} |
| Link | <${url}> |
| Code | \`git diff --check\` and **review** the result. |

Surrounding paragraph stays in the message layout.`;

for (const surface of ["channel", "thread"] as const) {
test(`markdown tables wrap and stay contained in the ${surface}`, async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 1280, height: 1440 });
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.waitForFunction(() =>
window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: "general",
}),
);
const root = await page.evaluate((body) => {
const root = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: body,
});
if (!root) throw new Error("Mock message was not emitted");
return root.id;
}, content);

const timelineMessage = page
.getByTestId("message-timeline")
.locator(`[data-message-id="${root}"]`);
await expect(timelineMessage).toBeVisible();
if (surface === "thread") {
await timelineMessage.hover();
await page.getByTestId(`reply-message-${root}`).click();
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
}
const scope = page.getByTestId(
surface === "thread" ? "message-thread-panel" : "message-timeline",
);
const markdown = scope
.locator(".message-markdown")
.filter({ hasText: "Table readability fixture" });
const block = markdown.locator("[data-table-block]");
await expect(block).toBeVisible();
await page.mouse.move(0, 0);
await waitForAnimations(page);
await markdown.screenshot({ path: testInfo.outputPath(`${surface}.png`) });
const metrics = await block.evaluate((element) => {
const table = element.querySelector("table");
if (!table) throw new Error("Semantic table missing");
const label = document.createRange();
label.selectNodeContents(table.rows[0].cells[0]);
return {
labelLines: label.getClientRects().length,
width: element.clientWidth,
scrollWidth: element.scrollWidth,
tableWidth: table.getBoundingClientRect().width,
alignments: Array.from(
table.querySelectorAll("th, td"),
(cell) => getComputedStyle(cell).verticalAlign,
),
rowHeight: table.rows[1].getBoundingClientRect().height,
lineHeight: Number.parseFloat(getComputedStyle(table).lineHeight),
pageWidth: document.documentElement.clientWidth,
pageScrollWidth: document.documentElement.scrollWidth,
};
});
await testInfo.attach("layout", {
body: JSON.stringify(metrics, null, 2),
contentType: "application/json",
});
expect(metrics.width).toBeGreaterThan(250);
if (surface === "thread") expect(metrics.width).toBeLessThan(500);
expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.width + 1);
expect(metrics.tableWidth).toBeLessThanOrEqual(metrics.width + 1);
expect(metrics.alignments.every((value) => value === "top")).toBe(true);
expect(metrics.labelLines).toBe(1);
expect(metrics.rowHeight).toBeGreaterThan(metrics.lineHeight * 2);
expect(metrics.pageScrollWidth).toBe(metrics.pageWidth);
await expect(block.locator("tbody tr")).toHaveCount(5);
await expect(block.getByRole("link")).toHaveCount(2);
for (const link of await block.getByRole("link").all()) {
await expect(link).toHaveAttribute("href", url);
}
await expect(block.locator("code")).toHaveText("git diff --check");
await expect(
block.locator("td").filter({ hasText: token }).first(),
).toHaveText(token);
await expect(markdown.locator("p").last()).toHaveText(
"Surrounding paragraph stays in the message layout.",
);
});
}

test("unavoidably wide tables scroll locally without losing cells", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await page.waitForFunction(() =>
window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
channelName: "general",
}),
);
const columns = Array.from({ length: 40 }, (_, i) => `C${i}`);
const wide = [columns, columns.map(() => "---"), columns]
.map((row) => `| ${row.join(" | ")} |`)
.join("\n");
await page.evaluate((body) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: `Wide table fixture\n\n${body}`,
});
}, wide);
const block = page
.getByTestId("message-timeline")
.locator(".message-markdown")
.filter({ hasText: "Wide table fixture" })
.locator("[data-table-block]");
await expect(block.locator("td")).toHaveCount(40);
const metrics = await block.evaluate((element) => {
element.scrollLeft = element.scrollWidth;
return {
width: element.clientWidth,
scrollWidth: element.scrollWidth,
scrollLeft: element.scrollLeft,
overflow: getComputedStyle(element).overflowX,
pageWidth: document.documentElement.clientWidth,
pageScrollWidth: document.documentElement.scrollWidth,
};
});
expect(metrics.scrollWidth).toBeGreaterThan(metrics.width);
expect(metrics.scrollLeft).toBeGreaterThan(0);
expect(metrics.overflow).toBe("auto");
expect(metrics.pageScrollWidth).toBe(metrics.pageWidth);
await expect(block.locator("td").last()).toHaveText("C39");
});
6 changes: 4 additions & 2 deletions desktop/tests/e2e/messaging.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,7 @@ test("long autolink wraps without widening the timeline", async ({ page }) => {
.toBeLessThanOrEqual(0);
});

test("markdown tables overflow wide content and fill the message when narrow", async ({
test("markdown tables wrap long prose and fill the message when narrow", async ({
page,
}) => {
await page.setViewportSize({ width: 900, height: 600 });
Expand Down Expand Up @@ -497,13 +497,15 @@ test("markdown tables overflow wide content and fill the message when narrow", a
await expect(wideTable).toBeVisible();
await expect(narrowTable).toBeVisible();

// Long prose should wrap, not force horizontal scrolling. Unavoidable
// many-column overflow is covered separately in markdown-tables.spec.ts.
await expect
.poll(() =>
wideTable.evaluate(
(element) => element.scrollWidth - element.clientWidth,
),
)
.toBeGreaterThan(1);
.toBeLessThanOrEqual(1);
await expect
.poll(() =>
narrowTable.evaluate((element) => {
Expand Down
Loading