@saltcorn/copilot 0.7.5 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/builder-schema.js CHANGED
@@ -41,6 +41,23 @@ const TOOLBOX_BY_MODE = {
41
41
  ],
42
42
  list: [
43
43
  "blank",
44
+ "list_columns",
45
+ "list_column",
46
+ "field",
47
+ "join_field",
48
+ "view_link",
49
+ "action",
50
+ "link",
51
+ "aggregation",
52
+ "view",
53
+ "container",
54
+ "dropdown_menu",
55
+ "line_break",
56
+ ],
57
+ listcolumns: [
58
+ "blank",
59
+ "list_columns",
60
+ "list_column",
44
61
  "field",
45
62
  "join_field",
46
63
  "view_link",
@@ -131,7 +148,6 @@ const buildSegmentDef = ({
131
148
  });
132
149
 
133
150
  const buildBuilderSchema = ({ mode, ctx }) => {
134
- console.log({ ctx });
135
151
  const normalizedMode = (mode || ctx?.mode || "show").toLowerCase();
136
152
  const fields = ctx?.fields || [];
137
153
  const actions = ctx?.actions || [];
@@ -141,9 +157,13 @@ const buildBuilderSchema = ({ mode, ctx }) => {
141
157
  Array.isArray(rawIcons) ? rawIcons : Object.keys(rawIcons)
142
158
  ).slice(0, 15);
143
159
 
144
- const fieldNames = fields.map((f) => f.name).filter(Boolean);
160
+ const isEditMode = normalizedMode === "edit" || normalizedMode === "filter";
161
+ const visibleFields = isEditMode
162
+ ? fields.filter((f) => !f.calculated)
163
+ : fields;
164
+ const fieldNames = visibleFields.map((f) => f.name).filter(Boolean);
145
165
  const fieldviewOptions = Array.from(
146
- new Set(fields.flatMap((f) => f.fieldviews || []).filter(Boolean)),
166
+ new Set(visibleFields.flatMap((f) => f.fieldviews || []).filter(Boolean))
147
167
  );
148
168
 
149
169
  const defs = {
@@ -240,11 +260,11 @@ const buildBuilderSchema = ({ mode, ctx }) => {
240
260
  },
241
261
  imageLocation: makeEnum(
242
262
  IMAGE_LOCATIONS,
243
- "Image location for card/container backgrounds.",
263
+ "Image location for card/container backgrounds."
244
264
  ),
245
265
  imageSize: makeEnum(
246
266
  IMAGE_SIZES,
247
- "Image sizing for Card or Body locations.",
267
+ "Image sizing for Card or Body locations."
248
268
  ),
249
269
  gradStartColor: {
250
270
  type: "string",
@@ -633,7 +653,7 @@ const buildBuilderSchema = ({ mode, ctx }) => {
633
653
  { $ref: "#/$defs/join_field" },
634
654
  { $ref: "#/$defs/list_column" },
635
655
  { $ref: "#/$defs/list_columns" },
636
- { $ref: "#/$defs/page" },
656
+ { $ref: "#/$defs/page" }
637
657
  );
638
658
 
639
659
  Object.assign(defs, {
package/chat-copilot.js CHANGED
@@ -382,6 +382,7 @@ const actionClasses = [
382
382
  require("./actions/generate-workflow"),
383
383
  require("./actions/generate-tables"),
384
384
  require("./actions/generate-js-action"),
385
+ require("./actions/generate-trigger"),
385
386
  require("./actions/generate-page"),
386
387
  require("./actions/generate-view"),
387
388
  ];
package/common.js CHANGED
@@ -14,6 +14,24 @@ const MarkdownIt = require("markdown-it"),
14
14
  md = new MarkdownIt();
15
15
  const HTMLParser = require("node-html-parser");
16
16
 
17
+ const getLlmConfigurationSafe = async () => {
18
+ const fn = getState().functions?.llm_get_configuration;
19
+ if (!fn?.run) return null;
20
+ try {
21
+ return await fn.run();
22
+ } catch (err) {
23
+ return null;
24
+ }
25
+ };
26
+
27
+ const canUseResponseFormat = (llmConfig) => {
28
+ const backend = llmConfig?.backend;
29
+ if (!backend) return false;
30
+ if (backend === "AI SDK") return true;
31
+ if (backend === "OpenAI") return !!llmConfig?.responses_api;
32
+ return false;
33
+ };
34
+
17
35
  const boxHandledStyles = new Set([
18
36
  "margin",
19
37
  "margin-top",
@@ -364,4 +382,6 @@ module.exports = {
364
382
  splitContainerStyle,
365
383
  walk_response,
366
384
  parseHTML,
385
+ getLlmConfigurationSafe,
386
+ canUseResponseFormat,
367
387
  };
@@ -3,6 +3,7 @@ const Table = require("@saltcorn/data/models/table");
3
3
  const Form = require("@saltcorn/data/models/form");
4
4
  const View = require("@saltcorn/data/models/view");
5
5
  const Trigger = require("@saltcorn/data/models/trigger");
6
+ const Plugin = require("@saltcorn/data/models/plugin");
6
7
  const { findType } = require("@saltcorn/data/models/discovery");
7
8
  const { save_menu_items } = require("@saltcorn/data/models/config");
8
9
  const db = require("@saltcorn/data/db");
@@ -46,6 +47,12 @@ const get_agent_view = () => {
46
47
  { skill_type: "Database design" },
47
48
  { skill_type: "Generate Workflow" },
48
49
  { skill_type: "Generate View" },
50
+ { skill_type: "Registry editor" },
51
+ { skill_type: "Javascript Action" },
52
+ { skill_type: "Generate trigger" },
53
+ ...(typeof Plugin.loadAndSaveNewPlugin === "function"
54
+ ? [{ skill_type: "Install Plugin" }]
55
+ : []),
49
56
  ],
50
57
  },
51
58
  });
@@ -64,7 +71,6 @@ const run = async (table_id, viewname, cfg, state, reqres) => {
64
71
  };
65
72
 
66
73
  const interact = async (table_id, viewname, config, body, reqres) => {
67
- console.log("copilot interact with body", body);
68
74
  const view = get_agent_view();
69
75
  return await view.runRoute("interact", body, reqres.res, reqres);
70
76
  };
package/index.js CHANGED
@@ -1,15 +1,27 @@
1
1
  const Workflow = require("@saltcorn/data/models/workflow");
2
2
  const Form = require("@saltcorn/data/models/form");
3
3
  const { features } = require("@saltcorn/data/db/state");
4
+ const db = require("@saltcorn/data/db");
5
+ const Plugin = require("@saltcorn/data/models/plugin");
6
+ const { viewname } = require("./app-constructor/common.js");
7
+
8
+ const headers = [
9
+ {
10
+ script: `/static_assets/${db.connectObj.version_tag}/mermaid.min.js`,
11
+ onlyViews: [viewname],
12
+ },
13
+ ];
4
14
 
5
15
  module.exports = {
6
16
  sc_plugin_api_version: 1,
17
+ headers,
7
18
  dependencies: ["@saltcorn/large-language-model", "@saltcorn/agents"],
8
19
  viewtemplates: features.workflows
9
20
  ? [
10
21
  require("./chat-copilot"),
11
22
  require("./user-copilot"),
12
23
  require("./copilot-as-agent"),
24
+ require("./app-constructor/view.js"),
13
25
  ]
14
26
  : [require("./action-builder"), require("./database-designer")],
15
27
  functions: features.workflows
@@ -17,15 +29,25 @@ module.exports = {
17
29
  copilot_standard_prompt: require("./standard-prompt.js"),
18
30
  copilot_generate_layout: require("./builder-gen.js"),
19
31
  copilot_generate_workflow: require("./workflow-gen"),
32
+ copilot_generate_javascript: require("./js-code-gen.js"),
20
33
  }
21
34
  : {},
22
- actions: { copilot_generate_page: require("./page-gen-action") },
35
+ actions: {
36
+ copilot_generate_page: require("./page-gen-action"),
37
+ app_constructor_feedback: require("./app-constructor/feedback-action.js"),
38
+ },
23
39
  exchange: {
24
40
  agent_skills: [
25
41
  require("./agent-skills/pagegen.js"),
26
42
  require("./agent-skills/database-design.js"),
27
43
  require("./agent-skills/workflow.js"),
28
44
  require("./agent-skills/viewgen.js"),
45
+ require("./agent-skills/registry-editor.js"),
46
+ require("./agent-skills/js-action.js"),
47
+ require("./agent-skills/triggergen.js"),
48
+ ...(typeof Plugin.loadAndSaveNewPlugin === "function"
49
+ ? [require("./agent-skills/install-plugin.js")]
50
+ : []),
29
51
  ],
30
52
  },
31
53
  };
package/js-code-gen.js ADDED
@@ -0,0 +1,65 @@
1
+ const { getState } = require("@saltcorn/data/db/state");
2
+ const { getPromptFromTemplate } = require("./common");
3
+ const Table = require("@saltcorn/data/models/table");
4
+
5
+ const stripCodeFences = (text) => {
6
+ let s = String(text || "").trim();
7
+ s = s.replace(/^```(?:javascript|js|ts|typescript)?\s*\n?/i, "");
8
+ s = s.replace(/\n?```\s*$/i, "");
9
+ return s.trim();
10
+ };
11
+
12
+ const getTableContext = (table_name) => {
13
+ if (!table_name) return null;
14
+ const table = Table.findOne({ name: table_name });
15
+ if (!table) return null;
16
+ const fields = table.getFields ? table.getFields() : table.fields || [];
17
+ return { table, fieldNames: fields.map((f) => f.name) };
18
+ };
19
+
20
+ module.exports = {
21
+ run: async (description, existing_code, table_name) => {
22
+ const systemPrompt = await getPromptFromTemplate(
23
+ "action-builder.txt",
24
+ description
25
+ );
26
+
27
+ const tableCtx = getTableContext(table_name);
28
+ let contextInfo;
29
+ if (tableCtx) {
30
+ contextInfo =
31
+ `\n\nThis action runs in the context of the "${table_name}" table. ` +
32
+ `Available variables: row (with fields: ${tableCtx.fieldNames.join(", ")}), ` +
33
+ `user, table, console, Actions, Table, File, User.`;
34
+ } else {
35
+ contextInfo =
36
+ "\n\nAvailable variables: user, console, Actions, Table, File, User.";
37
+ }
38
+
39
+ let prompt;
40
+ if (existing_code && existing_code.trim()) {
41
+ prompt =
42
+ `Modify the following JavaScript code based on this instruction: ${description}\n\n` +
43
+ `Existing code:\n${existing_code}` +
44
+ `${contextInfo}\n\n` +
45
+ `Return only the modified JavaScript code. Do not include any explanation or markdown code fences.`;
46
+ } else {
47
+ prompt =
48
+ `Generate JavaScript code for the following task: ${description}` +
49
+ `${contextInfo}\n\n` +
50
+ `Only return the JavaScript code. Do not include any explanation or markdown code fences.`;
51
+ }
52
+
53
+ const result = await getState().functions.llm_generate.run(prompt, {
54
+ systemPrompt,
55
+ });
56
+ return stripCodeFences(result);
57
+ },
58
+ isAsync: true,
59
+ description: "Generate JavaScript code for an action",
60
+ arguments: [
61
+ { name: "description", type: "String" },
62
+ { name: "existing_code", type: "String" },
63
+ { name: "table_name", type: "String" },
64
+ ],
65
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saltcorn/copilot",
3
- "version": "0.7.5",
3
+ "version": "0.8.1",
4
4
  "description": "AI assistant for building Saltcorn applications",
5
5
  "main": "index.js",
6
6
  "dependencies": {
@@ -0,0 +1,236 @@
1
+ const { RelationsFinder, RelationType } = require("@saltcorn/common-code");
2
+
3
+ /**
4
+ * Relation path documentation included in LLM system prompts (viewgen, builder-gen).
5
+ *
6
+ * TWO FORMATS EXIST:
7
+ *
8
+ * New format (always generate this):
9
+ * view: "viewname" + relation: ".sourcetable.segment1.segment2..."
10
+ *
11
+ * Segment types:
12
+ * Outbound FK (to parent): FK field name alone, e.g. trip_id
13
+ * Inbound FK (child rows): childtable$fkfield, e.g. packing_items$trip_id
14
+ *
15
+ * Examples:
16
+ * .trips.packing_items$trip_id ChildList: all packing_items for a trip
17
+ * .packing_items.trip_id ParentShow: the trip that owns a packing item
18
+ * .artists.artist_plays_on_album$artist.album ChildList through a join table
19
+ * .users.orders$user_id.order_lines$order_id RelationPath: multi-level
20
+ *
21
+ * Legacy format (may appear in existing configs — do not generate, understand only):
22
+ * The type and path are encoded together in the view field, no separate relation field.
23
+ * "Own:viewname" → same table, no relation
24
+ * "ParentShow:viewname.table.fkfield" → outbound FK to parent
25
+ * "ChildList:viewname.table.inbkey" → inbound FK, one-to-many
26
+ * "OneToOneShow:viewname.table.inbkey" → inbound FK, unique
27
+ * "Independent:viewname" → no FK relationship
28
+ *
29
+ * Relation types by new-format path structure:
30
+ * Own – zero segments, source and target are the same table
31
+ * ParentShow – single outbound-FK segment
32
+ * OneToOneShow – single inbound-FK segment on a unique field
33
+ * ChildList – one or more inbound-FK segments (may mix outbound for join tables)
34
+ * RelationPath – complex multi-level path mixing both segment types
35
+ */
36
+ const RELATION_PATH_DOC = `
37
+ ## Relation paths
38
+
39
+ Relation paths connect a view_link column or embedded view (type "view") segment to its
40
+ target view. There are two formats — **new** (preferred) and **legacy** (read-only).
41
+
42
+ ---
43
+
44
+ ### New format (always use this when generating or updating)
45
+
46
+ Two separate fields:
47
+ - \`view\`: just the view name, e.g. \`"packing_items_list"\`
48
+ - \`relation\`: a dot-separated path string, e.g. \`".trips.packing_items$trip_id"\`
49
+
50
+ **Path string format:** \`.sourcetable.segment1.segment2...\`
51
+
52
+ Segment types:
53
+ - **Outbound FK** (navigate to a parent): FK field name alone — e.g. \`trip_id\`
54
+ - **Inbound FK** (collect child rows): \`childtable$fkfield\` — e.g. \`packing_items$trip_id\`
55
+
56
+ Examples:
57
+ | relation string | type | meaning |
58
+ |---|---|---|
59
+ | \`.trips.packing_items$trip_id\` | ChildList | all packing_items for a trip |
60
+ | \`.packing_items.trip_id\` | ParentShow | the trip that owns a packing_item |
61
+ | \`.artists.artist_plays_on_album$artist.album\` | ChildList | albums via join table |
62
+ | \`.users.orders$user_id.order_lines$order_id\` | RelationPath | multi-level |
63
+
64
+ ---
65
+
66
+ ### Legacy format (you may encounter this in existing configs — do not generate it)
67
+
68
+ The type and path are encoded together inside the \`view\` field as a colon-prefixed string.
69
+ There is no separate \`relation\` field.
70
+
71
+ | legacy view field | equivalent new relation |
72
+ |---|---|
73
+ | \`"Own:viewname"\` | \`view: "viewname"\`, no relation |
74
+ | \`"ParentShow:viewname.table.fkfield"\` | \`relation: ".sourcetable.fkfield"\` |
75
+ | \`"ChildList:viewname.table.inbkey"\` | \`relation: ".sourcetable.childtable$inbkey"\` |
76
+ | \`"OneToOneShow:viewname.table.inbkey"\` | \`relation: ".sourcetable.childtable$inbkey"\` |
77
+ | \`"Independent:viewname"\` | \`view: "viewname"\`, no relation |
78
+
79
+ When you read an existing view config and see a legacy \`view\` value like \`"ChildList:trips_list.packing_items.trip_id"\`, parse it as: type=ChildList, view=trips_list, relation=.sourcetable.packing_items$trip_id. When writing back, convert to the new format.
80
+
81
+ ---
82
+
83
+ ### Using get_relation_paths
84
+
85
+ Call it **once** with all source_table/target_view pairs you need — do not make separate calls per pair. The tool returns paths in the new RelationPath format; always write back in the new format, even if you read legacy strings from an existing config.
86
+
87
+ When multiple paths are returned for one pair, pick by matching the relation type to what the target view is meant to show:
88
+ - **ChildList** — target view shows multiple rows belonging to the current row (e.g. packing items for a trip).
89
+ - **ParentShow** — target view shows the single parent the current row belongs to (e.g. the trip for a packing item).
90
+ - **OneToOneShow** — exactly one related child row via a unique FK.
91
+ - **Own** — target view is on the same table (no FK traversal needed).
92
+ - If multiple paths of the same type exist (e.g. a table has two FKs pointing to the same target), pick the one whose FK field name best matches the semantic relationship in the task.
93
+ - Prefer shorter paths (fewer segments) unless a longer one is clearly more appropriate.
94
+ `;
95
+
96
+ const typeToLabel = (type) => {
97
+ if (type === RelationType.OWN)
98
+ return "Own – source and target are the same table (no relation needed)";
99
+ if (type === RelationType.INDEPENDENT)
100
+ return "Independent – no FK relationship exists";
101
+ if (type === RelationType.PARENT_SHOW)
102
+ return "ParentShow – outbound FK to a parent record (many-to-one)";
103
+ if (type === RelationType.ONE_TO_ONE_SHOW)
104
+ return "OneToOneShow – unique inbound FK (one-to-one)";
105
+ if (type === RelationType.CHILD_LIST)
106
+ return "ChildList – inbound FK, one parent → many child rows";
107
+ return "RelationPath – complex multi-level path";
108
+ };
109
+
110
+ /**
111
+ * @param {string} sourceTableName
112
+ * @param {string} targetViewName
113
+ * @param {{ tables, views }} schemaData pre-fetched via build_schema_data()
114
+ * @returns {Array<Relation>} raw Relation objects from RelationsFinder
115
+ */
116
+ function getRelationPaths(sourceTableName, targetViewName, schemaData) {
117
+ if (!schemaData) return [];
118
+ try {
119
+ const finder = new RelationsFinder(schemaData.tables, schemaData.views, 6);
120
+ return finder.findRelations(sourceTableName, targetViewName, []);
121
+ } catch {
122
+ return [];
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Resolve multiple source_table/target_view pairs against pre-fetched schema data.
128
+ * All per-pair work is synchronous — call build_schema_data() once before invoking this.
129
+ * @param {Array<{source_table: string, target_view: string}>} pairs
130
+ * @param {{ tables, views }} schemaData
131
+ * @returns {Array<string>} one formatted result string per pair
132
+ */
133
+ function getRelationPathsForPairs(pairs, schemaData) {
134
+ if (!schemaData) return pairs.map(({ source_table, target_view }) =>
135
+ formatRelationPathResult(source_table, target_view, { error: "Schema data unavailable" })
136
+ );
137
+ const finder = new RelationsFinder(schemaData.tables, schemaData.views, 6);
138
+ return pairs.map(({ source_table, target_view }) => {
139
+ const targetView = (schemaData.views || []).find((v) => v.name === target_view);
140
+ if (!targetView)
141
+ return formatRelationPathResult(source_table, target_view, {
142
+ error: `View "${target_view}" not found in current schema`,
143
+ });
144
+ let relations;
145
+ try {
146
+ relations = finder.findRelations(source_table, target_view, []);
147
+ } catch (e) {
148
+ return formatRelationPathResult(source_table, target_view, {
149
+ error: `Failed to find relations: ${e.message}`,
150
+ });
151
+ }
152
+ return formatRelationPathResult(source_table, target_view, {
153
+ paths: relations.map((r) => ({
154
+ relation_string: r.relationString,
155
+ type: String(r.type),
156
+ label: typeToLabel(r.type),
157
+ })),
158
+ });
159
+ });
160
+ }
161
+
162
+ /**
163
+ * Pick the most useful relation from a list: Own > Parent > Child > first.
164
+ * Used as a fallback in builder-gen when the model doesn't specify a relation.
165
+ */
166
+ function pickBestRelation(relations) {
167
+ if (!relations.length) return null;
168
+ let own = null,
169
+ parent = null,
170
+ child = null;
171
+ for (const r of relations) {
172
+ if (r.type === RelationType.OWN) own = r;
173
+ else if (r.type === RelationType.PARENT_SHOW) parent = r;
174
+ else if (
175
+ r.type === RelationType.CHILD_LIST ||
176
+ r.type === RelationType.ONE_TO_ONE_SHOW
177
+ )
178
+ child = r;
179
+ }
180
+ return own || parent || child || relations[0];
181
+ }
182
+
183
+ /**
184
+ * Format the result of getRelationPaths into a human-readable string for the model.
185
+ * Handles both found and not-found cases for one source_table/target_view pair.
186
+ */
187
+ function formatRelationPathResult(source_table, target_view, result) {
188
+ if (result.error) return `${source_table} → ${target_view}: ${result.error}`;
189
+ if (!result.paths.length)
190
+ return `${source_table} → ${target_view}: no relation paths found (no FK relationship)`;
191
+ const lines = result.paths
192
+ .map((p) => ` "${p.relation_string}" — ${p.label}`)
193
+ .join("\n");
194
+ return `${source_table} → ${target_view}:\n${lines}`;
195
+ }
196
+
197
+ const GET_RELATION_PATHS_FUNCTION = {
198
+ name: "get_relation_paths",
199
+ description:
200
+ "Get all valid relation path strings for one or more source_table/target_view pairs. " +
201
+ "Call this ONCE with all pairs you need before setting any 'relation' property on " +
202
+ "view_link columns or embedded view (type 'view') segments.",
203
+ parameters: {
204
+ type: "object",
205
+ required: ["pairs"],
206
+ properties: {
207
+ pairs: {
208
+ type: "array",
209
+ description:
210
+ "All source_table/target_view pairs you need relation paths for. Include every pair in one call.",
211
+ items: {
212
+ type: "object",
213
+ required: ["source_table", "target_view"],
214
+ properties: {
215
+ source_table: {
216
+ type: "string",
217
+ description: "The table of the view being built or updated.",
218
+ },
219
+ target_view: {
220
+ type: "string",
221
+ description: "The view to link to or embed.",
222
+ },
223
+ },
224
+ },
225
+ },
226
+ },
227
+ },
228
+ };
229
+
230
+ module.exports = {
231
+ RELATION_PATH_DOC,
232
+ GET_RELATION_PATHS_FUNCTION,
233
+ getRelationPaths,
234
+ getRelationPathsForPairs,
235
+ pickBestRelation,
236
+ };
@@ -0,0 +1,56 @@
1
+ const { getState } = require("@saltcorn/data/db/state");
2
+ const View = require("@saltcorn/data/models/view");
3
+ const Table = require("@saltcorn/data/models/table");
4
+ const Plugin = require("@saltcorn/data/models/plugin");
5
+ const Trigger = require("@saltcorn/data/models/trigger");
6
+ const WorkflowStep = require("@saltcorn/data/models/workflow_step");
7
+ const WorkflowRun = require("@saltcorn/data/models/workflow_run");
8
+
9
+ const { mockReqRes } = require("@saltcorn/data/tests/mocks");
10
+ const { afterAll, beforeAll, describe, it, expect } = require("@jest/globals");
11
+
12
+ afterAll(require("@saltcorn/data/db").close);
13
+ beforeAll(async () => {
14
+ await require("@saltcorn/data/db/reset_schema")();
15
+ await require("@saltcorn/data/db/fixtures")();
16
+
17
+ getState().registerPlugin("base", require("@saltcorn/data/base-plugin"));
18
+ });
19
+
20
+ /*
21
+
22
+ RUN WITH:
23
+ saltcorn dev:plugin-test -d ~/copilot -o ~/large-language-model/
24
+
25
+ */
26
+
27
+ jest.setTimeout(60000);
28
+
29
+ const configs = require("./configs.js");
30
+
31
+ for (const nameconfig of configs) {
32
+ const { name, ...config } = nameconfig;
33
+ describe("copilot_generate_layout with " + name, () => {
34
+ beforeAll(async () => {
35
+ getState().registerPlugin(
36
+ "@saltcorn/large-language-model",
37
+ require("@saltcorn/large-language-model"),
38
+ config,
39
+ );
40
+ getState().registerPlugin("@saltcorn/copilot", require(".."));
41
+ });
42
+ for (const mode of ["page", "show", "edit", "filter"])
43
+ it("generates simple layout in mode " + mode, async () => {
44
+ const genres = await getState().functions.copilot_generate_layout.run(
45
+ "A container with a text element that says Hello World in H3",
46
+ mode,
47
+ mode === "page" ? null : "books",
48
+ );
49
+ const innerRes = genres.above ? genres.above[0] : genres;
50
+ expect(innerRes.type).toBe("container");
51
+ expect(innerRes.contents.type).toBe("blank");
52
+ expect(innerRes.contents.contents).toBe("Hello World");
53
+ expect(innerRes.contents.textStyle).toBe("h3");
54
+ });
55
+ });
56
+ }