garrytan/gstack · error · Error

Invalid heatmap JSON. Expected object

Error message

Invalid heatmap JSON. Expected object: '{"@e1":"green","@e3":"red"}'

What it means

Thrown when opts.heatmap cannot be parsed into a plain JSON object. The value is run through JSON.parse and then type-checked: anything that is null, an array, a primitive, or syntactically invalid JSON all funnel into this single message. The expected shape is an object whose keys are refs and values are color names.

Solutions

  1. Quote the entire JSON object in the shell and use double quotes inside: `-H '{"@e1":"red","@e3":"green"}'`.
  2. Validate the string with `jq .` or `JSON.parse` in a REPL before passing it.
  3. Ensure the value parses to a plain object — not an array, not a string, not null.
  4. If building the option programmatically, use `JSON.stringify(obj)` rather than hand-concatenating.

Example fix

// before
snapshot(page, { heatmap: '@e1:red' }); // not JSON
// after
snapshot(page, { heatmap: JSON.stringify({ '@e1': 'red' }) });
Defensive patterns

Strategy: validation

Validate before calling

function asHeatmapObject(s: string): Record<string, string> {
  let parsed: unknown;
  try { parsed = JSON.parse(s); } catch { throw new Error('Invalid heatmap JSON. Expected object: {"@e1":"green"}'); }
  if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
    throw new Error('Invalid heatmap JSON. Expected object: {"@e1":"green"}');
  }
  return parsed as Record<string, string>;
}

Type guard

function isPlainObject(v: unknown): v is Record<string, unknown> {
  return typeof v === 'object' && v !== null && !Array.isArray(v);
}

Try / catch

try {
  await snapshot(session, { heatmap: userInput });
} catch (e: any) {
  if (/^Invalid heatmap JSON/.test(e.message)) {
    // surface to the user with the expected shape
    throw new Error('heatmap must be a JSON object like {"@e1":"red"}');
  } else throw e;
}

Prevention

When it happens

Trigger: Passing -H/--heatmap a string that is not valid JSON (e.g. `@e1=red`), valid JSON of the wrong type (e.g. `["@e1","red"]` or `'"green"'`), or `null`.

Common situations: Shell quoting that strips the braces (`-H {@e1:red}`); passing the heatmap as a path to a JSON file instead of the contents; typos like missing quotes around keys; copy-pasting YAML by mistake.

Related errors


AI-assisted analysis of garrytan/gstack@94993f7401 (2026-08-12). Data as JSON: /api/errors/b66fbe55f3e0d573. Report an issue: GitHub.

Appendix: source

Thrown at browse/src/snapshot.ts:495

    const VALID_COLORS = new Set(['green', 'yellow', 'red', 'blue', 'orange', 'gray']);
    const COLOR_MAP: Record<string, { border: string; bg: string }> = {
      green:  { border: '#00b400', bg: 'rgba(0,180,0,0.15)' },
      yellow: { border: '#ffb400', bg: 'rgba(255,180,0,0.15)' },
      red:    { border: '#ff0000', bg: 'rgba(255,0,0,0.15)' },
      blue:   { border: '#0066ff', bg: 'rgba(0,102,255,0.15)' },
      orange: { border: '#ff6600', bg: 'rgba(255,102,0,0.15)' },
      gray:   { border: '#888888', bg: 'rgba(136,136,136,0.15)' },
    };

    let colorAssignments: Record<string, string>;
    try {
      const parsed = JSON.parse(opts.heatmap);
      if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
        throw new Error('not an object');
      }
      colorAssignments = parsed;
    } catch {
      throw new Error('Invalid heatmap JSON. Expected object: \'{"@e1":"green","@e3":"red"}\'');
    }

    // Validate colors
    for (const [ref, color] of Object.entries(colorAssignments)) {
      if (!VALID_COLORS.has(color)) {
        throw new Error(`Invalid heatmap color "${color}" for ${ref}. Valid: ${[...VALID_COLORS].join(', ')}`);
      }
    }

    try {
      const boxes: Array<{ ref: string; box: { x: number; y: number; width: number; height: number }; color: string }> = [];
      for (const [refKey, color] of Object.entries(colorAssignments)) {
        const cleanRef = refKey.startsWith('@') ? refKey.slice(1) : refKey;
        const entry = refMap.get(cleanRef);
        if (!entry) continue; // Skip refs not found on page
        try {
          const box = await entry.locator.boundingBox({ timeout: 1000 });
          if (box) {

View on GitHub (pinned to 94993f7401)