From dbec7f14104a2819cac4774ce0ad62296b77db03 Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Fri, 26 Jun 2026 19:12:52 -0400 Subject: [PATCH] fix(query): align error display with existdb-openapi#71 envelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The query error envelope existdb-openapi will return going forward is #71's QueryError schema — { code, message, line, column, raw } (HTTP 400): a concise human `message`, with the verbose W3C/Java boilerplate moved to `raw`, and user-relative line/column. Current existdb-openapi releases (≤ v0.9.7) instead return a generic { error: "..." }. eXide's run path (runQueryCursor → ../existdb-openapi/api/query) consumes whichever the deployed version returns, so handle both — and only these two: the { code, description, module, value } shape some pre-#71 dev builds emit was never tagged and won't be (releases go { error } → QueryError), so it isn't carried. - runQueryCursor: factor a single queryErrorMessage() helper used by both the !response.ok branch and the 200-body guard (eXide#828). It prefers `message` (QueryError), then `error` (generic), then `raw`. - error-status-ui.js: the panel and hover dump render `message` and expose `raw`, and fall back to the plain-text formatter for the generic { error } so the detail panel is never blank. The pure formatters move to module scope (out of init) so they can be unit-tested. Tests: - test/error-status-ui-test.js (5, node --test): pins the message/raw split and the generic-{error} fallback against the real envelope samples; mutation-verified. - query_error_structured_spec.cy.js: a version-robust live case (any envelope surfaces an error pill with the cause) plus a cy.intercept case that stubs the #71 envelope to verify the structured panel + message/raw split deterministically. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AbvYwhBSNMEVmqmZozZ4KS --- cypress/e2e/query_error_structured_spec.cy.js | 71 ++++++++++++------- src/eXide.js | 49 ++++++------- src/error-status-ui.js | 61 +++++++++------- test/error-status-ui-test.js | 67 +++++++++++++++++ 4 files changed, 172 insertions(+), 76 deletions(-) create mode 100644 test/error-status-ui-test.js diff --git a/cypress/e2e/query_error_structured_spec.cy.js b/cypress/e2e/query_error_structured_spec.cy.js index 3e506d7b..111a2ccb 100644 --- a/cypress/e2e/query_error_structured_spec.cy.js +++ b/cypress/e2e/query_error_structured_spec.cy.js @@ -32,37 +32,20 @@ describe('Query error display — structured fields', () => { }) } - it('exposes structured fields in pill title and panel body', () => { - // Type error — guaranteed to come back with code, line, column, description + it('surfaces a real failing query as an error pill with the cause', () => { + // Version-robust: works whether the bed's existdb-openapi returns the + // #71 QueryError shape or the current-release generic { error }. The + // structured-panel detail and the message/raw split are asserted + // deterministically by the cy.intercept test below. setEditorContent('1 + "oops"') cy.get('#run').click() - // Wait until the error pill is set cy.get('#exide-err-pill.has-error', { timeout: 10000 }).should('exist') - - // (1) Pill carries a `title` attribute with the structured dump - cy.get('#exide-err-pill') - .should('have.attr', 'title') - .and('include', 'Code:') - .and('include', 'err:XPTY0004') - .and('include', 'Location:') - .and('include', 'line 1') - .and('include', 'Description:') - - // (2) The auto-opened panel body shows the structured rows. - cy.get('#exide-err-panel-body.ep-structured').should('exist') - cy.get('#exide-err-panel-body .ep-field-code .ep-field-value') - .should('contain.text', 'err:XPTY0004') - cy.get('#exide-err-panel-body .ep-field-loc .ep-field-value') - .should('contain.text', 'line 1') - .and('contain.text', 'column 3') - cy.get('#exide-err-panel-body .ep-field-desc .ep-field-value') - .should('contain.text', 'type error') - - // (3) Pill label is still concise — shouldn't dump the whole thing. + // The pill shows the cause, concisely. cy.get('#exide-err-pill-label') .invoke('text') .then((label) => { + expect(label.trim().length).to.be.greaterThan(0) expect(label.length).to.be.at.most(60) }) @@ -71,6 +54,46 @@ describe('Query error display — structured fields', () => { cy.get('#exide-err-pill').should('not.have.class', 'has-error') }) + it('prefers message over raw and exposes raw in the hover dump (existdb-openapi#71 envelope)', () => { + // Stub a #71 envelope so this is deterministic regardless of the bed's + // existdb-openapi version: { code, message, line, column, raw }, HTTP 400. + // Exercises the full client path: runQueryCursor's coalesce → + // editor.evalError → the structured panel. + cy.intercept('POST', '**/existdb-openapi/api/query', { + statusCode: 400, + body: { + code: 'err:XPTY0004', + message: "'xs:string(oops)' can not be an operand for +", + line: 1, + column: 3, + raw: 'It is a type error if, during the static analysis phase, an expression is found to have a static type that is not appropriate.' + } + }).as('q71') + + setEditorContent('1 + "oops"') + cy.get('#run').click() + cy.wait('@q71') + + cy.get('#exide-err-pill.has-error', { timeout: 10000 }).should('exist') + + // Panel Description shows the *concise* message, not the verbose raw boilerplate. + cy.get('#exide-err-panel-body .ep-field-desc .ep-field-value') + .should('contain.text', 'can not be an operand') + .and('not.contain.text', 'It is a type error if') + // Code and location still surface. + cy.get('#exide-err-panel-body .ep-field-code .ep-field-value').should('contain.text', 'err:XPTY0004') + cy.get('#exide-err-panel-body .ep-field-loc .ep-field-value') + .should('contain.text', 'line 1').and('contain.text', 'column 3') + // The verbose detail is preserved under Raw in the hover dump. + cy.get('#exide-err-pill') + .should('have.attr', 'title') + .and('include', 'Raw:') + .and('include', 'It is a type error if') + + cy.get('#exide-err-panel-dismiss').click() + cy.get('#exide-err-pill').should('not.have.class', 'has-error') + }) + it('falls back gracefully when the error has no structured payload', () => { // Force a non-structured error: hit an endpoint that returns HTML/text // by invoking validator-style flow. The simplest deterministic way is diff --git a/src/eXide.js b/src/eXide.js index cde25fb6..d7eec89e 100644 --- a/src/eXide.js +++ b/src/eXide.js @@ -908,6 +908,21 @@ eXide.app = (function(util) { if (timingEl) timingEl.style.display = "none"; } + // Build a display message from a query-error envelope. existdb-openapi#71 + // returns the QueryError shape { code, message, line, column, raw }; + // current releases (≤ v0.9.7) return a generic { error: "..." }. Prefer + // the concise `message`, then `error`, then the verbose `raw`; fall back + // to a serialized payload so the cause is never silently lost. + function queryErrorMessage(err, fallback) { + if (err == null) { return fallback || "Query failed."; } + if (typeof err === "string") { return err; } + var msg = err.message || err.error || err.raw + || fallback || JSON.stringify(err); + if (err.code) { msg = "[" + err.code + "] " + msg; } + if (err.line > 0) { msg = "line " + err.line + ": " + msg; } + return msg; + } + // Close previous cursor if any if (app._cursorId) { fetch("../existdb-openapi/api/query/" + app._cursorId, { method: "DELETE" }).catch(function() {}); @@ -936,27 +951,11 @@ eXide.app = (function(util) { hideCancel(); if (!response.ok) { return response.json().then(function(err) { - // existdb-openapi/cursor:eval errors come back as - // { code, description, line, column, module, value } - // (the standard XPathException → JSON shape). Older - // code paths used { error: "..." } or { message: "..." }. - // Coalesce so the user sees the real cause regardless - // of which shape the server returns; fall back to a - // serialized payload so the user can still copy/paste - // the response if all known fields are missing. - var msg = err.description || err.error || err.message - || (typeof err === "string" ? err : JSON.stringify(err)); - if (err.code) { - msg = "[" + err.code + "] " + msg; - } - if (err.line > 0) { - msg = "line " + err.line + ": " + msg; - } - // Pass the full structured error so the panel can - // surface code/line/column/module/value separately - // (request from @line-o on PR #794: the description - // alone isn't enough — need all the info). - editor.evalError(msg, !livePreview, err); + // Pass the full structured error so the panel can surface + // code/location/message/raw separately (request from + // @line-o on PR #794: the message alone isn't enough — + // need all the info). + editor.evalError(queryErrorMessage(err), !livePreview, err); }, function () { // Body wasn't JSON — try to surface whatever the // server actually said (HTTP status + body text). @@ -978,11 +977,9 @@ eXide.app = (function(util) { // success path shows neither results nor an error. Coalesce the // shapes the same way the !response.ok branch does. if (data.error || !data.cursor) { - var emsg = data.description || data.error || data.message - || "Query failed: no cursor returned."; - if (data.code) { emsg = "[" + data.code + "] " + emsg; } - if (data.line > 0) { emsg = "line " + data.line + ": " + emsg; } - editor.evalError(emsg, !livePreview, data); + editor.evalError( + queryErrorMessage(data, "Query failed: no cursor returned."), + !livePreview, data); return; } app._cursorId = data.cursor; diff --git a/src/error-status-ui.js b/src/error-status-ui.js index 937cbdb9..1044f9e9 100644 --- a/src/error-status-ui.js +++ b/src/error-status-ui.js @@ -12,7 +12,6 @@ (function () { 'use strict'; - function init() { // ── Helpers ────────────────────────────────────────────────────────────── /** * Produce a short human-readable label from eXide's raw error string. @@ -53,11 +52,12 @@ return s; } /** - * Format a structured error object (from existdb-openapi: - * { code, description, line, column, module, value }) as a multi-field - * panel body. Renders only the fields that are present so we don't - * surface noise like "module: null" or empty values. Falls back to - * formatPanelHtml(raw) when no structured data is available. + * Format a structured error object as a multi-field panel body. The query + * error envelope is existdb-openapi#71's { code, message, line, column, raw } + * (the `QueryError` schema in its api.json). Current existdb-openapi releases + * (\u2264 v0.9.7) instead return a generic { error: "..." }; for that \u2014 or any + * payload without recognizable structured fields \u2014 fall back to the + * plain-text formatter so the panel is never blank. */ function formatStructuredPanelHtml(errObj, raw) { if (!errObj) return formatPanelHtml(raw); @@ -78,16 +78,10 @@ if (errObj.column) loc += ', column ' + errObj.column; } if (loc) row('Location', loc, 'loc'); - row('Description', errObj.description, 'desc'); - if (errObj.module && errObj.module !== 'unknown' && !/^String\//.test(errObj.module)) { - // Skip synthetic module IDs from compile errors on inline source - // (e.g. "String/-3990248984871632423") \u2014 they're internal noise - // and confuse users; keep real module paths. - row('Module', errObj.module, 'module'); - } - if (errObj.value !== null && errObj.value !== undefined && errObj.value !== '') { - row('Value', errObj.value, 'value'); - } + row('Description', errObj.message, 'desc'); + // Generic { error } (or anything without structured fields): show the + // plain text rather than an empty panel. + if (!rows.length) return formatPanelHtml(errObj.error || raw); return rows.join(''); } /** @@ -105,13 +99,16 @@ if (errObj.column) l += ', column ' + errObj.column; lines.push(l); } - if (errObj.description) lines.push('Description: ' + errObj.description); - if (errObj.module) lines.push('Module: ' + errObj.module); - if (errObj.value !== null && errObj.value !== undefined && errObj.value !== '') { - lines.push('Value: ' + errObj.value); + if (errObj.message) lines.push('Description: ' + errObj.message); + // #71's `raw` is the full boilerplate behind the concise message; expose + // it in the hover dump when it adds detail beyond the message shown. + if (errObj.raw && errObj.raw !== errObj.message) { + lines.push('Raw: ' + errObj.raw); } - return lines.length ? lines.join('\n') : raw; + return lines.length ? lines.join('\n') : (errObj.error || raw); } + + function init() { // ── Element references ─────────────────────────────────────────────────── var errSource = document.getElementById('error-status'); // eXide writes here var pill = document.getElementById('exide-err-pill'); @@ -265,10 +262,22 @@ } } // end init() - // Script loads in , so defer until DOM is ready - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init); - } else { - init(); + // Script loads in , so defer until DOM is ready. Guarded so the + // module can be required in Node (no document) to unit-test the pure + // formatters exported below. + if (typeof document !== 'undefined') { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + } + + if (typeof module !== 'undefined' && module.exports) { + module.exports = { + formatStructuredPanelHtml: formatStructuredPanelHtml, + formatTitleDump: formatTitleDump, + makeShortLabel: makeShortLabel + }; } })(); diff --git a/test/error-status-ui-test.js b/test/error-status-ui-test.js new file mode 100644 index 00000000..ac658eca --- /dev/null +++ b/test/error-status-ui-test.js @@ -0,0 +1,67 @@ +/** + * Tests for the error panel formatters in src/error-status-ui.js. + * + * Covers the existdb-openapi error-envelope parity: the panel/hover dump must + * prefer the concise `message` (existdb-openapi#71) over the verbose `raw`, + * while staying compatible with the older { code, description, module, value } + * shape and the oldest { error }-only / unstructured shapes. + * + * The envelope samples below are the real responses captured from running + * `1 + "oops"` against existdb-openapi at three versions. + */ +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); +const { formatStructuredPanelHtml, formatTitleDump, makeShortLabel } = + require("../src/error-status-ui.js"); + +// existdb-openapi#71 envelope (HTTP 400): concise message + raw boilerplate. +const E71 = { + code: "err:XPTY0004", + message: "'xs:string(oops)' can not be an operand for +", + line: 1, + column: 3, + raw: "It is a type error if, during the static analysis phase, an expression is found to have a static type that is not appropriate." +}; + +// Current existdb-openapi release (≤ v0.9.7): a generic { error } string, no +// code/line/message. #71's clean QueryError shape is not yet released. +const EGENERIC = { error: "Invalid context-item: 'xs:string(oops)' can not be an operand for +" }; + +describe("error-status-ui formatters — envelope parity", () => { + + it("#71: panel shows the concise message, not the raw boilerplate", () => { + const html = formatStructuredPanelHtml(E71, ""); + assert.match(html, /err:XPTY0004/); + assert.match(html, /line 1, column 3/); + assert.match(html, /can not be an operand/); // the message + assert.doesNotMatch(html, /It is a type error if/); // boilerplate stays out of the panel + }); + + it("#71: hover dump exposes the raw boilerplate under Raw", () => { + const dump = formatTitleDump(E71, ""); + assert.match(dump, /Code:\s+err:XPTY0004/); + assert.match(dump, /Location:\s+line 1, column 3/); + assert.match(dump, /Description: .*can not be an operand/); + assert.match(dump, /Raw:\s+It is a type error if/); // verbose detail preserved + }); + + it("generic { error } (current release) falls back to plain text, never blank", () => { + const html = formatStructuredPanelHtml(EGENERIC, ""); + assert.notEqual(html, ""); // not an empty panel + assert.match(html, /can not be an operand/); // the error text is shown + const dump = formatTitleDump(EGENERIC, ""); + assert.match(dump, /can not be an operand/); + assert.doesNotMatch(dump, /Raw:/); // no raw field on this shape + }); + + it("no structured object falls back to the raw-text formatter", () => { + const html = formatStructuredPanelHtml(null, "Cannot compile xquery: boom"); + assert.match(html, /boom/); + assert.equal(formatTitleDump(null, "rawonly"), "rawonly"); + }); + + it("makeShortLabel strips boilerplate and location", () => { + const label = makeShortLabel("err:XPST0017 Call to undeclared function: local:foo [at line 3, column 5]"); + assert.equal(label, "Call to undeclared function: local:foo"); + }); +});