diff --git a/docs/FEATURES.md b/docs/FEATURES.md
index 500b3f5dc..ea4b222cd 100644
--- a/docs/FEATURES.md
+++ b/docs/FEATURES.md
@@ -195,11 +195,11 @@ Two components are described below and a claim true of one can be false of the o
See [`providers/neo4j.md`](providers/neo4j.md#56-the-graph-tab).
### 15. Professional Data Export
-* **Format Versatility:** Instantly export query result sets to CSV, JSON, SQL `INSERT` statements, or a generated `CREATE TABLE` DDL.
+* **Format Versatility:** Instantly export query result sets to CSV, JSON, SQL `INSERT` statements, a generated `CREATE TABLE` DDL, a Markdown table or a standalone HTML table.
* **CSV Delimiters:** Choose comma (default), semicolon or tab in the import preview or result export menu. Changing the import delimiter reparses the preview and retains the header setting and column mappings. Export quoting, formula neutralization and UTF-8 encoding apply to every separator.
* **Import into a new table:** the table name and every column name are quoted in the connection's own style in both the `CREATE TABLE` and the `INSERT`, so a header that is a reserved word (`when`, `order`, `user`), has mixed case or holds a space creates that column as written. A name typed with a dot (`sales.imported`) is a table in that schema.
In the standalone app, an import the database refuses keeps the dialog open on the review step with the database's message, and the file, target and column mapping stay as they were; the dialog closes once the import ran, or when the confirmation dialog takes it over. The embedded workspace of the npm package still closes the dialog whatever the outcome, because its query adapter reports none.
-* **Masked Results:** While data masking applies to a result, every export and copy writes the masked text, so the export menu names the masked columns and says so. The grid's column filter and sort read the masked text too. SQL `INSERT` is not offered in that state, because it would store the mask as the column's value; CSV, JSON and DDL stay. When the result carries no declared column types, the DDL export infers a column's type from the masked text, so a masked column can come out as a text type.
+* **Masked Results:** While data masking applies to a result, every export and copy writes the masked text, so the export menu names the masked columns and says so. The grid's column filter and sort read the masked text too. SQL `INSERT` is not offered in that state, because it would store the mask as the column's value; CSV, JSON, DDL, Markdown and HTML stay. When the result carries no declared column types, the DDL export infers a column's type from the masked text, so a masked column can come out as a text type.
* **Developer-Ready:** Clean data output optimized for external analysis, reporting, or database migrations.
* **Binary Values:** A `bytea`, `BLOB`, `RAW` or `varbinary` value is written as the `\x` hex the grid shows (`\xdeadbeef00ff`) in the CSV and the JSON export and by Copy Row as JSON, and as the dialect's own binary literal in a SQL `INSERT`.
* **Replayable SQL:** The SQL `INSERT` form reads each column's declared type and writes arrays, maps, structs, tuples, intervals, wide integers and typed scalars in the literal the connected engine reads back (`src/lib/export/typed-literals.ts`). A row holding a value the engine has no literal for is replaced by a `-- Row N skipped: column "c" ...` comment instead of a statement that would stop the whole file. Masking turns a value into text, so with masking on a masked array, map, row or tuple cell is skipped this way on Trino and Cassandra, and on the other engines is written as the masked text, which the engine refuses on replay. The statement targets the one table the producing `SELECT` read, when it read exactly one, and otherwise the tab's title.
diff --git a/src/components/Studio.tsx b/src/components/Studio.tsx
index 49382ec6e..a27841a34 100644
--- a/src/components/Studio.tsx
+++ b/src/components/Studio.tsx
@@ -34,6 +34,7 @@ import {
FALLBACK_TABLE_NAME,
resultExportFileName,
type ResultExportFormat,
+ type ResultExportSource,
} from "@/lib/export/result-export";
import { downloadText } from "@/lib/export/download";
import { writeToClipboard } from "@/components/copy-button";
@@ -845,8 +846,8 @@ export default function Studio() {
* `currentTab.result` wrote rows nobody was looking at. That is why the menu used to
* be hidden over a hydrated view instead of retargeted.
*/
- const buildResultFile = useCallback(
- (format: ResultExportFormat, hydrated: AgentArtifactHydration | null, csvDelimiter?: CsvDelimiter) => {
+ const buildExportSource = useCallback(
+ (hydrated: AgentArtifactHydration | null): ResultExportSource | null => {
const source = hydrated?.result ?? tabMgr.currentTab.result;
if (!source) return null;
// The columns the engine declared for THIS result. The writers read every row by
@@ -856,8 +857,7 @@ export default function Studio() {
const fields = source.fields;
const sensitiveColumns = detectSensitiveColumnsFromConfig(fields, maskingConfig);
const rows = effectiveMasking ? applyMaskingToRows(source.rows, fields, sensitiveColumns) : source.rows;
-
- return buildResultExport(format, {
+ return {
rows,
fields,
// A run's rows did not come from this tab, so the SQL forms take the neutral
@@ -871,8 +871,7 @@ export default function Studio() {
// The types the engine declared for THIS result, which is what the DDL form
// writes when they are there — the only source for a computed column.
columnTypes: source.columnTypes,
- csvDelimiter,
- });
+ };
},
[
tabMgr.currentTab.result,
@@ -884,11 +883,21 @@ export default function Studio() {
],
);
+ const buildResultFile = useCallback(
+ (format: ResultExportFormat, hydrated: AgentArtifactHydration | null, csvDelimiter?: CsvDelimiter) => {
+ const source = buildExportSource(hydrated);
+ if (source === null) return null;
+ return buildResultExport(format, { ...source, csvDelimiter });
+ },
+ [buildExportSource],
+ );
+
const exportResults = useCallback(
(format: ResultExportFormat, hydrated: AgentArtifactHydration | null = null, csvDelimiter?: CsvDelimiter) => {
+ const fileName = (extension: string) => resultExportFileName(extension, hydrated?.runId);
const file = buildResultFile(format, hydrated, csvDelimiter);
if (file === null) return;
- downloadText(file.content, file.mimeType, resultExportFileName(file.extension, hydrated?.runId));
+ downloadText(file.content, file.mimeType, fileName(file.extension));
},
[buildResultFile],
);
diff --git a/src/components/studio/BottomPanel.tsx b/src/components/studio/BottomPanel.tsx
index 2aa962fd2..0921667ff 100644
--- a/src/components/studio/BottomPanel.tsx
+++ b/src/components/studio/BottomPanel.tsx
@@ -57,15 +57,19 @@ import { offersSqlExport } from "@/lib/db/types";
* drift between two spellings of one list is what put the delimiter options out of
* step with the writers before.
*/
-const RESULT_FORMATS: readonly {
+export type ResultFormatEntry = {
readonly format: ResultExportFormat;
readonly label: string;
readonly csvDelimiter?: CsvDelimiter;
-}[] = [
+};
+
+export const RESULT_FORMATS: readonly ResultFormatEntry[] = [
{ format: "csv", label: "CSV" },
{ format: "csv", label: "CSV (semicolon)", csvDelimiter: ";" },
{ format: "csv", label: "CSV (tab)", csvDelimiter: "\t" },
{ format: "json", label: "JSON" },
+ { format: "markdown", label: "Markdown" },
+ { format: "html", label: "HTML" },
{ format: "sql-insert", label: "SQL INSERT" },
{ format: "sql-ddl", label: "DDL (CREATE TABLE)" },
];
diff --git a/src/lib/export/html.ts b/src/lib/export/html.ts
new file mode 100644
index 000000000..41512ee1c
--- /dev/null
+++ b/src/lib/export/html.ts
@@ -0,0 +1,43 @@
+/**
+ * The one HTML table writer in the studio.
+ *
+ * Same value contract as `renderValue` in `csv.ts`, then the cell is escaped for HTML
+ * text content: `&`, `<`, `>`, `"` and `'` all become entities, in the data cells and in
+ * the header cells alike. Escaping only `& < >` would be enough for a `
`'s text, but
+ * the `"` and `'` are escaped too so a future move of the value into an attribute cannot
+ * smuggle a quote, and a header is a cell like any other.
+ *
+ * The output is a complete standalone document, not a fragment: it can be saved and
+ * opened directly, and the embedded stylesheet keeps table borders, the bold header and
+ * pre-formatted whitespace (so a cell's newlines survive where a Markdown export would
+ * turn them into ` `).
+ */
+
+import { cellOf, renderValue, resolveColumns } from "./csv";
+
+// The `&` is kept in a constant and spliced in rather than written as a literal entity,
+// so no tooling decodes the entity back into the bare character before it reaches the file.
+const ENTITY_MARK = "&";
+const HTML_ESCAPES: Readonly> = {
+ "&": `${ENTITY_MARK}amp;`,
+ "<": `${ENTITY_MARK}lt;`,
+ ">": `${ENTITY_MARK}gt;`,
+ '"': `${ENTITY_MARK}quot;`,
+ "'": `${ENTITY_MARK}#39;`,
+};
+
+/** One HTML cell — a data cell or a header cell — with the characters a parser would read as markup escaped. */
+export function htmlCell(value: unknown): string {
+ return renderValue(value).replace(/[&<>"']/g, (char) => HTML_ESCAPES[char]);
+}
+
+/** `rows` as a complete standalone HTML document holding one table. */
+export function htmlTable(rows: readonly Record[], columns?: readonly string[]): string {
+ const header = resolveColumns(rows, columns);
+ if (header.length === 0) return "";
+ const head = header.map((column) => `${htmlCell(column)} | `).join("");
+ const body = rows
+ .map((row) => `${header.map((column) => `| ${htmlCell(cellOf(row, column))} | `).join("")} `)
+ .join("");
+ return `\n\n\n\nQuery results\n\n\n\n\n\n${head} \n\n\n${body}\n\n \n\n\n`;
+}
diff --git a/src/lib/export/markdown.ts b/src/lib/export/markdown.ts
new file mode 100644
index 000000000..ea8e8eb6f
--- /dev/null
+++ b/src/lib/export/markdown.ts
@@ -0,0 +1,50 @@
+/**
+ * The one Markdown table writer in the studio.
+ *
+ * Same value contract as the CSV writer (`renderValue` in `csv.ts`): absent is empty,
+ * a date is its ISO text, a binary value is the shared `\x…` hex and a structured value
+ * is `jsonText`. On top of that contract a Markdown cell has its own grammar to respect:
+ *
+ * - `\` first, because it is Markdown's escape character. A cell holding a literal `\|`
+ * would otherwise look already-escaped to a renderer, so the backslash is doubled
+ * before the pipe is escaped.
+ * - `|` next, because an unescaped pipe ends the cell and shifts every column after it.
+ * - `\n`/`\r` last, one ` ` per line break (`\r\n` counts as one), because a bare
+ * newline ends the table row mid-cell.
+ *
+ * The header is escaped with the same function as the cells: a header is a cell too,
+ * and a column name with a pipe in it would otherwise split the table on the first line.
+ */
+
+import { cellOf, renderValue, resolveColumns } from "./csv";
+
+/**
+ * One Markdown cell — a data cell or a header cell — with the characters a table
+ * renderer would otherwise read as structure escaped.
+ */
+export function markdownCell(value: unknown): string {
+ return renderValue(value)
+ .replace(/\\/g, "\\\\")
+ .replace(/\|/g, "\\|")
+ .replace(/\r\n|\r|\n/g, " ");
+}
+
+/**
+ * `rows` as a Markdown table: a header row, a separator row, then one row per record.
+ *
+ * The separator row is always plain `---`; column alignment (`:---`, `---:`) is
+ * intentionally not emitted.
+ */
+export function markdownTable(rows: readonly Record[], columns?: readonly string[]): string {
+ const header = resolveColumns(rows, columns);
+ // A table with no columns has no cells to escape and no separator to write; the empty
+ // string is the same answer `toCsv` gives for this shape.
+ if (header.length === 0) return "";
+ const line = (cells: readonly string[]) => `| ${cells.join(" | ")} |`;
+ const separator = `| ${header.map(() => "---").join(" | ")} |`;
+ const lines = [line(header.map(markdownCell)), separator];
+ for (const row of rows) {
+ lines.push(line(header.map((column) => markdownCell(cellOf(row, column)))));
+ }
+ return lines.join("\n");
+}
diff --git a/src/lib/export/result-export.ts b/src/lib/export/result-export.ts
index 2bcd5ffa2..f1dec5fe0 100644
--- a/src/lib/export/result-export.ts
+++ b/src/lib/export/result-export.ts
@@ -3,7 +3,9 @@ import { isBareIdentifier, quoteIdentifier } from "@/lib/sql/identifier";
import { quoteLiteral } from "@/lib/sql/values";
import { asBytes, binaryText } from "./binary";
import { cellOf, resolveColumns, toCsv, type CsvDelimiter } from "./csv";
+import { htmlTable } from "./html";
import { binaryCellsAsHex, jsonText } from "./json";
+import { markdownTable } from "./markdown";
import { resolveUpdateTarget } from "@/lib/sql/update-target";
import { cqlFrozenNested, typedLiteral, UnwritableValue } from "./typed-literals";
import { isNonFiniteWord, nonFiniteWord, type NonFiniteWord } from "@/lib/non-finite";
@@ -18,7 +20,7 @@ import { isNonFiniteWord, nonFiniteWord, type NonFiniteWord } from "@/lib/non-fi
* masking) is decided by the caller and arrives here as `rows`.
*/
-export type ResultExportFormat = "csv" | "json" | "sql-insert" | "sql-ddl";
+export type ResultExportFormat = "csv" | "json" | "sql-insert" | "sql-ddl" | "markdown" | "html";
export interface ResultExportSource {
/** The rows to write, already masked if the caller masks. */
@@ -1088,6 +1090,14 @@ export function buildResultExport(format: ResultExportFormat, source: ResultExpo
const { rows, dialect } = source;
const columns = resolveColumns(rows, source.fields);
+ if (format === "markdown") {
+ return { content: markdownTable(rows, columns), mimeType: "text/markdown;charset=utf-8", extension: "md" };
+ }
+
+ if (format === "html") {
+ return { content: htmlTable(rows, columns), mimeType: "text/html;charset=utf-8", extension: "html" };
+ }
+
if (format === "json") {
return { content: jsonText(rows.map(binaryCellsAsHex), 2), mimeType: "application/json", extension: "json" };
}
diff --git a/src/workspace/StudioWorkspace.tsx b/src/workspace/StudioWorkspace.tsx
index 2f46d35e1..7e58b4811 100644
--- a/src/workspace/StudioWorkspace.tsx
+++ b/src/workspace/StudioWorkspace.tsx
@@ -27,7 +27,7 @@ import { useIsMobile } from "@/hooks/use-mobile";
import { ChunkBoundary, ViewLoading } from "@/components/LazyView";
import { lazyRetry } from "@/lib/lazy";
import { editorLanguageForTabType, resolveTabType } from "@/lib/editor/tab-language";
-import { buildResultExport, type ResultExportFormat } from "@/lib/export/result-export";
+import { buildResultExport, type ResultExportFormat, type ResultExportSource } from "@/lib/export/result-export";
import { writeToClipboard } from "@/components/copy-button";
import { downloadText } from "@/lib/export/download";
@@ -346,26 +346,31 @@ export function StudioWorkspace({
);
// === Export results (shared writers; this shell applies no masking) ===
+ const buildExportSource = useCallback((): ResultExportSource | null => {
+ if (!tabMgr.currentTab.result) return null;
+ return {
+ rows: tabMgr.currentTab.result.rows,
+ fields: tabMgr.currentTab.result.fields,
+ tabName: tabMgr.currentTab.name,
+ // The statement that fetched these rows names their table when it reads exactly one (#1386).
+ query: tabMgr.currentTab.resultQuery,
+ // Was missing from this callback's dependencies, so a SQL export written
+ // after the host switched connections quoted its literals for whichever
+ // engine happened to be active on the first render.
+ dialect: conn.activeConnection?.type,
+ // The host's own declared column types (`use-query-adapter` carries them),
+ // which the DDL form prefers over a type guessed from a value.
+ columnTypes: tabMgr.currentTab.result.columnTypes,
+ };
+ }, [tabMgr.currentTab, conn.activeConnection?.type]);
+
const buildResultFile = useCallback(
(format: ResultExportFormat, csvDelimiter?: CsvDelimiter) => {
- if (!tabMgr.currentTab.result) return null;
- return buildResultExport(format, {
- rows: tabMgr.currentTab.result.rows,
- fields: tabMgr.currentTab.result.fields,
- tabName: tabMgr.currentTab.name,
- // The statement that fetched these rows names their table when it reads exactly one (#1386).
- query: tabMgr.currentTab.resultQuery,
- // Was missing from this callback's dependencies, so a SQL export written
- // after the host switched connections quoted its literals for whichever
- // engine happened to be active on the first render.
- dialect: conn.activeConnection?.type,
- // The host's own declared column types (`use-query-adapter` carries them),
- // which the DDL form prefers over a type guessed from a value.
- columnTypes: tabMgr.currentTab.result.columnTypes,
- csvDelimiter,
- });
+ const source = buildExportSource();
+ if (source === null) return null;
+ return buildResultExport(format, { ...source, csvDelimiter });
},
- [tabMgr.currentTab, conn.activeConnection?.type],
+ [buildExportSource],
);
const exportResults = useCallback(
diff --git a/tests/components/studio/bottom-panel-export-formats.test.tsx b/tests/components/studio/bottom-panel-export-formats.test.tsx
index dcc918073..9387f06fd 100644
--- a/tests/components/studio/bottom-panel-export-formats.test.tsx
+++ b/tests/components/studio/bottom-panel-export-formats.test.tsx
@@ -42,12 +42,16 @@ const ALL_ITEMS = [
"Export as CSV (semicolon)",
"Export as CSV (tab)",
"Export as JSON",
+ "Export as Markdown",
+ "Export as HTML",
"Export as SQL INSERT",
"Export as DDL (CREATE TABLE)",
"Copy as CSV",
"Copy as CSV (semicolon)",
"Copy as CSV (tab)",
"Copy as JSON",
+ "Copy as Markdown",
+ "Copy as HTML",
"Copy as SQL INSERT",
"Copy as DDL (CREATE TABLE)",
];
diff --git a/tests/unit/components/bottom-panel-formats.test.ts b/tests/unit/components/bottom-panel-formats.test.ts
new file mode 100644
index 000000000..10ed64278
--- /dev/null
+++ b/tests/unit/components/bottom-panel-formats.test.ts
@@ -0,0 +1,14 @@
+import { describe, test, expect } from "bun:test";
+import { RESULT_FORMATS } from "@/components/studio/BottomPanel";
+
+describe("RESULT_FORMATS", () => {
+ test("offers Markdown and HTML alongside the existing formats", () => {
+ const formats = RESULT_FORMATS.map((entry) => entry.format);
+ expect(formats).toContain("markdown");
+ expect(formats).toContain("html");
+ expect(formats).toContain("csv");
+ expect(formats).toContain("json");
+ expect(formats).toContain("sql-insert");
+ expect(formats).toContain("sql-ddl");
+ });
+});
diff --git a/tests/unit/db/etcd/tls-handshake.test.ts b/tests/unit/db/etcd/tls-handshake.test.ts
index a2ea3ecba..94ac9e7db 100644
--- a/tests/unit/db/etcd/tls-handshake.test.ts
+++ b/tests/unit/db/etcd/tls-handshake.test.ts
@@ -898,8 +898,12 @@ const CASES: readonly CaseDefinition[] = [
: {
outcome: "failed",
category: "not-connected",
- grpcCode: 14,
- text: /Last error: Failed to connect/,
+ // 14 with "Last error: Failed to connect" where Bun surfaces the TLS alert before the
+ // call's deadline, 4 with "Deadline exceeded ... Waiting for LB pick" where it does not.
+ // Which one arrives is Bun's, not ours (#1511 precedent): the loaded Windows CI runner
+ // reported 4 for both client-certificate refusals while the same file passed on macOS.
+ grpcCode: [14, 4],
+ text: /Last error: Failed to connect|Deadline exceeded/,
sentence: noTlsAnswer("bun", `localhost:${ports.alerting}`, false),
},
),
@@ -925,8 +929,10 @@ const CASES: readonly CaseDefinition[] = [
: {
outcome: "failed",
category: "not-connected",
- grpcCode: 14,
- text: /Last error: Failed to connect/,
+ // Same as the no-client-certificate case above: 14 where Bun surfaces the alert in
+ // time, 4 where the call's deadline lands first on the loaded Windows runner.
+ grpcCode: [14, 4],
+ text: /Last error: Failed to connect|Deadline exceeded/,
sentence: noTlsAnswer("bun", `localhost:${ports.alerting}`, true),
},
),
diff --git a/tests/unit/lib/export/html.test.ts b/tests/unit/lib/export/html.test.ts
new file mode 100644
index 000000000..28b92e157
--- /dev/null
+++ b/tests/unit/lib/export/html.test.ts
@@ -0,0 +1,105 @@
+import { describe, test, expect } from "bun:test";
+import { htmlCell, htmlTable } from "@/lib/export/html";
+
+// The `&` is kept in a constant and spliced in, so the entity text is never decoded before it reaches the file.
+const ENTITY_MARK = "&";
+const amp = `${ENTITY_MARK}amp;`;
+const lt = `${ENTITY_MARK}lt;`;
+const gt = `${ENTITY_MARK}gt;`;
+const quot = `${ENTITY_MARK}quot;`;
+const apos = `${ENTITY_MARK}#39;`;
+
+describe("htmlCell", () => {
+ test("escapes every character a parser would read as markup", () => {
+ expect(htmlCell(`&<>"'`)).toBe(amp + lt + gt + quot + apos);
+ });
+
+ test("escapes a script payload so no raw tag survives", () => {
+ expect(htmlCell("")).toBe(lt + "script" + gt + "alert(1)" + lt + "/script" + gt);
+ expect(htmlCell(" ")).toBe(lt + "img src=x onerror=alert(1)" + gt);
+ });
+
+ test("double-escapes a value that already holds an entity, because the value is data", () => {
+ expect(htmlCell(amp)).toBe(amp + "amp;");
+ });
+
+ test("writes an absent value as an empty cell", () => {
+ expect(htmlCell(null)).toBe("");
+ expect(htmlCell(undefined)).toBe("");
+ });
+
+ test("writes a date and a binary value through the shared contract", () => {
+ expect(htmlCell(new Date("2026-08-17T06:31:49.000Z"))).toBe("2026-08-17T06:31:49.000Z");
+ expect(htmlCell({ type: "Buffer", data: [0xde, 0xad] })).toBe("\\xdead");
+ expect(htmlCell(new Uint8Array([0xde, 0xad]))).toBe("\\xdead");
+ });
+
+ test("escapes the quotes inside a Buffer-shaped document, as part of the HTML text", () => {
+ const cell = htmlCell({ type: "Buffer", data: [1, "two"] });
+ expect(cell).toBe(
+ "{" +
+ quot +
+ "type" +
+ quot +
+ ":" +
+ quot +
+ "Buffer" +
+ quot +
+ "," +
+ quot +
+ "data" +
+ quot +
+ ":[1," +
+ quot +
+ "two" +
+ quot +
+ "]}",
+ );
+ expect(cell).not.toContain('"');
+ });
+
+ test("serializes a structured value, escaping its quotes, and writes a bigint", () => {
+ expect(htmlCell({ a: 1 })).toBe("{" + quot + "a" + quot + ":1}");
+ expect(htmlCell([1, 2])).toBe("[1,2]");
+ expect(htmlCell(BigInt(10))).toBe("10");
+ });
+});
+
+describe("htmlTable", () => {
+ test("renders a complete standalone document with a header row", () => {
+ const html = htmlTable([{ id: 1, name: "Ada" }], ["id", "name"]);
+ expect(html.startsWith("")).toBe(true);
+ expect(html).toContain("| id | name | ");
+ expect(html).toContain("| 1 | Ada | ");
+ });
+
+ test("escapes a column name in the header", () => {
+ const html = htmlTable([{ "aa" + lt + "b");
+ });
+
+ test("escapes a quote in a header, so it cannot close an attribute if one is ever added", () => {
+ const html = htmlTable([{ 'a"b': 1 }], ['a"b']);
+ expect(html).toContain("a" + quot + "b | ");
+ });
+
+ test("writes a header with no rows under it when the columns are known but empty", () => {
+ const html = htmlTable([], ["a"]);
+ expect(html).toContain("a | ");
+ expect(html).toContain("\n\n");
+ });
+
+ test("writes nothing for no rows and no declared columns", () => {
+ expect(htmlTable([])).toBe("");
+ });
+
+ test("writes an empty cell for a prototype-named column the row does not carry", () => {
+ const html = htmlTable([{ id: 1 }], ["id", "constructor"]);
+ expect(html).toContain("| 1 | | ");
+ });
+
+ test("still writes a row's own value for a prototype-named column", () => {
+ const html = htmlTable([{ toString: "mine" }], ["toString"]);
+ expect(html).toContain("mine | ");
+ });
+});
diff --git a/tests/unit/lib/export/markdown.test.ts b/tests/unit/lib/export/markdown.test.ts
new file mode 100644
index 000000000..0370178c0
--- /dev/null
+++ b/tests/unit/lib/export/markdown.test.ts
@@ -0,0 +1,89 @@
+import { describe, test, expect } from "bun:test";
+import { markdownCell, markdownTable } from "@/lib/export/markdown";
+
+describe("markdownCell", () => {
+ test("escapes a pipe so it cannot end the cell", () => {
+ expect(markdownCell("a|b")).toBe("a\\|b");
+ });
+
+ test("doubles a backslash before escaping a pipe, so an already-escaped-looking value survives", () => {
+ expect(markdownCell("a\\|b")).toBe("a\\\\\\|b");
+ expect(markdownCell("\\")).toBe("\\\\");
+ });
+
+ test("turns a newline and a carriage return into a break, not a row split", () => {
+ expect(markdownCell("line1\nline2")).toBe("line1 line2");
+ expect(markdownCell("x\ry")).toBe("x y");
+ expect(markdownCell("a\r\nb")).toBe("a b");
+ expect(markdownCell("a\n\nb")).toBe("a
b");
+ });
+
+ test("writes an absent value as an empty cell, not as the text of the absence", () => {
+ expect(markdownCell(null)).toBe("");
+ expect(markdownCell(undefined)).toBe("");
+ });
+
+ test("writes a date in a form another tool can parse back", () => {
+ expect(markdownCell(new Date("2026-08-17T06:31:49.000Z"))).toBe("2026-08-17T06:31:49.000Z");
+ });
+
+ test("writes a binary value as the shared hex, escaping its backslash prefix", () => {
+ expect(markdownCell({ type: "Buffer", data: [0xde, 0xad] })).toBe("\\\\xdead");
+ expect(markdownCell(new Uint8Array([0xde, 0xad]))).toBe("\\\\xdead");
+ expect(markdownCell({ type: "Buffer", data: [] })).toBe("\\\\x");
+ });
+
+ test("keeps a document that only looks Buffer-shaped as JSON rather than misreading it as bytes", () => {
+ expect(markdownCell({ type: "Buffer", data: [1, "two"] })).toBe('{"type":"Buffer","data":[1,"two"]}');
+ });
+
+ test("serializes a structured value and a bigint", () => {
+ expect(markdownCell({ a: 1 })).toBe('{"a":1}');
+ expect(markdownCell([1, 2])).toBe("[1,2]");
+ expect(markdownCell(BigInt(10))).toBe("10");
+ });
+
+ test("writes booleans, numbers and non-ASCII text as themselves", () => {
+ expect(markdownCell(true)).toBe("true");
+ expect(markdownCell(-12.5)).toBe("-12.5");
+ expect(markdownCell("雪🚀")).toBe("雪🚀");
+ });
+});
+
+describe("markdownTable", () => {
+ test("writes the header from the declared columns and reads each row by name", () => {
+ const md = markdownTable([{ b: 2, a: 1 }], ["a", "b"]);
+ expect(md).toBe("| a | b |\n| --- | --- |\n| 1 | 2 |");
+ });
+
+ test("escapes a pipe in the header, not only in a cell", () => {
+ expect(markdownTable([{ "a|b": 1 }], ["a|b"])).toBe("| a\\|b |\n| --- |\n| 1 |");
+ });
+
+ test("leaves a column a row does not carry empty rather than shifting the rest", () => {
+ const md = markdownTable([{ a: 1, c: 3 }], ["a", "b", "c"]);
+ expect(md).toBe("| a | b | c |\n| --- | --- | --- |\n| 1 | | 3 |");
+ });
+
+ test("covers a key that only a later row carries when no columns are declared", () => {
+ const md = markdownTable([{ a: 1 }, { a: 2, b: 3 }]);
+ expect(md).toBe("| a | b |\n| --- | --- |\n| 1 | |\n| 2 | 3 |");
+ });
+
+ test("writes a header with no rows under it when the columns are known but empty", () => {
+ expect(markdownTable([], ["a", "b"])).toBe("| a | b |\n| --- | --- |");
+ });
+
+ test("writes nothing for no rows and no declared columns", () => {
+ expect(markdownTable([])).toBe("");
+ });
+
+ test("writes an empty cell for a prototype-named column the row does not carry", () => {
+ const md = markdownTable([{ constructor: "declared", id: 1 }, { id: 2 }], ["constructor", "id"]);
+ expect(md).toBe("| constructor | id |\n| --- | --- |\n| declared | 1 |\n| | 2 |");
+ });
+
+ test("still writes a row's own value for a prototype-named column", () => {
+ expect(markdownTable([{ toString: "mine" }], ["toString"])).toBe("| toString |\n| --- |\n| mine |");
+ });
+});
diff --git a/tests/unit/lib/export/result-export.test.ts b/tests/unit/lib/export/result-export.test.ts
index eb045bc9f..f1af14fe8 100644
--- a/tests/unit/lib/export/result-export.test.ts
+++ b/tests/unit/lib/export/result-export.test.ts
@@ -1746,3 +1746,32 @@ describe("resultExportFileName", () => {
expect(name).toBe(`agent_run_${"r".repeat(64)}_export.csv`);
});
});
+
+describe("buildResultExport — markdown and html", () => {
+ test("writes a Markdown table with the shared mime type and extension", () => {
+ const file = buildResultExport("markdown", source());
+ expect(file.content).toBe("| id | name |\n| --- | --- |\n| 1 | Ada |");
+ expect(file.mimeType).toBe("text/markdown;charset=utf-8");
+ expect(file.extension).toBe("md");
+ });
+
+ test("writes an HTML table with the shared mime type and extension", () => {
+ const file = buildResultExport("html", source());
+ expect(file.content).toContain("| id | name | ");
+ expect(file.content).toContain("| 1 | Ada | ");
+ expect(file.mimeType).toBe("text/html;charset=utf-8");
+ expect(file.extension).toBe("html");
+ });
+
+ test("ignores the dialect for the two text formats, which name no engine", () => {
+ const markdown = buildResultExport("markdown", source({ dialect: "oracle" }));
+ const html = buildResultExport("html", source({ dialect: "mssql" }));
+ expect(markdown.content).toBe("| id | name |\n| --- | --- |\n| 1 | Ada |");
+ expect(html.content).toContain("| 1 | Ada | ");
+ });
+
+ test("returns text content, never a binary blob", () => {
+ expect(typeof buildResultExport("markdown", source()).content).toBe("string");
+ expect(typeof buildResultExport("html", source()).content).toBe("string");
+ });
+});
|