#!/bin/bash # test-playground-migrations.sh — Playground v3 dataVersion v1->v2 idempotency # # Verifiserer: # 1. fixture (playground/test-fixtures/state-v1-snapshot.json) eksisterer + er v1 # 2. V2_FOUNDATION-blokken kan ekstraheres fra HTML-fila # 3. migrateDataVersion(fixture) gir resultat A # 4. migrateDataVersion(A) gir resultat B # 5. JSON.stringify(A) === JSON.stringify(B) -> idempotency # 6. A.dataVersion === 2 -> migrasjonen ble utført # # Bash 3.2-kompatibel. Bruker node til JS-eval; ingen npm-deps. # Designet for å integreres i tests/run-e2e.sh --playground. set -euo pipefail PLUGIN_ROOT="$(cd "$(dirname "$0")/.." && pwd)" HTML_FILE="$PLUGIN_ROOT/playground/ms-ai-architect-playground.html" FIXTURE_FILE="$PLUGIN_ROOT/playground/test-fixtures/state-v1-snapshot.json" # shellcheck disable=SC1091 source "$PLUGIN_ROOT/tests/lib/e2e-helpers.sh" init_suite "Playground v3 — dataVersion v1->v2 migration idempotency" # ---- 1. Filer eksisterer ---- if [ ! -f "$HTML_FILE" ]; then fail "HTML-fila finnes ikke: $HTML_FILE" print_summary; exit 1 fi pass "HTML-fil finnes: $(basename "$HTML_FILE")" if [ ! -f "$FIXTURE_FILE" ]; then fail "Fixture mangler: $FIXTURE_FILE" print_summary; exit 1 fi pass "Fixture finnes: $(basename "$FIXTURE_FILE")" # ---- 2. Fixture er v1 (mangler dataVersion eller dataVersion < 2) ---- if node -e " const f = JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8')); if (f.dataVersion === 2) { console.error('fixture er allerede v2'); process.exit(1); } if (typeof f.schemaVersion !== 'number') { console.error('fixture mangler schemaVersion'); process.exit(1); } if (!Array.isArray(f.projects) || f.projects.length === 0) { console.error('fixture har ingen projects'); process.exit(1); } " "$FIXTURE_FILE" 2>/dev/null; then pass "Fixture er v1-state (dataVersion ikke satt)" else fail "Fixture er ikke gyldig v1-state" print_summary; exit 1 fi # ---- 3. V2_FOUNDATION-markører eksisterer i HTML ---- if grep -q "V2_FOUNDATION_BEGIN" "$HTML_FILE" && grep -q "V2_FOUNDATION_END" "$HTML_FILE"; then pass "V2_FOUNDATION_BEGIN/END markører finnes" else fail "Mangler V2_FOUNDATION-markører i HTML" print_summary; exit 1 fi # ---- 4. migrateDataVersion-funksjonen er definert ---- if grep -q "function migrateDataVersion" "$HTML_FILE"; then pass "migrateDataVersion-funksjonen finnes" else fail "migrateDataVersion-funksjonen mangler" print_summary; exit 1 fi # ---- 5. Eval idempotency-test i node ---- # Strategi: ekstraher V2_FOUNDATION-blokken via sed, wrap med stubs (window, # CATALOG-mock som returnerer archetype basert på fixture command-IDs), eval, # kjør migrasjon to ganger på fixture-deep-copy, sammenlign JSON.stringify. IDEMPOTENCY_RESULT=$(node -e ' const fs = require("fs"); const path = require("path"); const htmlPath = process.argv[1]; const fixturePath = process.argv[2]; const html = fs.readFileSync(htmlPath, "utf8"); const beginMarker = "// === V2_FOUNDATION_BEGIN ==="; const endMarker = "// === V2_FOUNDATION_END ==="; const beginIdx = html.indexOf(beginMarker); const endIdx = html.indexOf(endMarker); if (beginIdx < 0 || endIdx < 0) { console.error("MARKER_MISSING"); process.exit(2); } const block = html.substring(beginIdx, endIdx + endMarker.length); // Stub-ene som blokken trenger const stubs = ` const window = {}; function escapeHtml(s) { return String(s == null ? "" : s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } function escapeAttr(s) { return escapeHtml(s); } // Mock CATALOG: kun de command-id-ene fixture-en bruker. const CATALOG = { commands: [ { id: "classify", report_archetype: "aiact" }, { id: "ros", report_archetype: "matrix-risk" }, { id: "cost", report_archetype: "cost-distribution" }, { id: "summary", report_archetype: "verdict" } ]}; `; const wrapped = stubs + block + "\nreturn { migrateDataVersion, defaultArchetypeFor };"; let api; try { api = (new Function(wrapped))(); } catch (e) { console.error("EVAL_FAILED:", e.message); process.exit(3); } const fixture = JSON.parse(fs.readFileSync(fixturePath, "utf8")); // Deep clones så vi sammenligner uavhengige objekter const stateA = JSON.parse(JSON.stringify(fixture)); api.migrateDataVersion(stateA, api.defaultArchetypeFor); const stateB = JSON.parse(JSON.stringify(stateA)); api.migrateDataVersion(stateB, api.defaultArchetypeFor); const a = JSON.stringify(stateA); const b = JSON.stringify(stateB); if (stateA.dataVersion !== 3) { console.error("DATA_VERSION_NOT_BUMPED got=" + stateA.dataVersion); process.exit(4); } // Sjekk at minst én rapport fikk verdict + keyStats utledet let verdictsAdded = 0, statsAdded = 0; for (const p of (stateA.projects || [])) { for (const id of Object.keys(p.reports || {})) { const r = p.reports[id]; if (r && r.parsed) { if (r.parsed.verdict != null) verdictsAdded++; if (Array.isArray(r.parsed.keyStats)) statsAdded++; } } } if (verdictsAdded === 0) { console.error("NO_VERDICTS_ADDED"); process.exit(5); } if (statsAdded === 0) { console.error("NO_KEYSTATS_ADDED"); process.exit(6); } // Sesjon 3: v2→v3-migrasjon må produsere project.artifacts med v3-shape. let artifactsBuilt = 0; for (const p of (stateA.projects || [])) { if (!p.artifacts) continue; for (const id of Object.keys(p.artifacts)) { const a = p.artifacts[id]; if (a && a.commandId === id && typeof a.raw_markdown === "string" && a.importedAt && a.updatedAt) { artifactsBuilt++; } } } if (artifactsBuilt === 0) { console.error("NO_ARTIFACTS_BUILT"); process.exit(8); } if (a === b) { console.log("IDEMPOTENT verdicts=" + verdictsAdded + " stats=" + statsAdded + " artifacts=" + artifactsBuilt); } else { console.error("NOT_IDEMPOTENT"); process.exit(7); } ' "$HTML_FILE" "$FIXTURE_FILE" 2>&1) || RC=$? if [ "${RC:-0}" -eq 0 ] && echo "$IDEMPOTENCY_RESULT" | grep -q "^IDEMPOTENT"; then pass "migrateDataVersion er idempotent ($IDEMPOTENCY_RESULT)" else fail "Idempotency-test feilet: $IDEMPOTENCY_RESULT" fi # ---- 6. dataVersion bumpes til 3 ved første kjøring (v?→v3 kjede) ---- DV_RESULT=$(node -e ' const fs = require("fs"); const html = fs.readFileSync(process.argv[1], "utf8"); const begin = html.indexOf("// === V2_FOUNDATION_BEGIN ==="); const end = html.indexOf("// === V2_FOUNDATION_END ==="); const block = html.substring(begin, end + 32); const stubs = ` const window = {}; function escapeHtml(s) { return String(s == null ? "" : s); } function escapeAttr(s) { return escapeHtml(s); } const CATALOG = { commands: [ { id: "classify", report_archetype: "aiact" } ]}; `; const api = (new Function(stubs + block + "\nreturn { migrateDataVersion, defaultArchetypeFor };"))(); const state = { schemaVersion: 1, projects: [] }; api.migrateDataVersion(state, api.defaultArchetypeFor); console.log(state.dataVersion); ' "$HTML_FILE" 2>&1) || true if [ "$DV_RESULT" = "3" ]; then pass "dataVersion bumpes til 3 (kjede v?→v3)" else fail "dataVersion ble ikke bumpet til 3 (got '$DV_RESULT')" fi # ---- 7. v2→v3 produserer artifacts uten å miste reports ---- V3_SHAPE=$(node -e ' const fs = require("fs"); const html = fs.readFileSync(process.argv[1], "utf8"); const begin = html.indexOf("// === V2_FOUNDATION_BEGIN ==="); const end = html.indexOf("// === V2_FOUNDATION_END ==="); const block = html.substring(begin, end + 32); const stubs = ` const window = {}; function escapeHtml(s) { return String(s == null ? "" : s); } function escapeAttr(s) { return escapeHtml(s); } const CATALOG = { commands: [ { id: "classify", report_archetype: "aiact" } ]}; `; const api = (new Function(stubs + block + "\nreturn { migrateDataVersion, defaultArchetypeFor };"))(); // Bygg en v2-state med ett prosjekt og én report som har parsed-data const state = { schemaVersion: 1, dataVersion: 2, projects: [{ id: "p1", name: "Test", createdAt: "2026-05-15T00:00:00.000Z", reports: { classify: { raw_markdown: "# klassifisering", parsed: { risk_level: "minimal", verdict: "go", keyStats: [] } } } }] }; api.migrateDataVersion(state, api.defaultArchetypeFor); const p = state.projects[0]; const hasReports = !!(p.reports && p.reports.classify); const hasArtifacts = !!(p.artifacts && p.artifacts.classify); const a = p.artifacts && p.artifacts.classify; const shapeOk = a && a.commandId === "classify" && a.raw_markdown === "# klassifisering" && a.parsed && a.parsed.risk_level === "minimal" && a.verdict === "go" && Array.isArray(a.keyStats) && typeof a.importedAt === "string" && typeof a.updatedAt === "string"; console.log(JSON.stringify({ dataVersion: state.dataVersion, hasReports, hasArtifacts, shapeOk })); ' "$HTML_FILE" 2>&1) || true if echo "$V3_SHAPE" | grep -q '"dataVersion":3'; then pass "v2→v3 setter dataVersion=3" else fail "v2→v3 setter ikke dataVersion=3 ($V3_SHAPE)" fi if echo "$V3_SHAPE" | grep -q '"hasReports":true'; then pass "v2→v3 bevarer project.reports (bakover-kompat v1.15.0)" else fail "v2→v3 mistet project.reports ($V3_SHAPE)" fi if echo "$V3_SHAPE" | grep -q '"hasArtifacts":true'; then pass "v2→v3 bygger project.artifacts" else fail "v2→v3 bygde ikke project.artifacts ($V3_SHAPE)" fi if echo "$V3_SHAPE" | grep -q '"shapeOk":true'; then pass "v2→v3 artifact-shape ({commandId, raw_markdown, parsed, verdict, keyStats, importedAt, updatedAt})" else fail "v2→v3 artifact-shape mismatch ($V3_SHAPE)" fi # ---- 8. v2→v3 kant-case-tester (sesjon 4) ---- # Tomt prosjekt, manglende reports, blandet state, idempotens-mutasjon. EDGE_RESULT=$(node -e ' const fs = require("fs"); const html = fs.readFileSync(process.argv[1], "utf8"); const begin = html.indexOf("// === V2_FOUNDATION_BEGIN ==="); const end = html.indexOf("// === V2_FOUNDATION_END ==="); const block = html.substring(begin, end + 32); const stubs = ` const window = {}; function escapeHtml(s) { return String(s == null ? "" : s); } function escapeAttr(s) { return escapeHtml(s); } const CATALOG = { commands: [ { id: "classify", report_archetype: "aiact" }, { id: "ros", report_archetype: "matrix-risk" }, { id: "cost", report_archetype: "cost-distribution" }, { id: "summary", report_archetype: "verdict" } ]}; `; const api = (new Function(stubs + block + "\nreturn { migrateDataVersion, defaultArchetypeFor };"))(); function emit(key, ok, info) { console.log("EDGE\t" + key + "\t" + (ok ? "PASS" : "FAIL") + "\t" + (info || "")); } // Case A: tomt prosjekt (reports={}). (function () { const state = { schemaVersion: 1, dataVersion: 2, projects: [{ id: "pA", name: "Empty", createdAt: "2026-05-15T00:00:00.000Z", reports: {} }] }; api.migrateDataVersion(state, api.defaultArchetypeFor); const p = state.projects[0]; const ok = p.artifacts !== undefined && typeof p.artifacts === "object" && Object.keys(p.artifacts).length === 0 && state.dataVersion === 3; emit("empty-project", ok, "artifacts=" + JSON.stringify(p.artifacts) + " dv=" + state.dataVersion); })(); // Case B: prosjekt uten reports-felt i det hele tatt. (function () { const state = { schemaVersion: 1, dataVersion: 2, projects: [{ id: "pB", name: "NoReports", createdAt: "2026-05-15T00:00:00.000Z" }] }; let threw = false; try { api.migrateDataVersion(state, api.defaultArchetypeFor); } catch (e) { threw = true; } const p = state.projects[0]; const ok = !threw && p.artifacts !== undefined && typeof p.artifacts === "object" && Object.keys(p.artifacts).length === 0; emit("no-reports-field", ok, "threw=" + threw + " artifacts=" + JSON.stringify(p.artifacts)); })(); // Case C: blandet state — artifacts har 2 entries fra før, reports har 5. // Migrering skal legge til de 3 manglende uten å overskrive eksisterende. (function () { const preNow = "2026-04-01T00:00:00.000Z"; const state = { schemaVersion: 1, dataVersion: 2, projects: [{ id: "pC", name: "Mixed", createdAt: preNow, artifacts: { classify: { commandId: "classify", raw_markdown: "EXISTING_CLASSIFY", parsed: { risk_level: "minimal" }, verdict: "go", keyStats: [], importedAt: preNow, updatedAt: preNow, _preExisting: true }, ros: { commandId: "ros", raw_markdown: "EXISTING_ROS", parsed: { threats: [] }, verdict: "approved", keyStats: [], importedAt: preNow, updatedAt: preNow, _preExisting: true } }, reports: { classify: { raw_markdown: "NEW_CLASSIFY", parsed: { risk_level: "high" } }, ros: { raw_markdown: "NEW_ROS", parsed: { threats: [{ id: "T1" }] } }, cost: { raw_markdown: "NEW_COST", parsed: { p50: 1000 } }, summary: { raw_markdown: "NEW_SUMMARY", parsed: { verdict: "go" } } } }] }; api.migrateDataVersion(state, api.defaultArchetypeFor); const p = state.projects[0]; const cls = p.artifacts.classify; const ros = p.artifacts.ros; const cost = p.artifacts.cost; const summary = p.artifacts.summary; // Eksisterende artifacts bevart med _preExisting-flag og opprinnelig raw_markdown. const classifyPreserved = cls && cls._preExisting === true && cls.raw_markdown === "EXISTING_CLASSIFY"; const rosPreserved = ros && ros._preExisting === true && ros.raw_markdown === "EXISTING_ROS"; // Nye artifacts bygget fra reports. const costAdded = cost && cost.commandId === "cost" && cost.raw_markdown === "NEW_COST"; const summaryAdded = summary && summary.commandId === "summary" && summary.raw_markdown === "NEW_SUMMARY"; const ok = classifyPreserved && rosPreserved && costAdded && summaryAdded; emit("mixed-state-merge", ok, "cls_preserved=" + !!classifyPreserved + " ros_preserved=" + !!rosPreserved + " cost_added=" + !!costAdded + " summary_added=" + !!summaryAdded); })(); // Case D: idempotens etter mutasjon — sett _touched=true på en artifact, kjør // migrasjon på nytt, sjekk at flagget er bevart (ikke overskrevet). (function () { const state = { schemaVersion: 1, dataVersion: 2, projects: [{ id: "pD", name: "Idempotent", createdAt: "2026-04-01T00:00:00.000Z", reports: { classify: { raw_markdown: "# klassifisering", parsed: { risk_level: "minimal" } } } }] }; // Første migrasjon bygger artifact. api.migrateDataVersion(state, api.defaultArchetypeFor); const p = state.projects[0]; p.artifacts.classify._touched = true; p.artifacts.classify.raw_markdown = "MUTATED_AFTER_MIGRATION"; // Re-migrer — idempotent skal ikke overskrive. api.migrateDataVersion(state, api.defaultArchetypeFor); const a = state.projects[0].artifacts.classify; const ok = a._touched === true && a.raw_markdown === "MUTATED_AFTER_MIGRATION"; emit("idempotent-after-mutation", ok, "_touched=" + a._touched + " raw=" + JSON.stringify(a.raw_markdown)); })(); // Case E: dataVersion=3 fra før — migrasjon er no-op. (function () { const state = { schemaVersion: 1, dataVersion: 3, projects: [{ id: "pE", name: "AlreadyV3", createdAt: "2026-04-01T00:00:00.000Z", artifacts: { cost: { commandId: "cost", raw_markdown: "EXISTING", parsed: { p50: 1 }, verdict: "go", keyStats: [], importedAt: "x", updatedAt: "x" } } }] }; const beforeJson = JSON.stringify(state); api.migrateDataVersion(state, api.defaultArchetypeFor); const afterJson = JSON.stringify(state); emit("already-v3-noop", beforeJson === afterJson, "diff=" + (beforeJson === afterJson ? "none" : "changed")); })(); ' "$HTML_FILE" 2>&1) || true # Parse EDGE-resultater while IFS=$'\t' read -r tag key status info; do [ "$tag" = "EDGE" ] || continue case "$key" in empty-project) desc="v3 kant-case: tomt prosjekt (reports={}) → artifacts={} uten å feile" ;; no-reports-field) desc="v3 kant-case: prosjekt uten reports-felt → artifacts={} opprettet" ;; mixed-state-merge) desc="v3 kant-case: blandet state — eksisterende artifacts bevart, nye lagt til" ;; idempotent-after-mutation) desc="v3 kant-case: idempotens etter mutasjon — _touched-flag bevart" ;; already-v3-noop) desc="v3 kant-case: state allerede v3 → migrasjon er no-op" ;; *) desc="v3 kant-case: $key" ;; esac if [ "$status" = "PASS" ]; then pass "$desc" else fail "$desc ($info)" fi done <<< "$EDGE_RESULT" print_summary