On this page

Writing

Mirroring a live Angular quiz form

The IITM assessment portal shows one question at a time. There are numbered chips across the top; clicking one swaps the question below. To review a fifteen-question quiz you click through fifteen chips, waiting for autosave between each.

The obvious fix is to clone each question’s HTML and stack the clones. That fails immediately. Angular binds answer state to the live DOM through event listeners on the live element. A cloned node looks identical but is inert. Input into a clone doesn’t reach Angular and doesn’t save.

The full source is on GitHub as civiks/unfold-iitm.

Display and state are separable

The portal does two separable things with each question: it displays it, and it owns the answer state. These don’t have to be the same element.

The extension captures each question’s rendered HTML for display only, then keeps the single live Angular component mounted off-screen. When you interact with a displayed question (selecting an option, typing an answer), the extension replays that action against the live component:

interaction on a snapshot block
  → navigate the live component to that question
  → replay the action on the live control
  → flush autosave (navigate away)
  → re-snapshot the block

The snapshots are read-only. The live component is the only writable surface.

Mapping the selectors

The portal is a : no source maps, no Angular devtools. Figure out the relevant structure by probing from the console:

console.log('chips:', document.querySelectorAll('div.chips').length);
console.log('chip buttons:', document.querySelectorAll('div.chips button.chip').length);
console.log('question:', document.querySelectorAll('app-assessment-question').length);
console.log('view:', document.querySelectorAll('app-assessment-question-view').length);

(These components don’t exist until the quiz starts; on the start page, every count is zero.)

Purpose Selector
Quiz-page detection div.chips and app-assessment-question both present
Paginator chips div.chips button.chip
Live question component app-assessment-question
Answer options [role=radio], [role=checkbox]
Typed answers textarea, input[type=text], input[type=number]
Rendered prompt (may contain math) .backend-html (outside the question component)
Save status app-save-status

The shared helpers are small:

const SEL = {
  chips:  'div.chips',
  chip:   'div.chips button.chip',
  q:      'app-assessment-question',
  view:   'app-assessment-question-view',
};
const OPT = '[role=radio],[role=checkbox]';
const FLUSH_MS = 350;

const $  = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
const liveQ = () => $(SEL.q);
const chips = () => $$(SEL.chip);
const sleep = (ms) => new Promise(r => setTimeout(r, ms));

proxy(qi, idx, sel, fn) ties everything together: click the chip for question qi, wait for the component to render, find the control at position idx, and run fn against it.

const proxy = async (qi, idx, sel, fn) => {
  const list = chips();
  const prev = liveQ()?.textContent || '';
  list[qi].click();
  await waitReady(prev.replace(/\s+/g, ' ').trim().slice(0, 120));
  const target = $$(sel, liveQ())[idx];
  if (target) fn(target);
};

Verifying the save round-trip

Before building anything on top of it, verify the riskiest assumption directly: a scripted click needs to both select an option and trigger autosave.

const opts = [...lq.querySelectorAll('[role=radio],[role=checkbox]')];
const target = opts.find(o => o.getAttribute('aria-checked') !== 'true') || opts[0];
target.click();
let n = 0; const iv = setInterval(() => {
  console.log(++n, opts.map(o => o.getAttribute('aria-checked')),
    document.querySelector('app-save-status')?.textContent);
  if (n >= 6) clearInterval(iv);
}, 700);

aria-checked flips to true and the status settles on "Saved". The options are plain <button role="radio"> elements with no inner <input>; selected state is held entirely in aria-checked.

Replaying answers

Options are matched by index, not text. Each snapshot is byte-identical to the live question it was captured from, so the control at position idx in the snapshot is the same control at idx in the live component.

const idx = [...block.querySelectorAll(OPT)].indexOf(opt);
proxy(qi, idx, OPT, t => t.click());

Typed answers require more care. Assigning input.value directly doesn’t notify Angular’s : the value has to be set through the element’s native setter and followed by the events the form listens for:

Object.getOwnPropertyDescriptor(t.constructor.prototype, 'value').set.call(t, value);
t.dispatchEvent(new Event('input',  { bubbles: true }));
t.dispatchEvent(new Event('change', { bubbles: true }));
t.dispatchEvent(new Event('blur',   { bubbles: true }));

Flushing autosave

The portal commits an answer when the user navigates away from a question: it and flushes on leave. A proxy that answers a question and stays on it leaves the save pending; a reload loses it.

The fix is to navigate away after each answer:

const flush = qi === 0 ? (list[1] ? 1 : 0) : 0;
if (flush !== qi) { list[flush].click(); await sleep(FLUSH_MS); }

Math rendering

Some question stems contain mathematical notation. In the captured markup they appear as raw LaTeX (\bar{A}) because the question component stores the prompt as a custom element: &lt;gcb-math&gt;\bar{A}&lt;/gcb-math&gt;. The rendered output is produced somewhere else.

Checking for MathJax first:

console.log('MathJax version:', window.MathJax?.version, '| Hub(v2)?', !!window.MathJax?.Hub);
console.log('mjx-container:', q.querySelectorAll('mjx-container,.MathJax').length);
console.log('raw TeX in text?', /\\bar|\\frac/.test(q.textContent));

No MathJax. The portal uses KaTeX, and the rendered output lives in .backend-html, in the page header, outside app-assessment-question entirely:

view.querySelectorAll('.katex').length            // 2
document.querySelector('app-assessment-question').querySelectorAll('.katex').length  // 0

So each question is captured as two parts: the rendered .backend-html stem (math already typeset, KaTeX CSS loaded globally) and the options component. KaTeX output is self-contained HTML, so the captured stem renders in the stack without re-typesetting.

const grabStem = () => {
  const view = $(SEL.view), q = liveQ(), seen = new Set();
  const stems = $$('.backend-html', view)
    .filter(el => !q.contains(el))
    .map(el => el.outerHTML)
    .filter(h => !seen.has(h) && seen.add(h));
  if (stems.length) return stems.join('');
  const leg = q.querySelector('legend,.choices-legend');
  return leg ? `<div>${isEscapedHtml(leg) ? leg.textContent : leg.innerHTML}</div>` : '';
};

Capture speed

Capture is sequential: one live component, navigated per chip. So there’s no parallelism. The only cost to minimize is the wait for Angular to re-render after each chip click.

The initial implementation polled until the question text stopped changing, adding a fixed margin to every question. Waiting for the content to change from the previous question is faster, with a stability fallback for the already-active first chip:

const sig = () => (liveQ()?.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 120);
const waitReady = async (prev) => {
  let last = null, steady = 0;
  for (let i = 0; i < 60; i++) {
    await sleep(50);
    const s = sig();
    if (!s) { steady = 0; last = s; continue; }
    if (s !== prev) { await sleep(40); return sig(); }
    if (s === last) { if (++steady >= 2) return s; } else steady = 0;
    last = s;
  }
  return sig();
};

Safeguards

The extension runs during a timed assessment, so failures need to be surfaced rather than silently assumed correct.

  • Save confirmation. After each proxied answer, app-save-status is polled until it reads "Saved" rather than waiting a fixed delay. A save that never confirms flags the block as “Not saved — check on the original quiz”.
  • Count check. The number of captured questions is compared against the “Question N / TOTAL” in the header. A mismatch raises a banner.
  • Timer mirror. The live app-submission-timer text is mirrored into the sheet header on an interval.

The timer, save-status indicator, and Submit button are never moved or restyled. The extension is a view layer over the live form.

The approach

None of this came from reading source, since there wasn’t any readable source to read. It came from testing one piece at a time: check that a click actually saves before building anything on top of it, copy the app’s real save behavior instead of just its DOM, match controls by position instead of text, and check what’s actually rendering instead of guessing. The same approach works on any closed-source web UI.

The complete source (manifest, background worker, and run.js) is at civiks/unfold-iitm.