import { describe, expect, test } from "vitest"; import { bootstrap } from "./bootstrap.js"; import { renderAsync } from "./render.js"; import { putSchema } from "./schema.js"; import { createMemoryStore } from "./store.js"; describe("HTML Render MVP", () => { describe("HTML template discovery", () => { test("discovers HTML instance template from @ocas/template/html/", async () => { const store = createMemoryStore(); const aliases = bootstrap(store); // Create a simple schema const personSchema = putSchema(store, { type: "object", properties: { name: { type: "string" }, age: { type: "number" }, }, }); // Register HTML template for this schema const stringHash = aliases["@ocas/string"]; if (!stringHash) throw new Error("@ocas/string not found"); const templateContent = '

{{ payload.name }}

Age: {{ payload.age }}

'; const templateHash = store.cas.put(stringHash, templateContent); store.var.set(`@ocas/template/html/${personSchema}`, templateHash); // Create a person node const personHash = store.cas.put(personSchema, { name: "Alice", age: 30, }); // Render with HTML format const output = await renderAsync(store, personHash, { format: "html" }); // Should contain the rendered HTML fragment expect(output).toContain('
'); expect(output).toContain("

Alice

"); expect(output).toContain("

Age: 30

"); }); test("HTML template uses LiquidJS engine", async () => { const store = createMemoryStore(); const aliases = bootstrap(store); const listSchema = putSchema(store, { type: "object", properties: { items: { type: "array", items: { type: "string" }, }, }, }); // Register HTML template with LiquidJS loop const stringHash = aliases["@ocas/string"]; if (!stringHash) throw new Error("@ocas/string not found"); const templateContent = ""; const templateHash = store.cas.put(stringHash, templateContent); store.var.set(`@ocas/template/html/${listSchema}`, templateHash); // Create a list node const listHash = store.cas.put(listSchema, { items: ["one", "two", "three"], }); // Render with HTML format const output = await renderAsync(store, listHash, { format: "html" }); // Should contain the rendered list expect(output).toContain(""); }); }); describe("YAML fallback for missing HTML templates", () => { test("falls back to structured HTML when no HTML template exists", async () => { const store = createMemoryStore(); bootstrap(store); const personSchema = putSchema(store, { type: "object", properties: { name: { type: "string" }, age: { type: "number" }, }, }); // NO HTML template registered - should fall back to structured HTML const personHash = store.cas.put(personSchema, { name: "Bob", age: 25 }); // Render with HTML format const output = await renderAsync(store, personHash, { format: "html" }); // Should contain structured HTML (not
 YAML)
      expect(output).toContain("");
    });

    test("fallback escapes HTML special characters in values", async () => {
      const store = createMemoryStore();
      bootstrap(store);

      const schema = putSchema(store, {
        type: "object",
        properties: {
          html: { type: "string" },
        },
      });

      // Payload contains HTML-unsafe characters
      const hash = store.cas.put(schema, {
        html: '',
      });

      const output = await renderAsync(store, hash, { format: "html" });

      // Angle brackets should be escaped in the fallback
      expect(output).toContain("<script>");
      expect(output).not.toContain("",
      );

      // CSS appears before  (in the head section)
      const headEnd = output.indexOf("");
      const stylePos = output.indexOf("");
      const headEnd = output.indexOf("");
      const stylePos = output.indexOf("{% endif %}
  {% endfor %}


  
{{ content }}
{% for ts in type_statics %} {% if ts.js %}{% endif %} {% endfor %} `; const composeHash = store.cas.put(stringHash, customCompose); store.var.set("@ocas/template-compose/html", composeHash); const nodeHash = store.cas.put(schema, { title: "My Page" }); const output = await renderAsync(store, nodeHash, { format: "html" }); // Custom compose was used expect(output).toContain("Custom App"); // Content rendered inside
expect(output).toContain("

My Page

"); // CSS rendered via for loop expect(output).toContain(""); }); test("type_statics passed as iterable array with type_hash and slots", async () => { const store = createMemoryStore(); const aliases = bootstrap(store); const personSchema = putSchema(store, { type: "object", properties: { name: { type: "string" } }, }); const docSchema = putSchema(store, { type: "object", properties: { body: { type: "string" } }, }); const stringHash = aliases["@ocas/string"]; if (!stringHash) throw new Error("@ocas/string not found"); // Register templates for both types const personTemplate = "{{ name }}"; const personTemplateHash = store.cas.put(stringHash, personTemplate); store.var.set(`@ocas/template/html/${personSchema}`, personTemplateHash); const docTemplate = "
{{ body }}
"; const docTemplateHash = store.cas.put(stringHash, docTemplate); store.var.set(`@ocas/template/html/${docSchema}`, docTemplateHash); // Static templates: person has CSS+JS, doc has CSS only const personStatic = '{"css": ".person { color: blue; }", "js": "initPerson();"}'; const personStaticHash = store.cas.put(stringHash, personStatic); store.var.set( `@ocas/template-static/html/${personSchema}`, personStaticHash, ); const docStatic = '{"css": ".doc { padding: 16px; }"}'; const docStaticHash = store.cas.put(stringHash, docStatic); store.var.set(`@ocas/template-static/html/${docSchema}`, docStaticHash); // Custom compose that outputs JSON for introspection const composeTemplate = "STATICS_JSON:{{ type_statics | json }}:END\n{{ content }}"; const composeHash = store.cas.put(stringHash, composeTemplate); store.var.set("@ocas/template-compose/html", composeHash); // Root referencing both types const rootSchema = putSchema(store, { type: "object", properties: { person: { type: "string", format: "ocas_ref" }, doc: { type: "string", format: "ocas_ref" }, }, }); const rootTemplate = "
{% render person %}{% render doc %}
"; const rootTemplateHash = store.cas.put(stringHash, rootTemplate); store.var.set(`@ocas/template/html/${rootSchema}`, rootTemplateHash); const personHash = store.cas.put(personSchema, { name: "Alice" }); const docHash = store.cas.put(docSchema, { body: "Content" }); const rootHash = store.cas.put(rootSchema, { person: personHash, doc: docHash, }); const output = await renderAsync(store, rootHash, { format: "html" }); // Extract JSON from compose output const jsonMatch = output.match(/STATICS_JSON:(.*?):END/); expect(jsonMatch).not.toBeNull(); const statics = JSON.parse(jsonMatch?.[1] ?? ""); // type_statics is an array expect(Array.isArray(statics)).toBe(true); // Each element has type_hash for (const entry of statics) { expect(entry).toHaveProperty("type_hash"); expect(typeof entry.type_hash).toBe("string"); } // Find person entry const personEntry = statics.find( (e: Record) => e.type_hash === personSchema, ); expect(personEntry).toBeDefined(); expect(personEntry.css).toBe(".person { color: blue; }"); expect(personEntry.js).toBe("initPerson();"); // Find doc entry const docEntry = statics.find( (e: Record) => e.type_hash === docSchema, ); expect(docEntry).toBeDefined(); expect(docEntry.css).toBe(".doc { padding: 16px; }"); expect(docEntry).not.toHaveProperty("js"); // No JS for doc }); test("types without static templates are excluded from type_statics array", async () => { const store = createMemoryStore(); const aliases = bootstrap(store); const withStatic = putSchema(store, { type: "object", properties: { a: { type: "string" } }, }); const withoutStatic = putSchema(store, { type: "object", properties: { b: { type: "string" } }, }); const stringHash = aliases["@ocas/string"]; if (!stringHash) throw new Error("@ocas/string not found"); // Templates for both store.var.set( `@ocas/template/html/${withStatic}`, store.cas.put(stringHash, "

{{ a }}

"), ); store.var.set( `@ocas/template/html/${withoutStatic}`, store.cas.put(stringHash, "

{{ b }}

"), ); // Static template only for the first type store.var.set( `@ocas/template-static/html/${withStatic}`, store.cas.put(stringHash, '{"css": ".a {}"}'), ); // Compose template that outputs statics count const composeTemplate = "COUNT:{{ type_statics.size }}:END\nARRAY:{{ type_statics | json }}:END\n{{ content }}"; store.var.set( "@ocas/template-compose/html", store.cas.put(stringHash, composeTemplate), ); // Root references both types const rootSchema = putSchema(store, { type: "object", properties: { c1: { type: "string", format: "ocas_ref" }, c2: { type: "string", format: "ocas_ref" }, }, }); store.var.set( `@ocas/template/html/${rootSchema}`, store.cas.put(stringHash, "
{% render c1 %}{% render c2 %}
"), ); const h1 = store.cas.put(withStatic, { a: "A" }); const h2 = store.cas.put(withoutStatic, { b: "B" }); const rootHash = store.cas.put(rootSchema, { c1: h1, c2: h2 }); const output = await renderAsync(store, rootHash, { format: "html" }); // Extract array const jsonMatch = output.match(/ARRAY:(.*?):END/); expect(jsonMatch).not.toBeNull(); const arr = JSON.parse(jsonMatch?.[1] ?? ""); // Only the type with a static template should be in the array expect(arr).toHaveLength(1); expect(arr[0].type_hash).toBe(withStatic); }); test("multiple type_statics entries generate separate style and script blocks", async () => { const store = createMemoryStore(); const aliases = bootstrap(store); const t1 = putSchema(store, { type: "object", properties: { x: { type: "string" } }, }); const t2 = putSchema(store, { type: "object", properties: { y: { type: "string" } }, }); const stringHash = aliases["@ocas/string"]; if (!stringHash) throw new Error("@ocas/string not found"); // Templates store.var.set( `@ocas/template/html/${t1}`, store.cas.put(stringHash, "

{{ x }}

"), ); store.var.set( `@ocas/template/html/${t2}`, store.cas.put(stringHash, "

{{ y }}

"), ); // Static templates for both store.var.set( `@ocas/template-static/html/${t1}`, store.cas.put( stringHash, '{"css": "/* t1 css */", "js": "/* t1 js */"}', ), ); store.var.set( `@ocas/template-static/html/${t2}`, store.cas.put( stringHash, '{"css": "/* t2 css */", "js": "/* t2 js */"}', ), ); // No custom compose — uses builtin const rootSchema = putSchema(store, { type: "object", properties: { c1: { type: "string", format: "ocas_ref" }, c2: { type: "string", format: "ocas_ref" }, }, }); store.var.set( `@ocas/template/html/${rootSchema}`, store.cas.put(stringHash, "
{% render c1 %}{% render c2 %}
"), ); const h1 = store.cas.put(t1, { x: "X" }); const h2 = store.cas.put(t2, { y: "Y" }); const rootHash = store.cas.put(rootSchema, { c1: h1, c2: h2 }); const output = await renderAsync(store, rootHash, { format: "html" }); // Each type's CSS produces one "); expect(output).toContain(""); // Each type's JS produces one "); expect(output).toContain(""); // Verify document structure: styles in head, scripts in body const headEnd = output.indexOf(""); const bodyEnd = output.indexOf(""); expect(output.indexOf("")).toBeLessThan( headEnd, ); expect(output.indexOf("")).toBeLessThan( headEnd, ); expect(output.indexOf("")).toBeLessThan( bodyEnd, ); expect(output.indexOf("")).toBeLessThan( bodyEnd, ); }); }); });