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 What is a minified production build? A build with comments, whitespace, and variable names stripped out to shrink the file. It ships without source maps, so the browser can’t map it back to readable code.
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 What is a reactive form? Angular’s form model. A input.value directly doesn’t notify Angular’s FormControl object mirrors each input’s value, but only updates when it hears the DOM events it’s subscribed to, not from a raw property write.
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 What is debouncing? Waits until input pauses before saving, so a burst of keystrokes collapses into one write instead of one per character.
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: <gcb-math>\bar{A}</gcb-math>. 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-statusis 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-timertext 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.