Public: anyone can read this; the crew can change it.

A GraphQL schema (`.graphql`, `.gql`) opens as its types: each type, input and enum with its fields and their types #11287

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/11224-a-graphql-schema-graphql-gql-opens-as-it → main 5 commits fork 3 threads · 3 unresolved

  • Merged. mason/11224-a-graphql-schema-graphql-gql-opens-as-it was merged into main as b164573.

Review

Sign in to review this change.
8 files changed +491 −0
docs/features/file-graphql.md added +11
@@ -0,0 +1,11 @@
1 # A GraphQL schema opens as its types
2
3 `.graphql` and `.gql` files open as blocks instead of raw text. Source is one tap away (`?view=source`).
4
5 - **Blocks:** one per `type`, `input`, `interface` and `enum` (and `extend` forms); each field is a row with its arguments, type and directives. `Query`, `Mutation` and `Subscription` come first.
6 - **Anything else** (`union`, `scalar`, `schema`, `directive`, operations, a body the reader cannot parse) is one plain row with its text, never an error.
7 - **Fallback:** a file with no type, input, interface or enum shows as text with one line saying why.
8 - **Cap:** 500 blocks and 500 fields per block, then `more not shown`. Files over 1 MB open as text.
9 - Every name is escaped, drawn on the server; no script, no dependency.
10
11 Code: `src/web/render/graphql.ts`, `src/web/render/kinds.ts`. Test: `test/render-graphql.test.ts`.
src/tui/screens/workspace.ts +31
@@ -63,6 +63,7 @@ import { CASE_CAP, parseJunit } from "../../web/render/junit.ts";
6363 import { parseOpenApi } from "../../web/render/openapi.ts";
6464 import { parseDockerfile } from "../../web/render/dockerfile.ts";
6565 import { parseWorkflow } from "../../web/render/workflow.ts";
66 import { parseGraphql } from "../../web/render/graphql.ts";
6667 import { parseSql } from "../../web/render/sql.ts";
6768 import { RESULT_CAP, parseSarif } from "../../web/render/sarif.ts";
6869 import { RECORD_CAP, parseLcov, worstFirst, type Ratio } from "../../web/render/lcov.ts";
@@ -536,6 +537,31 @@ function sqlTable(source: string): Table | string {
536537 return { header: ["Table", "Column", "Type", "Null", "Key", "Default"], rows, more: doc.total - doc.statements.length, delimiter: ",", columns: 6 };
537538 }
538539
540 /** A `.graphql` or `.gql` file by name. */
541 function isGraphqlPath(path: string): boolean {
542 return kindByName(path) === "graphql";
543 }
544
545 /**
546 * A GraphQL schema as a table: a row per field or enum value of each block (roots first), one row
547 * per other definition; the reason when it is not drawn. Shares the web's parser.
548 */
549 function graphqlTable(source: string): Table | string {
550 const doc = parseGraphql(source);
551 if (doc === null) return "not drawn as a schema: this file holds no type, input, interface or enum to draw";
552 const dash = (v: string): string => (v === "" ? "-" : v);
553 const rows = doc.blocks.flatMap((b) =>
554 b.kind === "other"
555 ? [["-", b.text, "-", "-", "-"]]
556 : [
557 ...(b.header === "" ? [] : [[`${b.extend ? "extend " : ""}${b.kind} ${b.name}`, "-", "-", "-", b.header]]),
558 ...b.fields.map((f) => [`${b.extend ? "extend " : ""}${b.kind} ${b.name}`, f.name, dash(f.args), dash(f.type), dash(f.note)]),
559 ...(b.fields.length === 0 && b.header === "" ? [[`${b.kind} ${b.name}`, "-", "-", "-", "-"]] : []),
560 ],
561 );
562 return { header: ["Block", "Field", "Arguments", "Type", "Directives"], rows, more: doc.total - doc.blocks.length, delimiter: ",", columns: 5 };
563 }
564
539565 /** A JUnit report, by the web's own rule (file name, or root element). */
540566 function isJunitPath(path: string, source: Uint8Array): boolean {
541567 return /\.xml$/i.test(path) && fileKind(path, source.subarray(0, 4096)) === "junit";
@@ -805,6 +831,11 @@ class Tab {
805831 this.table = typeof schema === "string" ? null : schema;
806832 this.fault = typeof schema === "string" ? schema : "";
807833 if (this.table !== null) this.grid = layoutTable(this.table);
834 } else if (isGraphqlPath(this.path)) {
835 const schema = graphqlTable(new TextDecoder().decode(source));
836 this.table = typeof schema === "string" ? null : schema;
837 this.fault = typeof schema === "string" ? schema : "";
838 if (this.table !== null) this.grid = layoutTable(this.table);
808839 } else if (isJunitPath(this.path, source)) {
809840 const results = junitTable(new TextDecoder().decode(source));
810841 this.table = typeof results === "string" ? null : results;
src/web/render/graphql.ts added +300
@@ -0,0 +1,300 @@
1 /**
2 * A `.graphql` or `.gql` schema as its shape: one block per `type`, `input`,
3 * `interface` and `enum` (and their `extend` forms), each field one row with
4 * its type and arguments. `Query`, `Mutation` and `Subscription` come first.
5 * The reader is its own and small: it drops comments, then walks definitions
6 * at the top level. Anything it does not understand (`union`, `scalar`,
7 * `schema`, `directive`, operations, a body it cannot read) is one plain row
8 * with its text, never an error. A file with no definition at all is not
9 * drawn: the source view shows with the reason.
10 * Every name is escaped by the template; at most `BLOCK_CAP` blocks and
11 * `FIELD_CAP` rows per block are drawn.
12 */
13
14 import { html, type Html } from "../html.ts";
15 import { fileSize } from "../ui.ts";
16 import type { Rendered, RenderInput } from "./renderers.ts";
17
18 export const BLOCK_CAP = 500;
19 export const FIELD_CAP = 500;
20 const TEXT_CAP = 300;
21
22 export interface GqlField {
23 readonly name: string;
24 readonly args: string;
25 readonly type: string;
26 readonly note: string;
27 }
28
29 export type GqlBlock =
30 | { readonly kind: "type" | "input" | "interface" | "enum"; readonly name: string; readonly extend: boolean; readonly header: string; readonly fields: readonly GqlField[]; readonly more: number }
31 | { readonly kind: "other"; readonly text: string };
32
33 export interface GqlDoc {
34 readonly blocks: readonly GqlBlock[];
35 readonly total: number;
36 }
37
38 const SHAPES = new Set(["type", "input", "interface", "enum"]);
39 const DEFINITIONS = new Set(["type", "input", "interface", "enum", "union", "scalar", "schema", "directive", "extend", "fragment", "query", "mutation", "subscription"]);
40 const ROOTS = ["Query", "Mutation", "Subscription"];
41
42 /** Remove `#` comments outside strings; strings are kept whole. */
43 function stripComments(s: string): string {
44 let out = "";
45 for (let i = 0; i < s.length; i++) {
46 const ch = s[i]!;
47 if (s.startsWith('"""', i)) {
48 let end = i + 3;
49 while (end < s.length && !(s.startsWith('"""', end) && s[end - 1] !== "\\")) end++;
50 out += s.slice(i, end + 3);
51 i = end + 2;
52 } else if (ch === '"') {
53 let j = i + 1;
54 while (j < s.length && s[j] !== '"' && s[j] !== "\n") j += s[j] === "\\" ? 2 : 1;
55 out += s.slice(i, j + 1);
56 i = j;
57 } else if (ch === "#") {
58 while (i < s.length && s[i] !== "\n") i++;
59 out += "\n";
60 } else out += ch;
61 }
62 return out;
63 }
64
65 /** Index just past a string starting at `i` (which holds a quote). */
66 function stringEnd(s: string, i: number): number {
67 if (s.startsWith('"""', i)) {
68 let end = i + 3;
69 while (end < s.length && !(s.startsWith('"""', end) && s[end - 1] !== "\\")) end++;
70 return Math.min(s.length, end + 3);
71 }
72 let j = i + 1;
73 while (j < s.length && s[j] !== '"' && s[j] !== "\n") j += s[j] === "\\" ? 2 : 1;
74 return Math.min(s.length, j + 1);
75 }
76
77 /** Index just past the bracket group opening at `i`, or -1 when it never closes. */
78 function groupEnd(s: string, i: number): number {
79 let depth = 0;
80 for (let j = i; j < s.length; j++) {
81 const ch = s[j]!;
82 if (ch === '"') j = stringEnd(s, j) - 1;
83 else if (ch === "(" || ch === "{" || ch === "[") depth++;
84 else if ((ch === ")" || ch === "}" || ch === "]") && --depth === 0) return j + 1;
85 }
86 return -1;
87 }
88
89 const WS = /[\s,]/;
90 const NAME = /^[A-Za-z_][A-Za-z0-9_]*/;
91 const squash = (s: string): string => s.replace(/\s+/g, " ").trim();
92 const clip = (s: string): string => (s.length > TEXT_CAP ? s.slice(0, TEXT_CAP) + "…" : s);
93
94 function skipSpace(s: string, i: number): number {
95 while (i < s.length && WS.test(s[i]!)) i++;
96 return i;
97 }
98
99 /** Directives `@name(args)` from `i`; the text of them and where they end. */
100 function directives(s: string, i: number): { text: string; end: number } {
101 const start = i;
102 for (;;) {
103 let j = skipSpace(s, i);
104 if (s[j] !== "@") return { text: squash(s.slice(start, i)), end: i };
105 j++;
106 const n = NAME.exec(s.slice(j, j + 128));
107 if (n === null) return { text: squash(s.slice(start, i)), end: i };
108 j += n[0].length;
109 if (s[j] === "(") {
110 const e = groupEnd(s, j);
111 if (e === -1) return { text: squash(s.slice(start, i)), end: i };
112 j = e;
113 }
114 i = j;
115 }
116 }
117
118 /** Read the fields of a body (without braces). Null when it is not in a shape this reader knows. */
119 function fields(body: string, isEnum: boolean): GqlField[] | null {
120 const out: GqlField[] = [];
121 let i = skipSpace(body, 0);
122 while (i < body.length) {
123 if (body[i] === '"') {
124 i = skipSpace(body, stringEnd(body, i));
125 continue;
126 }
127 const n = NAME.exec(body.slice(i, i + 256));
128 if (n === null) return null;
129 const name = n[0];
130 i += name.length;
131 let args = "";
132 let type = "";
133 if (!isEnum) {
134 let j = skipSpace(body, i);
135 if (body[j] === "(") {
136 const e = groupEnd(body, j);
137 if (e === -1) return null;
138 args = squash(body.slice(j + 1, e - 1));
139 j = skipSpace(body, e);
140 }
141 if (body[j] !== ":") return null;
142 j = skipSpace(body, j + 1);
143 // the type is a name in brackets and bangs; the next field's name must not be eaten
144 const m = /^(?:\[+\s*)*[A-Za-z_][A-Za-z0-9_]*(?:\s*!)?(?:\s*\]\s*!?)*/.exec(body.slice(j, j + 512));
145 if (m === null) return null;
146 type = m[0].replace(/\s+/g, "");
147 i = j + m[0].length;
148 const k = skipSpace(body, i);
149 if (body[k] === "=") {
150 // a default value, shown with the type
151 let v = skipSpace(body, k + 1);
152 if (body[v] === "[" || body[v] === "{") {
153 const e = groupEnd(body, v);
154 if (e === -1) return null;
155 v = e;
156 } else if (body[v] === '"') v = stringEnd(body, v);
157 else {
158 const w = /^[^\s,@]+/.exec(body.slice(v, v + 256));
159 v += w === null ? 0 : w[0].length;
160 }
161 type += " " + squash(body.slice(k, v));
162 i = v;
163 }
164 }
165 const d = directives(body, i);
166 i = skipSpace(body, d.end);
167 out.push({ name, args, type, note: d.text });
168 if (out.length >= FIELD_CAP + 1) break;
169 }
170 return out;
171 }
172
173 /** Where an unrecognised definition ends: before the next definition keyword that starts a line. */
174 function otherEnd(s: string, i: number): number {
175 let j = i;
176 while (j < s.length) {
177 const ch = s[j]!;
178 if (ch === '"') j = stringEnd(s, j);
179 else if (ch === "{" || ch === "(" || ch === "[") {
180 const e = groupEnd(s, j);
181 j = e === -1 ? s.length : e;
182 } else if (ch === "\n") {
183 const k = skipSpace(s, j + 1);
184 const w = NAME.exec(s.slice(k, k + 32));
185 const prev = s.slice(i, j).trimEnd();
186 if (w !== null && DEFINITIONS.has(w[0]) && !/[=|&]$/.test(prev) && !/^extend$/.test(prev.split(/\s+/).pop() ?? "")) return j;
187 if (w !== null && /^["{]/.test(s[k] ?? "")) return j;
188 j++;
189 } else j++;
190 }
191 return s.length;
192 }
193
194 /** Read a GraphQL schema. Pure; never throws; null when it holds no definition. */
195 export function parseGraphql(source: string): GqlDoc | null {
196 const s = stripComments(source);
197 const blocks: GqlBlock[] = [];
198 let total = 0;
199 let i = skipSpace(s, 0);
200 while (i < s.length) {
201 // a description belongs to the definition after it
202 if (s[i] === '"') {
203 i = skipSpace(s, stringEnd(s, i));
204 continue;
205 }
206 const start = i;
207 const word = NAME.exec(s.slice(i, i + 32))?.[0] ?? "";
208 let block: GqlBlock | null = null;
209 let end = -1;
210 let k = i;
211 let extend = false;
212 let kw = word;
213 if (word === "extend") {
214 extend = true;
215 k = skipSpace(s, i + word.length);
216 kw = NAME.exec(s.slice(k, k + 32))?.[0] ?? "";
217 }
218 if (SHAPES.has(kw)) {
219 k = skipSpace(s, k + kw.length);
220 const name = NAME.exec(s.slice(k, k + 256))?.[0];
221 if (name !== undefined) {
222 k += name.length;
223 // `implements A & B` and directives, until the body
224 let h = k;
225 const continues = (at: number): boolean => s[at] === "\n" && /^\s*(&|implements\b|@)/.test(s.slice(at, at + 40));
226 while (h < s.length && s[h] !== "{" && (s[h] !== "\n" || continues(h))) {
227 if (s[h] === "(") {
228 const e = groupEnd(s, h);
229 if (e === -1) break;
230 h = e;
231 } else if (s[h] === '"') h = stringEnd(s, h);
232 else h++;
233 }
234 const header = squash(s.slice(k, h));
235 if (s[h] === "{") {
236 const e = groupEnd(s, h);
237 if (e !== -1) {
238 const list = fields(s.slice(h + 1, e - 1), kw === "enum");
239 if (list !== null) {
240 block = { kind: kw as "type", name, extend, header, fields: list.slice(0, FIELD_CAP), more: Math.max(0, list.length - FIELD_CAP) };
241 end = e;
242 }
243 }
244 } else {
245 // a type with no body: still one block, with no rows
246 block = { kind: kw as "type", name, extend, header, fields: [], more: 0 };
247 end = h;
248 }
249 }
250 }
251 if (block === null) {
252 end = otherEnd(s, i + Math.max(1, word.length));
253 block = { kind: "other", text: clip(squash(s.slice(start, end))) };
254 }
255 total++;
256 if (blocks.length < BLOCK_CAP) blocks.push(block);
257 i = skipSpace(s, Math.max(end, i + 1));
258 }
259 if (!blocks.some((b) => b.kind !== "other")) return null;
260 const rank = (b: GqlBlock): number => {
261 const r = b.kind === "other" ? -1 : ROOTS.indexOf(b.name);
262 return r === -1 ? ROOTS.length : r;
263 };
264 const sorted = blocks.map((b, n) => ({ b, n })).sort((a, c) => rank(a.b) - rank(c.b) || a.n - c.n).map((x) => x.b);
265 return { blocks: sorted, total };
266 }
267
268 const cell = (v: string): string => (v === "" ? "—" : v);
269
270 function block(b: GqlBlock): Html {
271 if (b.kind === "other") return html`<div class="table-scroll"><table class="table" data-dense aria-label="Definition"><tbody><tr><td><code>${b.text}</code></td></tr></tbody></table></div>`;
272 const label = `${b.extend ? "extend " : ""}${b.kind}`;
273 const head = html`<h3><span class="muted small">${label}</span> <code>${b.name}</code></h3>${b.header === "" ? html`` : html`<p class="muted small"><code>${b.header}</code></p>`}`;
274 if (b.fields.length === 0) return head;
275 const isEnum = b.kind === "enum";

Principle 7/3: each block is an h3, a muted header line and a four-column table. On a 360px phone Field/Arguments/Type/Directives sit in a scroll box. Consider dropping the Directives column when no field has one.

276 // a column nobody uses is not drawn: the table stays narrow on a phone
277 const notes = b.fields.some((f) => f.note !== "");
278 const noteCol = (f: GqlField): Html => (notes ? html`<td>${cell(f.note)}</td>` : html``);
279 const noteHead = notes ? html`<th scope="col">Directives</th>` : html``;
280 const rows = b.fields.map((f) =>
281 isEnum
282 ? html`<tr><th scope="row"><code>${f.name}</code></th>${noteCol(f)}</tr>`
283 : html`<tr><th scope="row"><code>${f.name}</code></th><td>${f.args === "" ? "—" : html`<code>${f.args}</code>`}</td><td><code>${f.type}</code></td>${noteCol(f)}</tr>`,
284 );
outdated

Both doors: no terminal view for this kind. Mirror the sql one in src/tui/screens/workspace.ts and test it.

285 const cols = isEnum
286 ? html`<th scope="col">Value</th>${noteHead}`
287 : html`<th scope="col">Field</th><th scope="col">Arguments</th><th scope="col">Type</th>${noteHead}`;
288 const more = b.more > 0 ? html`<p class="muted small">${b.more.toLocaleString()} more not shown</p>` : html``;
289 return html`${head}<div class="table-scroll"><table class="table" data-dense aria-label="${b.name} fields"><thead><tr>${cols}</tr></thead><tbody>${rows}</tbody></table></div>${more}`;
290 }
291
292 export function graphql(input: RenderInput): Rendered {
293 const doc = parseGraphql(new TextDecoder().decode(input.body));
294 if (doc === null) return { content: html``, lines: 0, fallback: "This file holds no type, input, interface or enum to draw." };
295 const more = doc.total - doc.blocks.length;
296 const foot = more > 0 ? html`<p class="muted small">${more.toLocaleString()} more not shown</p>` : html``;
297 const content = html`<div class="graphql-view">${doc.blocks.map(block)}${foot}</div>`;
298 const n = doc.total;
299 return { content, lines: 0, summary: `${n.toLocaleString()} ${n === 1 ? "definition" : "definitions"} · ${fileSize(input.size)}` };
300 }
src/web/render/kinds.ts +4
@@ -47,6 +47,7 @@ export type FileKind =
4747 | "workflow"
4848 | "compose"
4949 | "sql"
50 | "graphql"
5051 | "text"
5152 | "binary";
5253
@@ -92,6 +93,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = {
9293 workflow: 1 * MB,
9394 compose: 1 * MB,
9495 sql: 1 * MB,
96 graphql: 1 * MB,
9597 text: 1 * MB,
9698 binary: 0,
9799 };
@@ -216,6 +218,8 @@ const TEXTUAL: Readonly<Record<string, FileKind>> = {
216218 sarif: "sarif",
217219 lcov: "lcov",
218220 sql: "sql",
221 graphql: "graphql",
222 gql: "graphql",
219223 };
220224
221225 /** The three example names; a plain `.env` is never one. */
src/web/render/renderers.ts +2
@@ -12,6 +12,7 @@ import { config } from "./config.ts";
1212 import { isFormConfig } from "./config-format.ts";
1313 import { envView } from "./env.ts";
1414 import { sqlView } from "./sql.ts";
15 import { graphql } from "./graphql.ts";
1516 import { eml } from "./eml.ts";
1617 import { csv } from "./csv.ts";
1718 import { geojson } from "./geojson.ts";
@@ -106,6 +107,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = {
106107 junit,
107108 env: envView,
108109 sql: sqlView,
110 graphql,
109111 openapi,
110112 makefile,
111113 dockerfile,
test/render-graphql.test.ts added +118
@@ -0,0 +1,118 @@
1 /** A GraphQL schema as its types: type, enum, input, arguments, roots first, plain rows, escaping, caps, the registry. */
2 import { describe, expect, test } from "bun:test";
3 import { BLOCK_CAP, graphql, parseGraphql } from "../src/web/render/graphql.ts";
4 import { fileKind, kindByName, RENDER_CAP } from "../src/web/render/kinds.ts";
5 import { RENDERERS, rendererFor } from "../src/web/render/renderers.ts";
6
7 const enc = (s: string): Uint8Array => new TextEncoder().encode(s);
8 const draw = (text: string) => graphql({ path: "schema.graphql", size: text.length, body: enc(text), media: "/m", download: "/d" });
9 const out = (r: ReturnType<typeof draw>): string => String(r.content);
10
11 const SCHEMA = `# the schema
12 """A user."""
13 type User implements Node & Entity @key(fields: "id") {
14 id: ID!
15 "their name"
16 name(format: String = "full", upper: Boolean): String
17 friends(first: Int! = 10, after: String): [User!]!
18 old: String @deprecated(reason: "no")
19 }
20 enum Role { ADMIN
21 GUEST @deprecated }
22 input NewUser {
23 name: String!
24 tags: [String!] = ["a", "b"]
25 }
26 union Result = User | Error
27 scalar Date
28 type Query {
29 user(id: ID!): User
30 }
31 type Mutation { addUser(input: NewUser!): User }
32 `;
33
34 describe("graphql", () => {
35 test("registered for .graphql and .gql", () => {
36 expect(fileKind("a/schema.graphql", new Uint8Array())).toBe("graphql");
37 expect(kindByName("x.gql")).toBe("graphql");
38 expect(RENDERERS.graphql).toBe(graphql);
39 expect(RENDER_CAP.graphql).toBe(1_000_000);
40 });
41
42 test("a type, an enum, an input and a field with arguments", () => {
43 const doc = parseGraphql(SCHEMA)!;
44 expect(doc.blocks.map((b) => (b.kind === "other" ? "other" : b.name))).toEqual(["Query", "Mutation", "User", "Role", "NewUser", "other", "other"]);
45 const user = doc.blocks.find((b) => b.kind === "type" && b.name === "User");
46 if (user?.kind !== "type") throw new Error("type");
47 expect(user.header).toContain("implements Node & Entity");
48 expect(user.fields.map((f) => [f.name, f.args, f.type])).toEqual([
49 ["id", "", "ID!"],
50 ["name", 'format: String = "full", upper: Boolean', "String"],
51 ["friends", "first: Int! = 10, after: String", "[User!]!"],
52 ["old", "", "String"],
53 ]);
54 expect(user.fields[3]!.note).toContain("@deprecated");
55 const en = doc.blocks.find((b) => b.kind === "enum");
56 if (en?.kind !== "enum") throw new Error("enum");
57 expect(en.fields.map((f) => f.name)).toEqual(["ADMIN", "GUEST"]);
58 const inp = doc.blocks.find((b) => b.kind === "input");
59 if (inp?.kind !== "input") throw new Error("input");
60 expect(inp.fields.map((f) => [f.name, f.type])).toEqual([["name", "String!"], ["tags", '[String!] = ["a", "b"]']]);
61 const others = doc.blocks.filter((b) => b.kind === "other").map((b) => (b.kind === "other" ? b.text : ""));
62 expect(others).toEqual(["union Result = User | Error", "scalar Date"]);
63 const r = draw(SCHEMA);
64 expect(r.summary).toContain("7 definitions");
65 expect(out(r)).toContain("[User!]!");
66 });
67
68 test("an unreadable body is one plain row, not an error", () => {
69 const doc = parseGraphql("type A { ??? }\ntype B { x: Int }")!;
70 expect(doc.blocks.map((b) => b.kind)).toEqual(["other", "type"]);
71 });
72
73 test("a file with no definition falls back", () => {
74 expect(draw("just words").fallback).toBeDefined();
75 expect(draw("").fallback).toBeDefined();
76 });
77
78 test("every name is escaped", () => {
79 const r = draw(`type Query { f(a: String = "<img src=x onerror=1>"): Int @x(v: "<script>") }\nscalar S_<b>\nunion <i> = A`);
80 const h = out(r);
81 expect(h).not.toContain("<img");
82 expect(h).not.toContain("<script>");
83 expect(h).not.toContain("<b>");
84 expect(h).not.toContain("<i>");
85 expect(h).toContain("&lt;");
86 });
87
88 test("blocks are capped", () => {
89 const text = Array.from({ length: BLOCK_CAP + 5 }, (_, n) => `type T${n} { a: Int }`).join("\n");
90 const doc = parseGraphql(text)!;
91 expect(doc.blocks.length).toBe(BLOCK_CAP);
92 expect(doc.total).toBe(BLOCK_CAP + 5);
93 expect(out(draw(text))).toContain("more not shown");
94 });
95
outdated

Add a test that a file over RENDER_CAP.graphql opens as text, and one for extend type.

96 test("an extend type renders as a block marked extend", () => {
97 const doc = parseGraphql("extend type Query @x { more: Int }\ntype A { a: Int }")!;
98 const q = doc.blocks[0]!;
99 if (q.kind !== "type") throw new Error("type");
100 expect([q.extend, q.name, q.header, q.fields.map((f) => f.name)]).toEqual([true, "Query", "@x", ["more"]]);
101 expect(out(draw("extend type Query { more: Int }"))).toContain("extend type");
102 });
103
104 test("a type-header directive is kept in the header line", () => {
105 expect(out(draw('type A @key(fields: "id") { a: Int }'))).toContain("@key(fields:");
106 });
107
108 test("a file over the cap opens as text", () => {
109 const blob = (size: number) => ({ kind: "text", size, body: enc("type A { a: Int }") }) as never;
110 expect(rendererFor("a.graphql", blob(RENDER_CAP.graphql))?.render).toBe(graphql);
111 expect(rendererFor("a.graphql", blob(RENDER_CAP.graphql + 1))).toBeUndefined();
112 });
113
114 test("the Directives column is drawn only when a field has one", () => {
115 expect(out(draw("type A { a: Int }"))).not.toContain("Directives");
116 expect(out(draw("type A { a: Int @deprecated }"))).toContain("Directives");
117 });
118 });
test/render-kinds.test.ts +2
@@ -82,6 +82,8 @@ const SAMPLES: Record<string, [Uint8Array, FileKind, string]> = {
8282 gcode: [enc("G28\nG1 X1 E1\n"), "gcode", "application/octet-stream"],
8383 gco: [enc("G28\n"), "gcode", "application/octet-stream"],
8484 sql: [enc("CREATE TABLE a (id int);\n"), "sql", "application/octet-stream"],
85 graphql: [enc("type A { b: Int }\n"), "graphql", "application/octet-stream"],
86 gql: [enc("type A { b: Int }\n"), "graphql", "application/octet-stream"],
8587 sarif: [enc('{"runs":[]}'), "sarif", "application/octet-stream"],
8688 lcov: [enc("SF:a.ts\nend_of_record\n"), "lcov", "application/octet-stream"],
8789 };
test/tui-workspace.test.ts +23
@@ -681,6 +681,29 @@ describe("the terminal workspace", () => {
681681 }
682682 });
683683
684 test("a .graphql file opens as its types, roots first, and s shows the source", async () => {
685 const made = await createRepo(forge, alice, { name: "gqlrepo", visibility: "public", description: "api" });
686 if (!made.ok) throw new Error("repo failed");
687 const gql = 'type User @key(fields: "id") { id: ID! }\ntype Query { user(id: ID!): User }\nscalar Date';
688 await commitToBranch(forge, alice, made.value, { message: "seed", files: [{ path: "api/schema.graphql", content: gql }] });
689 const h = boot();
690 try {
691 await h.settle();
692 await open(h, "gqlrepo");
693 await openFile(h, "api/schema.graphql");
694 const text = h.text();
695 expect(text).toContain("type Query");
696 expect(text).toContain("user");
697 expect(text).toContain("ID!");
698 expect(text).toContain("scalar Date");
699 expect(text.indexOf("type Query")).toBeLessThan(text.indexOf("type User"));
700 await h.press("s");
701 expect(h.text()).toContain("type Query {");
702 } finally {
703 h.stop();
704 }
705 });
706
684707 test("a JUnit report opens as its totals and failing cases, sharing the web parser", async () => {
685708 const made = await createRepo(forge, alice, { name: "junitrepo", visibility: "public", description: "tests" });
686709 if (!made.ok) throw new Error("repo failed");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs