Ctrl K
ComunidadGuías…

test/dashboard-error-envelope.test.js

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// #257 close-out: a failed dashboard request must be distinguishable from a
// payload that merely lacks a field — on the wire AND in the panel.
//
// Before this file, `success === false` was an unreachable branch for every
// HTTP error: 53 non-2xx `json()` envelopes in src/dashboard/api.js carried no
// `success` key, and src/dashboard/index.html's `_apiRaw` handed the raw error
// body to the loaders. The loaders that did check `success === false` never
// fired on a 404/500, so they painted an empty panel over the last good state.
//
// Four layers are pinned here, each of which fails if the corresponding fix is
// reverted:
//   1. `json()` stamps `success:false` on non-2xx envelopes only — 2xx bodies
//      keep their exact shape.
//   2. The same property end-to-end through `handleDashboardApi` on real routes.
//   3. Both skins' fetch wrappers force `success:false` on a non-2xx response,
//      including a non-JSON error body.
//   4. The shipped loaders, run for real against a failing `api()`, must leave
//      the DOM/state they own untouched — and must still render a successful
//      payload, so a loader that "passes" by doing nothing cannot hide here.

import { afterEach, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';

import { config } from '../src/config.js';
import { configureBindHost, _resetLockoutForTests } from '../src/auth.js';
import { handleDashboardApi } from '../src/dashboard/api.js';
import { _resetRuntimeConfigForTests } from '../src/runtime-config.js';

const read = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8');
const API_SRC = read('src/dashboard/api.js');
const MAIN_HTML = read('src/dashboard/index.html');
const SKETCH_HTML = read('src/dashboard/index-sketch.html');

const originalDashboardPassword = config.dashboardPassword;
const originalApiKey = config.apiKey;
const originalAllowNoAuth = process.env.DASHBOARD_ALLOW_NO_AUTH;

afterEach(() => {
  _resetRuntimeConfigForTests();
  _resetLockoutForTests();
  config.dashboardPassword = originalDashboardPassword;
  config.apiKey = originalApiKey;
  if (originalAllowNoAuth === undefined) delete process.env.DASHBOARD_ALLOW_NO_AUTH;
  else process.env.DASHBOARD_ALLOW_NO_AUTH = originalAllowNoAuth;
  configureBindHost('0.0.0.0');
});

const FAILED = { success: false, error: 'Dashboard API: GET /x not found' };

// ── source extraction ───────────────────────────────────────────────────────
// Pull a method body out of a dashboard source file so the SHIPPED code runs.
// Brace/quote aware: template literals and comments inside these bodies contain
// braces of their own, and a naive counter stops in the wrong place.
function extractMethod(src, signature) {
  const at = src.indexOf(`\n  ${signature}`);
  assert.notEqual(at, -1, `missing \`${signature}\` in the dashboard source`);
  const open = src.indexOf('{', at + signature.length - 1);
  assert.notEqual(open, -1, `no body for ${signature}`);
  let depth = 0;
  for (let i = open; i < src.length; i++) {
    const c = src[i];
    if (c === '/' && src[i + 1] === '/') { i = src.indexOf('\n', i); if (i < 0) break; continue; }
    if (c === '/' && src[i + 1] === '*') { i = src.indexOf('*/', i); if (i < 0) break; i += 1; continue; }
    if (c === "'" || c === '"') {
      i += 1;
      while (i < src.length && src[i] !== c) { if (src[i] === '\\') i += 1; i += 1; }
      continue;
    }
    if (c === '`') {
      i += 1;
      while (i < src.length && src[i] !== '`') {
        if (src[i] === '\\') { i += 2; continue; }
        if (src[i] === '$' && src[i + 1] === '{') {
          // Balanced `${ … }` inside a template is a nested expression.
          let d = 0;
          i += 1;
          while (i < src.length) {
            if (src[i] === '{') d += 1;
            else if (src[i] === '}') { d -= 1; if (d === 0) { i += 1; break; } }
            i += 1;
          }
          continue;
        }
        i += 1;
      }
      continue;
    }
    if (c === '{') depth += 1;
    else if (c === '}' && --depth === 0) return src.slice(open, i + 1);
  }
  throw new Error(`could not delimit the body of ${signature}`);
}

function extractTopLevelFunction(src, signature) {
  const at = src.indexOf(`function ${signature}(`);
  assert.notEqual(at, -1, `missing \`function ${signature}(\` in api.js`);
  const open = src.indexOf('{', at + signature.length);
  let depth = 0;
  for (let i = open; i < src.length; i++) {
    if (src[i] === '{') depth += 1;
    else if (src[i] === '}' && --depth === 0) return src.slice(at, i + 1);
  }
  throw new Error(`could not delimit function ${signature}`);
}

const I18nStub = { t: (key, params) => (params ? `${key}:${JSON.stringify(params)}` : key) };
const localStorageStub = { getItem: () => null, setItem() {}, removeItem() {} };

function instantiate(src, signature, doc) {
  const body = extractMethod(src, signature);
  const stripped = signature.replace(/^async\s+/, '');
  const name = stripped.slice(0, stripped.indexOf('('));
  const asyncKw = /^async\s/.test(signature) ? 'async ' : '';
  // eslint-disable-next-line no-new-func -- executing the shipped source is the point
  return new Function('document', 'I18n', 'localStorage', `return ${asyncKw}function ${name}() ${body};`)(
    doc, I18nStub, localStorageStub,
  );
}

// ── DOM / App stubs ─────────────────────────────────────────────────────────
function makeEl(id) {
  const target = {
    id,
    __writes: [],
    style: {},
    dataset: {},
    classList: { add() {}, remove() {}, toggle() {}, contains: () => true },
    appendChild() {},
    click() {},
    remove() {},
  };
  return new Proxy(target, {
    set(t, key, value) {
      if (key !== '__writes') t.__writes.push(String(key));
      t[key] = value;
      return true;
    },
  });
}

function makeDom(existingIds = null) {
  const els = new Map();
  const getElementById = (id) => {
    if (existingIds && !existingIds.includes(id)) return null;
    if (!els.has(id)) els.set(id, makeEl(id));
    return els.get(id);
  };
  return {
    els,
    el: getElementById,
    doc: {
      cookie: '',
      getElementById,
      querySelector: () => makeEl('query'),
      createElement: (tag) => makeEl(tag),
      body: { appendChild() {} },
    },
  };
}

function appStub(apiResult, extra = {}) {
  const self = {
    api: async () => apiResult,
    toasts: [],
    toast(message, type) { self.toasts.push([message, type]); },
    esc: (s) => String(s === null || s === undefined ? '' : s).replace(/[&<>"']/g, (c) => c),
    escJsAttr: (s) => String(s === null || s === undefined ? '' : s),
    escAttr: (s) => String(s === null || s === undefined ? '' : s),
    systemPromptDomId: (k) => `sp-${encodeURIComponent(String(k)).replace(/%/g, '_')}`,
    _helpTip: () => '',
    metric: () => '',
    _animateNumber: () => {},
    enhanceControls: () => {},
    accountsListUrl: () => '/accounts',
    _dropStaleSelection: () => false,
    renderTablePagination: () => {},
    downloadAccountsJson: () => {},
    poll: () => {},
    ...extra,
  };
  return self;
}

async function runLoader(src, signature, self, dom) {
  const fn = instantiate(src, signature, dom.doc);
  return fn.call(self);
}

// ── 1. json() stamps non-2xx envelopes only ─────────────────────────────────
describe('#257 the dashboard json() helper stamps every non-2xx envelope', () => {
  function runJson(status, body) {
    const jsonFn = new Function(`return (${extractTopLevelFunction(API_SRC, 'json')});`)();
    let sent = null;
    const res = { _dashboardCorsOrigin: '', writeHead() {}, end(chunk) { sent = chunk; } };
    jsonFn(res, status, body);
    return sent === null ? undefined : JSON.parse(sent);
  }

  it('adds success:false for every error status class the dashboard uses', () => {
    for (const status of [400, 401, 403, 404, 409, 429, 500, 503]) {
      const out = runJson(status, { error: 'ERR_X' });
      assert.equal(out.success, false, `status ${status} must carry success:false`);
      assert.equal(out.error, 'ERR_X', `status ${status} must keep the original error field`);
    }
  });

  it('leaves 2xx bodies byte-identical — including bodies with no success key', () => {
    assert.deepEqual(runJson(200, { flags: { nativeToolCall: true } }), { flags: { nativeToolCall: true } });
    assert.deepEqual(runJson(200, { success: true, cleaned: 2 }), { success: true, cleaned: 2 });
    assert.equal(runJson(204, ''), '');
    // A non-2xx body that already declares success keeps its own value.
    assert.deepEqual(runJson(409, { success: true, odd: 1 }), { success: true, odd: 1 });
  });

  it('never turns a non-object body into an envelope', () => {
    assert.equal(runJson(500, 'boom'), 'boom');
    assert.deepEqual(runJson(500, [1, 2]), [1, 2]);
    assert.equal(runJson(500, null), null);
  });
});

// ── 2. the property survives the real handler ───────────────────────────────
describe('#257 real dashboard error routes answer with success:false', () => {
  function fakeRes() {
    return {
      statusCode: 0,
      body: '',
      writeHead(status) { this.statusCode = status; },
      end(chunk) { this.body += chunk ? String(chunk) : ''; },
      json() { return this.body ? JSON.parse(this.body) : null; },
    };
  }
  const localReq = (path, headers = {}) => ({
    url: `/dashboard/api${path}`, headers, socket: { remoteAddress: '127.0.0.1' },
  });

  const authed = { 'x-dashboard-password': 'dash-secret' };

  it('401 gate, 400 validation, 403 loopback-only and the 404 fallback all carry it', async () => {
    _resetRuntimeConfigForTests();
    config.dashboardPassword = 'dash-secret';
    config.apiKey = '';
    configureBindHost('0.0.0.0');

    const cases = [
      ['401', 'GET', '/cache', {}, {}],
      ['400', 'POST', '/accounts', {}, authed],
      ['403', 'GET', '/accounts/import-local', {}, authed],
      ['404', 'GET', '/definitely-not-a-dashboard-route', {}, authed],
    ];
    for (const [label, method, path, body, headers] of cases) {
      const res = fakeRes();
      await handleDashboardApi(method, path, body, localReq(path, headers), res);
      assert.equal(res.statusCode, Number(label), `${method} ${path} should answer ${label}`);
      const payload = res.json();
      assert.equal(payload.success, false, `${method} ${path} (${label}) must carry success:false`);
      assert.ok(payload.error, `${method} ${path} (${label}) must keep its error field`);
    }
  });

  it('a 200 route that never had a success key still does not gain one', async () => {
    _resetRuntimeConfigForTests();
    config.dashboardPassword = 'dash-secret';
    config.apiKey = '';
    configureBindHost('0.0.0.0');

    const res = fakeRes();
    await handleDashboardApi('GET', '/experimental', {}, localReq('/experimental', authed), res);
    assert.equal(res.statusCode, 200);
    const payload = res.json();
    assert.ok(!Object.prototype.hasOwnProperty.call(payload, 'success'),
      '2xx bodies must not be reshaped by the error-envelope fix');
    assert.ok(payload.flags && payload.tunables, 'and must still carry their real payload');
  });
});

// ── 3. both skins synthesize the envelope for non-2xx ───────────────────────
describe('#257 the client fetch wrappers force success:false on non-2xx', () => {
  function withFetch(status, body) {
    const r = {
      status,
      ok: status >= 200 && status < 300,
      json: async () => body,
    };
    return { fetch: async () => r };
  }

  async function runApiRaw(html, signature, fetchImpl, extraSelf = {}) {
    const body = extractMethod(html, signature);
    const stripped = signature.replace(/^async\s+/, '');
    const name = stripped.slice(0, stripped.indexOf('('));
    const dom = makeDom(['login-overlay', 'login-password', 'login-btn']);
    // eslint-disable-next-line no-new-func -- executing the shipped source is the point
    const fn = new Function('document', 'I18n', 'localStorage', 'fetch',
      `return async function ${name}(method, path, body) ${body};`)(dom.doc, I18nStub, localStorageStub, fetchImpl);
    // The shipped _apiRaw routes the toast text through translateError so an
    // unmapped ERR_* code never reaches the user; the stub keeps the raw text
    // so the assertions below still see the server's message.
    const self = { _authEpoch: 0, toasts: [], toast(m, t) { self.toasts.push([m, t]); }, password: null, translateError(code, fallback) { return code || fallback; }, ...extraSelf };
    return { out: await fn.call(self, 'GET', '/x', null), self };
  }

  for (const [label, html, signature] of [
    ['index.html', MAIN_HTML, 'async _apiRaw(method, path, body)'],
    ['index-sketch.html', SKETCH_HTML, 'async api(method, path, body)'],
  ]) {
    it(`${label}: a 404 body gains success:false and keeps its error`, async () => {
      const { out } = await runApiRaw(html, signature, async () => ({
        status: 404, ok: false, json: async () => ({ error: 'Dashboard API: GET /x not found' }),
      }));
      assert.equal(out.success, false, `${label} must force success:false on a 404`);
      assert.equal(out.error, 'Dashboard API: GET /x not found', `${label} must keep the server error`);
    });

    it(`${label}: a non-JSON error body still yields an envelope`, async () => {
      const { out } = await runApiRaw(html, signature, async () => ({
        status: 500, ok: false, json: async () => { throw new Error('not json'); },
      }));
      assert.equal(out.success, false, `${label} must synthesize success:false when the body is unparseable`);
    });

    it(`${label}: a 200 body passes through untouched`, async () => {
      const { out } = await runApiRaw(html, signature, async () => ({
        status: 200, ok: true, json: async () => ({ flags: { nativeToolCall: true } }),
      }));
      assert.deepEqual(out, { flags: { nativeToolCall: true } }, `${label} must not reshape 2xx bodies`);
    });
  }
});

// ── 4. loaders keep the last good panel when the read fails ─────────────────
describe('#257 panel loaders keep the last good state on a failed read', () => {
  it('loadAccounts no longer repaints over a valid table on a failed read', async () => {
    let painted = 0;
    const stub = (result) => appStub(result, {
      loadDrought: () => {},
      renderAccountsSkeleton: () => {},
      setAccountsLoadStatus: () => {},
      renderAccountsPagination: () => {},
      renderAccountsKpis: () => {},
      renderAccountsFilterState: () => {},
      renderAccountsTable: () => { painted += 1; },
      applyAccountsSortState: () => {},
      applyAccountsDensity: () => {},
      startAccountsClock: () => {},
    });

    await runLoader(MAIN_HTML, 'async loadAccounts()', stub(FAILED), makeDom());
    assert.equal(painted, 0, 'a failed read must not paint an empty table over a valid pool');

    await runLoader(MAIN_HTML, 'async loadAccounts()', stub({ accounts: [], stats: {}, total: 0 }), makeDom());
    assert.equal(painted, 1, 'control: a successful read must still paint, or this test proves nothing');
  });

  it('loadModels keeps the previous catalog and mode on failure', async () => {
    const self = appStub(FAILED);
    self.allModels = ['LAST-MODEL'];
    self.modelAccessConfig = { mode: 'allowlist', list: ['m'], defaultModel: 'm' };

    const dom = makeDom([]);
    await runLoader(MAIN_HTML, 'async loadModels()', self, dom);
    assert.deepEqual(self.allModels, ['LAST-MODEL'], 'a failed read must not empty the model catalog');
    assert.equal(self.modelAccessConfig.mode, 'allowlist', 'and must not reset the access mode to "all"');
  });

  const cases = [
    {
      name: 'loadExperimental',
      signature: 'async loadExperimental()',
      existing: ['exp-cascade-reuse', 'exp-pool-cards', 'skin-select', 'exp-drought-threshold'],
      seed: (dom, self) => { dom.el('exp-pool-cards').innerHTML = 'LAST-GOOD'; dom.el('exp-cascade-reuse').checked = true; },
      read: (dom) => [dom.el('exp-pool-cards').innerHTML, dom.el('exp-cascade-reuse').checked],
      expect: ['LAST-GOOD', true],
      ok: { flags: { cascadeConversationReuse: true }, conversationPool: { hits: 3 }, tunables: {} },
      readOk: (dom) => dom.el('exp-pool-cards').innerHTML,
      extraSelf: { loadClineCompat: () => {}, loadCcCompat: () => {} },
    },
    {
      name: 'loadSystemPrompts',
      signature: 'async loadSystemPrompts()',
      existing: ['system-prompts-editor'],
      seed: (dom, self) => { dom.el('system-prompts-editor').innerHTML = 'LAST-GOOD'; self._systemPromptOriginals = { keep: 'me' }; },
      read: (dom, self) => [dom.el('system-prompts-editor').innerHTML, JSON.stringify(self._systemPromptOriginals)],
      expect: ['LAST-GOOD', '{"keep":"me"}'],
      ok: { prompts: { toolReinforcement: 'body text' } },
      readOk: (dom) => dom.el('system-prompts-editor').innerHTML,
    },
    {
      name: 'renderRuntimeEnvStatus',
      signature: 'async renderRuntimeEnvStatus()',
      existing: ['set-runtime-env-body'],
      seed: (dom) => { dom.el('set-runtime-env-body').innerHTML = 'LAST-GOOD'; },
      read: (dom) => [dom.el('set-runtime-env-body').innerHTML],
      expect: ['LAST-GOOD'],
      ok: { switches: { devinConnect: { value: true, source: 'config' } } },
      readOk: (dom) => dom.el('set-runtime-env-body').innerHTML,
    },
    {
      name: 'renderSecuritySettings',
      signature: 'async renderSecuritySettings()',
      existing: ['set-security-body'],
      seed: (dom) => { dom.el('set-security-body').innerHTML = 'LAST-GOOD'; },
      read: (dom) => [dom.el('set-security-body').innerHTML],
      expect: ['LAST-GOOD'],
      ok: { tunables: { emailLockThreshold: 4 } },
      readOk: (dom) => dom.el('set-security-body').innerHTML,
    },
    {
      name: 'renderBreakerSettings',
      signature: 'async renderBreakerSettings()',
      existing: ['set-breaker-body'],
      seed: (dom) => { dom.el('set-breaker-body').innerHTML = 'LAST-GOOD'; },
      read: (dom) => [dom.el('set-breaker-body').innerHTML],
      expect: ['LAST-GOOD'],
      ok: { knobs: { errorStreakThreshold: { value: 4 } } },
      readOk: (dom) => dom.el('set-breaker-body').innerHTML,
    },
    {
      name: 'loadCredentials',
      signature: 'async loadCredentials()',
      existing: ['credentials-apikey-source', 'credentials-apikey-masked', 'credentials-dashboardpw-source', 'credentials-dashboardpw-status'],
      seed: (dom) => {
        for (const id of ['credentials-apikey-source', 'credentials-apikey-masked', 'credentials-dashboardpw-source', 'credentials-dashboardpw-status']) {
          dom.el(id).textContent = 'LAST-GOOD';
        }
      },
      read: (dom) => ['credentials-apikey-source', 'credentials-apikey-masked', 'credentials-dashboardpw-source', 'credentials-dashboardpw-status']
        .map((id) => dom.el(id).textContent),
      expect: ['LAST-GOOD', 'LAST-GOOD', 'LAST-GOOD', 'LAST-GOOD'],
      ok: { apiKeySource: 'env', apiKey_masked: 'sk-****', dashboardPasswordSource: 'env', dashboardPasswordSet: true },
      readOk: (dom) => dom.el('credentials-apikey-source').textContent,
    },
    {
      name: 'loadClineCompat',
      signature: 'async loadClineCompat()',
      existing: ['cline-compat-badge', 'cline-compat-repairs'],
      seed: (dom) => { dom.el('cline-compat-badge').style.display = 'inline'; },
      read: (dom) => [dom.el('cline-compat-badge').style.display],
      expect: ['inline'],
      ok: { enabled: true, stats: { argRepairs: 3 } },
      readOk: (dom) => dom.el('cline-compat-badge').style.display,
    },
    {
      name: 'loadCcCompat',
      signature: 'async loadCcCompat()',
      existing: ['cc-compat-badge', 'cc-compat-schema'],
      seed: (dom) => { dom.el('cc-compat-badge').style.display = 'inline'; },
      read: (dom) => [dom.el('cc-compat-badge').style.display],
      expect: ['inline'],
      ok: { enabled: true, stats: { schemaNormalized: 3 } },
      readOk: (dom) => dom.el('cc-compat-badge').style.display,
    },
  ];

  for (const spec of cases) {
    it(`${spec.name} leaves its panel alone when the read fails`, async () => {
      const dom = makeDom(spec.existing);
      const self = appStub(FAILED, spec.extraSelf || {});
      spec.seed(dom, self);

      await runLoader(MAIN_HTML, spec.signature, self, dom);
      assert.deepEqual(spec.read(dom, self), spec.expect, `${spec.name} repainted on a failed read`);

      // Control: the same loader must still write when the read succeeds.
      const okDom = makeDom(spec.existing);
      const okSelf = appStub(spec.ok, spec.extraSelf || {});
      if (spec.seed) spec.seed(okDom, okSelf);
      await runLoader(MAIN_HTML, spec.signature, okSelf, okDom);
      assert.notDeepEqual(spec.readOk(okDom, okSelf), spec.expect[0],
        `${spec.name} no longer renders a successful payload — this guard would be vacuous`);
    });
  }

  it('loadSettings keeps the last prefs on failure but still drives its sub-panels', async () => {
    const dom = makeDom(['set-oauth-skip-chooser']);
    dom.el('set-oauth-skip-chooser').checked = true;
    const called = [];
    const self = appStub(FAILED, {
      renderSecuritySettings: () => called.push('security'),
      renderRuntimeEnvStatus: () => called.push('runtimeEnv'),
      loadCredentials: () => called.push('credentials'),
      loadSystemPrompts: () => called.push('prompts'),
      renderBreakerSettings: () => called.push('breaker'),
    });
    self._prefs = { keep: true };

    await runLoader(MAIN_HTML, 'async loadSettings()', self, dom);
    assert.deepEqual(self._prefs, { keep: true }, 'a failed prefs read must not reset the toggles');
    assert.equal(dom.el('set-oauth-skip-chooser').checked, true, 'nor repaint the checkbox');
    assert.deepEqual(called.sort(), ['breaker', 'credentials', 'prompts', 'runtimeEnv', 'security'],
      'the sub-panels own their own failure handling and must still be driven');
  });

  it('exportAccounts does not download an empty pool on a failed read', async () => {
    const dom = makeDom([]);
    const self = appStub(FAILED);
    await runLoader(MAIN_HTML, 'async exportAccounts()', self, dom);
    assert.deepEqual(self.toasts, [], 'a failed export must not toast "exported 0 accounts" as success');

    const okDom = makeDom([]);
    const okSelf = appStub({ accounts: [] });
    await runLoader(MAIN_HTML, 'async exportAccounts()', okSelf, okDom);
    assert.ok(okSelf.toasts.some(([, type]) => type === 'success'),
      'control: a successful export still reports success');
  });
});

describe('#257 the sketch skin keeps its panels too', () => {
  const cases = [
    {
      name: 'loadExperimental',
      signature: 'async loadExperimental()',
      existing: ['exp-cascade-reuse', 'exp-pool-cards', 'skin-select'],
      seed: (dom) => { dom.el('exp-pool-cards').innerHTML = 'LAST-GOOD'; dom.el('exp-cascade-reuse').checked = true; },
      read: (dom) => [dom.el('exp-pool-cards').innerHTML, dom.el('exp-cascade-reuse').checked],
      expect: ['LAST-GOOD', true],
      ok: { flags: { cascadeConversationReuse: true }, conversationPool: { hits: 1 } },
      readOk: (dom) => dom.el('exp-pool-cards').innerHTML,
      extraSelf: { loadCredentials: () => {}, loadSystemPrompts: () => {} },
    },
    {
      name: 'loadSystemPrompts',
      signature: 'async loadSystemPrompts()',
      existing: ['system-prompts-editor'],
      seed: (dom) => { dom.el('system-prompts-editor').innerHTML = 'LAST-GOOD'; },
      read: (dom) => [dom.el('system-prompts-editor').innerHTML],
      expect: ['LAST-GOOD'],
      ok: { prompts: { toolReinforcement: 'body text' } },
      readOk: (dom) => dom.el('system-prompts-editor').innerHTML,
    },
    {
      name: 'loadCredentials',
      signature: 'async loadCredentials()',
      existing: ['credentials-apikey-source', 'credentials-apikey-masked', 'credentials-dashboardpw-source', 'credentials-dashboardpw-status'],
      seed: (dom) => { dom.el('credentials-apikey-source').textContent = 'LAST-GOOD'; },
      read: (dom) => [dom.el('credentials-apikey-source').textContent],
      expect: ['LAST-GOOD'],
      ok: { apiKeySource: 'env' },
      readOk: (dom) => dom.el('credentials-apikey-source').textContent,
    },
  ];

  for (const spec of cases) {
    it(`sketch ${spec.name} leaves its panel alone when the read fails`, async () => {
      const dom = makeDom(spec.existing);
      const self = appStub(FAILED, spec.extraSelf || {});
      spec.seed(dom, self);
      await runLoader(SKETCH_HTML, spec.signature, self, dom);
      assert.deepEqual(spec.read(dom, self), spec.expect, `sketch ${spec.name} repainted on a failed read`);

      const okDom = makeDom(spec.existing);
      const okSelf = appStub(spec.ok, spec.extraSelf || {});
      spec.seed(okDom, okSelf);
      await runLoader(SKETCH_HTML, spec.signature, okSelf, okDom);
      assert.notDeepEqual(spec.readOk(okDom, okSelf), spec.expect[0],
        `sketch ${spec.name} no longer renders a successful payload`);
    });
  }
});