How to QA-test RezUp (cv.zalize.com) end-to-end — free/launch mode, license activation, downloads, Lemon Squeezy checkout, AI tools — without ever completing a real payment.
Instrucciones de origen · Vista previa de solo lectura
name
testing-rezup
description
How to QA-test RezUp (cv.zalize.com) end-to-end — free/launch mode, license activation, downloads, Lemon Squeezy checkout, AI tools — without ever completing a real payment.
Testing RezUp
CDP mobile-emulation & deep-link pitfalls
Emulation.setDeviceMetricsOverride reverts when the websocket that set it closes — hold a single CDP connection open for the whole mobile pass, and re-check innerWidth before judging layout. Pair with Emulation.setPageScaleFactor(1) for stable layout metrics.
A nohup'd CDP-hold helper may survive kill_shell — pkill -f cdp_hold and verify innerWidth is back to desktop before final desktop screenshots.
After killing the CDP hold, Emulation.clearDeviceMetricsOverride alone may leave the page stuck at the mobile width — if innerWidth stays 375, send Emulation.setDeviceMetricsOverride({width:0,height:0,deviceScaleFactor:0,mobile:false}), then clearDeviceMetricsOverride, then Page.reload.
xdotool typing drops/mutates characters (seen: "+"→"O", leading letter dropped) — always verify typed text in honestcv.resume before asserting, and use ASCII-word substitutes for symbols that won't land.
Router deep-link features (e.g. ?assistant=1, ?doc=) must be tested on repeat activation, not just first use: cleaning query params with raw history.replaceState desyncs react-router's location and the second activation silently no-ops.
DOCX exports can be runtime-verified without Word/LibreOffice: download, then unzip -p file.docx word/document.xml and extract text with re.findall(r'<w:t[^>]*>([^<]*)</w:t>'). Section headings are stored uppercase (grep "MILITARY SERVICE", not "Military service"); bold is a <w:b/> in the run properties immediately before the <w:t>.
Clicking multiple export buttons in a row: Chrome's downloads popup can cover the later buttons so only the first file lands — press Escape and click each export separately, verifying ~/Downloads between clicks.
Clearing honestcv.* (e.g. for fresh-resume tests) also wipes the download-unlock flag — re-set honestcv.shared='1' before export tests, and expect the pre-existing "Final check before download" nudge on sparse resumes (click "Download anyway").
A fresh resume does not write honestcv.resume until the first edit — assert its default section order from the rendered Section order panel, not storage.
Web-font features: assert lazy loading via performance.getEntriesByType('resource') — no /fonts/… entry before the font is selected, a 200 entry with transferSize>0 after; prove the face actually loaded with document.fonts.check('12px <Family>') after document.fonts.ready. For pdf-lib subset:true embedded fonts, pdffonts shows numeric PostScript-name suffixes (not the classic ABCDEF+ prefix, sub=no) — prove subsetting by file size vs the raw TTF size.
Export ink colors can be verified objectively: render the downloaded PDF with pdftoppm -png -r 150 and take a PIL Counter over non-white pixels — the top dark colors map 1:1 to body ink, accent hex and soft-gray dates. For DOCX, the default body ink lives in word/styles.xml<w:docDefaults><w:rPrDefault><w:rPr><w:color> (absent/empty in Default mode); accent colors are per-run w:color in document.xml.
Design-toolbar swatches (button[aria-label^="Text color"], accent swatches) are compact 32px circles on desktop; Text color swatches grow to 40px below the sm breakpoint.
At emulated mobile widths, judge horizontal overflow by document.documentElement.scrollWidth vs visualViewport.width — innerWidth reports the (expanded) layout viewport (e.g. 414 at a 375 device width) and masks overflow. Design-toolbar group spans (Font/Text/Spacing) are individual flex spans: any pill added there needs the span to have flex-wrap or it overflows 375px.
Chrome may speculatively preload fonts used in a previous session on the same origin (initiator "link", crossorigin-mismatch console warning) — exclude such entries when asserting lazy loading; assert on the specific new-family files instead.
Don't combine pkill -f <script> with follow-up commands in one shell invocation — pkill matches its own wrapper and kills the whole command; kill and verify in separate calls.
Text-size scaling in the preview is applied via CSS zoom on the ResumePreview root (not font-size), so computed font-size stays constant across scales — assert on the root's computed zoom or on getBoundingClientRect() of preview text. For exports, pdftotext -bbox word heights give exact glyph-size ratios, and DOCX w:sz values are Math.round(base × fontScaleOf) with base half-points 40/24/22/21/19.
PDF line-spacing ratios via pdftotext -bbox: consecutive-line y-deltas contain a constant non-scaled leading (~2pt) on top of the fontSize·lineHeight component — solve delta = a·ls + c across two spacing settings instead of expecting the raw delta ratio to equal the multiplier ratio. In the preview, the resume root carries an inline line-height = lineSpacingOf + 0.1; rect-height ratios of the same wrapped paragraph give exact (ls₁+0.1)/(ls₂+0.1). DOCX line spacing lives in w:spacing w:line = round(240·ls/1.35).
Diffing two DOCX exports for formatting-only changes: hyperlink r:id attribute values in document.xml are randomly regenerated per export — strip/ignore r:id="rId…" before comparing or the contact line shows a false diff. Also, the example resume only exercises the Experience bullet path in the preview (2 ULs); preview assertions on bullet styling cover a subset of the 8 SectionBlock bullet paths unless extra sections are added first.
PDF contact lines never contain literal | glyphs even with contact icons Off — segments are drawn individually with separator-width spacing, so assert Off behavior via pdftotext -bbox x-positions/annot Rects, not pipe extraction (icons On shifts each link Rect x1 left by iconSize+gap ≈ 10pt at 9pt font). Templates with headerAlign:'left' are Minimal/Bold/Elegant/Engineer/Slate/Startup/Horizon/Ink/Atlas/Quartz/Cobalt — Compact/Executive center their headers despite the names.
Per-line bullet hints render under each Experience bullets textarea via BulletHints (Builder.tsx): max 4 flagged lines × 2 issues per line (slice(0,2) — earlier kinds like weak-opener/no-metric can hide later kinds such as passive; test a hidden kind by fixing the companions first). The green "✓ Bullet best practices applied" line requires zero issues AND 3–6 bullets; the built-in example bullets lack trailing periods, so they show punctuation warnings by default and need periods added to reach the green state.
R126 collapsible entry cards: chevron toggles are button[aria-label="Collapse|Expand role N"] (education N, project N) with aria-expanded; state is a Builder-local Set keyed by entry id (follows the entry through reorder, resets on reload, new/duplicated entries start expanded). Experience/Project header control rows stay visible while collapsed; Education's controls live in the body and hide when collapsed (expected). Pitfall: a collapsed entry's inputs are unmounted — querySelectorAll('input[placeholder="Job title"]') only returns expanded entries; edit honestcv.resume directly to mutate collapsed ones. Mobile 375 pitfall: the Experience header (identity text + 6-button control row) can overflow the layout viewport (scrollWidth 540) even though the identity span has min-w-0 truncate — Education (chevron only) and Projects headers truncate fine; always assert the scrollWidth trio per entry type, and note that once the layout viewport expands, span truncated flags become unreliable (re-test after a fresh reload). Post-fix (index-pkLZhLFR.js): min-w-0 on the Experience header row/
+ shrink-0 on the controls keeps scrollWidth at 375, but at 375 the identity span's clientWidth collapses to 0 — the header shows only "Role N" with no ellipsis. span.scrollWidth>clientWidth alone doesn't prove a visible ellipsis; also check clientWidth > 0 and screenshot the header. Final layout (index-CdMeqhhX.js): the Experience header row is flex-wrap — below 640px the identity
is basis-full (its own full-width line) and the 6-button controls wrap to a right-aligned second line; on desktop it stays a single line. Assert mobile wrap via p.top < controls.top plus scrollWidth 375.
R158+: Experience/Education card fields have stable ids — exp-{entryId}-role|company|location|start|bullets, edu-{entryId}-degree|school|location|start — prefer them as selectors over placeholder matching.
Export downloads are gated: set localStorage['honestcv.shared']='1' to skip the free-beta email dialog (never submit a real email), then expect a "Final check before download" dialog with a "download anyway" button when the resume has audit warnings; downloads land in ~/Downloads; remove honestcv.shared in cleanup.
Edit history (R160): the toolbar button is matched by title ("Edit history — automatic checkpoints of this draft"), not aria-label; when crafting honestcv.resumeHistory snapshots (elements {id, at, data}), always build data from the app-normalizedhonestcv.resume (seed fixture → reload → read back) — raw fixture data missing defaults like gpa/hiddenContact makes rows show neither "Current" nor a diff line and fakes bugs. Entry-count diffs use U+2212 for the minus sign.
Quick health-tier recipes: the standard fixture scores ≈99 (Strong); replacing all bullets with a single "responsible for … various …" line + a buzzword summary drops health to ≈43 (<50); one numbered bullet + two weak ones lands ≈62 (50–79).
The assistant panel opens via the chat-bubble icon in the builder header (desktop ~x=703, mobile ~x=147); chat history lives in honestcv.assistantChat. Assistant turns can be exercised with zero quota by stubbing window.fetch to return a canned 200 {text, action:null, freeRemaining} for /api/ai/assistant (the user turn persists even on failure, so a rejecting stub also works for "non-empty chat" states). The fastest all-structure-checks-pass fixture is padding experience bullets until the plain-text word count is 400–800.
The R82 Fix→ ring flash lasts only 1600ms (Builder.tsx SectionCard setTimeout(…,1600)) — to prove it, install a polling watcher for [class*="ring-primary/60"] via CDP before clicking and screenshot immediately after the click. Builder/checker check rows show hints only for failing checks — assert pass states via the ✓ + "N of 8 structure checks passing" counter and score math, not the pass-hint string. /api/hit is the normal first-party page-view endpoint alongside billing/quota — not an anomaly. Since R96 the stock example resume scores ATS 88 (word-count check fails at 158 words) — baselines expecting "ATS 100" must use 88.
PDF text geometry: pdfminer.six (pip-installable) gives per-char fontname/x positions — ideal for proving bold-label prefixes and wrap x-origins. Since R98 (drawnWidth in pdf.ts) wrapped lines must never exceed the content edge — assert a hard full-page sweep max x1 ≤ pageW-2*54 = 558pt (letter); any overshoot is a bug. The client-id localStorage key is honestcv.clientId (not client-id), and the example resume's placeholder website ("yoursite.com") emits no URI annot, so contact-line annot counts are 2 (mailto+linkedin), not 3. Computer-use typing into the Skills textarea frequently drops the first keystroke after a click ("Languages"→"anguages") — verify the stored value via JSON.parse(localStorage.getItem('honestcv.resume')).skills after typing and fix via UI. Each of the PDF/DOCX/TXT/MD download buttons triggers its own "Final check before download" modal when checks fail — click "Download anyway" per format.
Mobile-overflow QA: when starting 375px emulation via CDP Emulation.setDeviceMetricsOverride, ALWAYS reload under emulation and assert the trio innerWidth === visualViewport.width === scrollWidth === 375 before measuring — without a reload the layout keeps the old width, and under emulation innerWidth silently expands past 375 when the document overflows (a better tripwire than scrollWidth alone). To localize an overflow, binary-search by toggling display:none on candidate nodes while re-reading document.documentElement.scrollWidth, then shorten suspect text. Tailwind truncate (whitespace-nowrap) fails when the flex/grid item lacks min-width:0 — min-w-0/overflow-hidden on block-level descendants do NOT stop intrinsic min-content propagation in Chrome; in the builder the effective min-w-0 lives on the editor grid column (main.grid → left div.min-w-0.space-y-4, Builder.tsx ~L1085). Never accept class presence as proof of a fix — re-measure and require real ellipsis (p.scrollWidth > p.clientWidth); you can prove a candidate fix live via el.style.minWidth='0' before/after. Since R99, honestcv.experienceLibrary is a legit localStorage key on clients that used the saved-roles library — include it in byte-for-byte backup/restore sweeps.
Since R100, honestcv.activeVersionId is a legit localStorage key (links the builder draft to a saved copy) — include it in byte-for-byte backup/restore sweeps, and expect the builder to silently delete it on the next debounced save if it points at a missing copy (its disappearance is self-healing, not a bug). Dashboard card "Open" shows a confirm dialog ("Open and replace draft") before linking. Jobs "Target my resume"/"Cover letter" both confirm and write targetRole+JD synchronously (saveResume + syncActiveVersion) before navigating to /builder — assert storage immediately on landing; the cover flow opens an AI dialog, close it without clicking Generate to keep runs AI-free. In 375px overflow sweeps, exclude children of overflow:hidden truncate parents (e.g. the "· editing" span measures past the viewport while visually clipped). Don't run pkill -f/pgrep -f loops against the CDP hold script from the same shell — kill by explicit PID from ps aux.
Builder education entries lay fields out two-per-row (row 1 degree|school, row 2 location|start/end, row 3 GPA|minor, row 4 details + controls) — computer-use typing can land one row off, so verify field values in honestcv.resume before saving. The per-entry controls row is up/down/duplicate/BookmarkPlus/delete for both Experience and Education; library panel rows expose button[aria-label^="Remove saved e"] plus an "Insert" text button (h-10 mobile / sm:h-7 desktop). Library keys are honestcv.experienceLibrary and (since R111) honestcv.educationLibrary — both max 30, newest first, with sanitize* silently dropping malformed rows; include both in byte-for-byte backup/restore sweeps.
Skills library (R112, honestcv.skillsLibrary): the single skills textarea uses a plain Save skills to library aria-label (list sections use numbered Save role N…/Save education N…); panel rows are p.truncate (first non-blank line) + Saved <date> with button[aria-label^="Remove saved skills"]. Insert appends \n + block when the textarea is non-empty (trailing whitespace stripped) and sets it verbatim when empty — assert exact string equality via CDP rather than visual diffing. Since R113 there are four isomorphic libraries — experience/education/skills/summary (honestcv.summaryLibrary) — all max 30/newest-first/silently-cleaned; text-block libraries use singular aria-labels (Save summary|skills to library, Remove saved summary…) vs numbered Save role N/Save education N; the Summary button row is flex flex-wrap, so at 375px assert flexWrap === 'wrap' + right edge ≤ 375 rather than expecting a single line. R114 adds a fifth: honestcv.projectLibrary (structured, like education). The "Projects (optional)" section is defaultOpen=false and re-collapses on every reload — re-expand via its header before any post-F5 assertion. Structured-library saves store data with a fresh id at save time (stored data.id ≠ source entry id — don't assert equality); sanitizers drop optional keys when blank (project org/startDate/endDate, education gpa/minor) — assert key ABSENCE ('org' in data === false), not empty strings. Malformed rows are cleaned at read time: the UI/count is authoritative while raw localStorage may still contain dropped rows until the next persist. R115 adds a sixth: honestcv.involvementLibrary — unlike project/education its data has 7 required string keys with empty strings kept (assert key PRESENCE, not absence); rows label as non-blank role — organization (fallback "Untitled involvement"), remove buttons match button[aria-label^="Remove saved involvement"]. Collapsed-by-default sections auto-expand when they contain a non-empty entry, so post-F5 re-expansion is only needed in the empty state. R116 adds a seventh: honestcv.courseworkLibrary — like involvement its data has 6 required string keys with empty strings kept (assert key PRESENCE); save buttons Save coursework N to library, remove , row labels (fallback "Untitled course"). Per-entry save buttons for project/involvement/coursework/awards only exist once those sections have entries — add an entry before asserting regression presence. R120 adds an eleventh: — Publications is a TOP-LEVEL section between Awards & honors and References (resume key , baseline empty — click "Add publication" first); has 5 always-present strings (id/title/venue/date/description); validity = title OR venue OR description non-blank (date alone invalid); save buttons , remove , row labels (fallback "Untitled publication"); PDF renders with the date in the right-hand column and description lines as bullets. R119 adds a tenth: — resume entries live at key (NOT certificationsList) INSIDE the Skills & certifications section under "Certifications (optional)"; the legacy free-text "Additional certifications" input is separate and unchanged. has 5 always-present strings (id/name/issuer/date/description); validity = name OR issuer OR description non-blank (date alone invalid); save buttons , remove , row labels (fallback "Untitled certification"); PDF renders with the date in the right-hand date column and the description on the next line. R118 adds a ninth: — has 5 always-present strings + whitelisted to (anything else → at read); validity = name OR employer OR email non-blank (title/phone/kind alone don't count); save buttons , remove , row labels (fallback "Untitled reference"); PDF/preview heading is plus a detail line . R117 adds an eighth: — its has 5 required string keys with empty strings kept (assert key PRESENCE); save buttons , remove , row labels (fallback "Untitled award"). After a new deploy the browser tab may still hold the previous bundle — always hard-reload (Ctrl+Shift+R) and assert includes the expected before testing.
Profile photo (R121): the resume may carry an optional photo key — a data:image/... URL, sanitized at load (anything not starting data:image/ is dropped in memory but may linger in raw storage until the next persist). Upload via the hidden input[aria-label="Upload profile photo"] driven with CDP DOM.setFileInputFiles — clicking the "Add photo (optional)" button opens a native dialog that can't be automated. Images are center-cropped to 256×256 JPEG q0.85, so a landscape fixture with distinct edge colors gives a pixel-provable crop assertion. PDF checks: pdfimages -list for the embedded object (one 256×256 rgb JPEG on page 1), pdftoppm pixel sampling for the top-right position, and a pdftotext diff against a no-photo export (must be empty — layout untouched). DOCX/TXT/MD intentionally omit the photo. Remove deletes the key entirely ('photo' in parsed === false).
Publication Type (R122): the entry's Type input is input[list="publication-kinds"] (placeholder "Type — e.g. Journal Article", 9 datalist options, not required); kind is stored only when non-blank (assert key ABSENCE for blank, like project org). Preview renders title — venue + a separate italic span (Kind); PDF/TXT/MD carry (Kind) in the heading line and DOCX adds an italic <w:i/> run (Kind). A type-only entry counts as non-empty for save-to-library. Pitfall: driving the R121 photo upload via Runtime.evaluate objectId → DOM.requestNode → DOM.setFileInputFiles silently no-ops — resolve the nodeId via DOM.getDocument + DOM.querySelector instead and the upload works.
Audit-chip CSS popover (R149): chip is wrapped in span.group.relative; the popover is div[aria-hidden].absolute.top-full.right-0.w-56 toggled by group-hover:block / group-focus-within:block. CRITICAL: Tailwind v4 gates ALL hover variants behind @media (hover: hover), and the Devin test browser reports hover:none/pointer:none (despite --blink-settings=primaryHoverType… flags — X11 device detection overrides them), so hover popovers/tooltips NEVER open via real mouse hover in this VM. Workaround to demo the hover path visually: recursively walk document.styleSheets (media rules nest inside @layer — top-level iteration finds nothing) and set r.media.mediaText='all' on rules whose media contains "hover", then real-mouse hover works; the focus path (group-focus-within) is ungated and needs no patch — programmatically .focus() a neighbor (e.g. the "Sort by date" button, whose click would toggle sort — focus() is safe) then send one real Tab. Rollup row: ✓ N best practice(s) applied, N = checks − distinct violated categories (Experience 10, Projects 8, Education 1), omitted at N=0. KNOWN BUG (index-CZ4ieiiA.js): at 375px the right-0 w-56 panel extends 224px left of the chip, and chips sit near the header's left edge → panel left goes negative (≈ −91px) and is visibly clipped by the viewport (scrollWidth stays 375 — negative-left overflow creates no scrollbar, so assert panel.getBoundingClientRect().left ≥ 0, not scrollWidth). Mobile-dock fix history: index-DdVkN5JT.js docked the panel fixed inset-x-4 bottom-4 z-30 — horizontal containment fixed (rect 16–359), BUT the Builder's fixed bottom Edit/Preview tab bar (also z-30, later in DOM, top ≈ innerHeight−61) occluded the panel's bottom (green ✓ single-row panel fully hidden). FIXED in index-xr2YJNZ0.js with bottom-20 z-40 — panel bottom lands at innerHeight−80 (732 at 812h), above the bar. When re-testing mobile popovers, always hit-test the panel's LAST row with document.elementFromPoint — if it returns the bg-background/95 fixed inset-x-0 bottom-* bar, the popover is hidden behind it. Undo pitfall: Ctrl+Z after an accidental add-a-section click may revert an earlier field edit instead of the section add — verify storage after undo/redo.
Audit-popover explanations + named passed checks (R150, index-CfFmemYJ.js): each failed row gains a muted span.block with AUDIT_EXPLANATION text; the green rollup gains a muted line with passedNames.join(', ') = applicable checks minus failing groups, in canonical order (Experience: Number of bullet points, Dates are missing, then the 8 bullet categories; Education: Dates only; Projects: 8 bullet cats only). Desktop panel widened to sm:w-64 (256px). NEW 375px GOTCHA/BUG: the explanations make the mobile docked panel much taller (it can span ~y353–732 at 812h), and tapping the amber chip first fires focusin → group-focus-within opens the panel BEFORE the click event dispatches → the click lands on the panel (target becomes a panel/wrapper span, not the chip button) and the card does NOT expand. Reproduce: touchStart/touchEnd at the chip center while the chip's y lies within the open panel's rect; the same tap works when the chip is scrolled above the panel top. When testing tap-to-expand, always check both chip positions and instrument document.addEventListener('click', …, true) to see the real click target — elementFromPoint before the tap looks fine because the panel is still closed.
Tap-swallow fix (a52e64a, index-MwUPUKpz.js): amber chip expands on onPointerDown (fires before focusin opens the panel) and its onClick only runs for keyboard activation (e.detail === 0), so pointer taps/clicks never double-toggle. To verify keyboard activation via CDP, Input.dispatchKeyEvent must use type keyDown with text:'\r' — rawKeyDown does NOT trigger button Enter activation.
Fixture schema gotcha: the resume's top-level contact object is contact with fullName (NOT basics/name — a wrong shape makes the Builder silently fall back to an EMPTY resume even though honestcv.resume holds your fixture; storage looks right but the UI renders blank). When unsure, make one UI edit and read back localStorage.getItem('honestcv.resume') to capture the authoritative schema before seeding.
Preview Pages/Flow toggle (R147): Builder-only view prop; Flow is [aria-label="Resume preview (continuous)"] with .border-dashed "Page break" markers at unscaled offsetTop = 32 + (i+1)*992 (letter) — marker POSITIONS never move with layout controls (fixed page geometry); only COUNT changes when contentH crosses n*992+1. contentH is flowContainer.firstElementChild.firstElementChild.scrollHeight. Preference key honestcv.previewView ('flow'/'pages', not in resume schema — include in cleanup sweeps). Preview text is nearly all InlineText spans (even H3 section headings are editable) — for section-jump clicks, sample elementFromPoint at mid-x near the section's bottom padding until the hit is the [title="Edit <Section>"] DIV itself. At 375 the preview column is display:none behind the "Preview & score" mobile tab; KNOWN BUG (as shipped): Flow's inner div is fixed width:816px + transform scale, whose min-content inflates the grid column → scrollWidth/innerWidth 860 at 375 (Pages stays 375); FIXED in index-DvPCTegi.js by absolutely positioning the 816px inner div (like paged frames) — post-fix assert scrollWidth===innerWidth===375 and that the flow frame height is still contentH-driven (not collapsed to 0 by the absolute child). Seeding a multi-page fixture via localStorage.setItem('honestcv.resume',…) while /builder is open gets clobbered by the app's debounced save — seed from the landing page (or about:blank) then navigate to /builder; bullets must be string ARRAYS or the sanitizer silently drops them.
Auto-fit + section spacing (R123): Auto-fit tries 15 fontScale×lineSpacing combos at the current sectionSpacing, then (only if nothing fits 1 page) retries the tighter half at 'tight' then 'xtight' — toast appends ", tight|xtight sections" ONLY when spacing changed; short resumes must never show "sections". To exercise the fallback you need a fixture that's 2 pages at xs/compact/normal but 1 page at xs/compact/tight — the window is narrow (~1 bullet wide): on the example resume, appending 8 long extra bullets to experience[0] (after its 3 stock ones) and 6 to experience[1] (after its 2) hits it; search by bisecting bullet counts via localStorage + reload, reading the "PDF export: N page(s)" indicator text. Sections control shows 1.00 normal / 0.60 tight / 0.35 xtight (values, not names). Reset fontScale/lineSpacing/sectionSpacing (delete keys) before clicking Auto-fit so the 2-page precondition holds.
Preview click-to-edit (R125): in /builder every preview section wrapper plus the contact header carries title="Edit <label>" + cursor-pointer hover:bg-black/5 ONLY when Builder passes onSectionJump; share pages (/s/...) and dashboard previews pass nothing — assert NO title attr and computed cursor ≠ pointer there. Mapping: certifications→"Skills & certifications" card, custom:<id>→"Custom sections" card, else 1:1; on mobile a preview tap also flips to the Edit pane (setMobilePane('edit')). The flash ring is the same 1600ms R82 mechanism — screenshot fast. DEPLOY PITFALL: verify feature-branch ancestry — the R125 prod build was cut from the R123 base and silently dropped the R124 date pickers (zero button[aria-label="Open date picker"] in prod); always grep the deployed chunk for the previous release's marker string before regression-passing it.
Date pickers (R124, MonthYearField): all 10 Experience/Education/Projects/Involvement/Military start+end date inputs keep free-text <input> behavior but add a toggle button[aria-label="Open date picker"|"Close date picker"] (aria-expanded reflects state). Popover: ‹ {year} › header (aria-labels "Previous year"/"Next year"), 12 short-month buttons picking "Mon YYYY", footer "{year} only" + ("Present" on end fields / "Clear" on start fields); closes on Escape and outside pointerdown; year initializes from the first 19xx/20xx in the current value, else current year. Popover is w-56 right-0 — at 375px assert its rect stays within [0,375]. Under CDP mobile emulation, screen coords ≈ CSS coords × (240/375) with +88px y offset for browser chrome — but computer-use clicks landing one row off are common; verify the resulting input value via storage after each pick.
Inline preview editing (R127, InlineText in ResumePreview): editable spans are span[contenteditable][role=textbox][aria-label="Edit text"] — exactly name/title/summary/exp role+company+bullets/edu degree+school/project name+description; dates, locations, contact line, skills, headings have none (assert by counting [contenteditable] and listing textContent). Commit on blur (whitespace collapsed+trimmed), Enter blurs w/o newline, Escape restores pre-edit text, paste is intercepted to plain insertText (assert span.querySelectorAll('*').length===0 after pasting rich HTML copied from a file:// page). onClick stopPropagation blocks the R125 section jump — assert window.scrollY unchanged after clicking editable text vs. jump on whitespace. Ctrl+A inside a focused span selects only the span; if the click misses the span, Ctrl+A selects the whole page — always check document.activeElement is the span before select-all/delete. Empty commit clears form+storage but the ' · ' separator before company still renders (pre-existing rule). Share /s/... and /dashboard render ResumePreview without onEdit → zero contenteditable. Pitfall: the share dialog URL input truncates visually — read the real id from localStorage['honestcv.shareLink'], not the screenshot.
Renamable section headings (R128): built-in headings in /builder preview are h3 > span[contenteditable][aria-label="Edit text"] (same InlineText semantics as R127). Overrides live in honestcv.resume.sectionHeadings (e.g. {"experience":"Work Experience"}) — stored AS TYPED; uppercase templates (Modern etc.) apply text-transform: uppercase via CSS only, so assert storage case separately from display (switch to Minimal to see the literal string). Clearing the heading removes the key; when no overrides remain the whole sectionHeadings field is absent (sanitizer also drops unknown keys/defaults/empties). Exports: PDF/DOCX/MD carry the override as typed (## Work Experience in MD, pdftotext shows WORK EXPERIENCE with upper template), TXT always uppercases headings. Share /s/... and dashboard show the override read-only (zero contenteditable). Clicking heading TEXT edits (no R125 jump, scrollY unchanged); section whitespace still jumps. Custom sections keep their own title editing.
Section-spacing assertions: preview section headings carry an inline margin-top = 16·sectionSpacingOf px (inline style, unaffected by the preview CSS zoom — assert h3.style.marginTop directly); DOCX heading paragraphs use w:spacing w:before = round(240·ss) (a different field from line-spacing w:line; body paragraphs have unrelated constant before=100/60); PDF section gaps fit gap = 10pt·ss + per-boundary constant — solve the linear model across two settings like the line-spacing note above.
R76 share-link QA notes
The share dialog's Link access control is a native <select aria-label="Link access"> — click the select, then click the option; the first click sometimes closes without selecting, so retry. Verify Copy via xclip -selection clipboard -o, not in-page navigator.clipboard.readText() (hangs on a permission prompt under CDP).
Incognito windows (Ctrl+Shift+N) appear on the same CDP :9222 endpoint — select targets by URL substring, never "first page" (/tmp/cdp.py grabs the first target and can hit the wrong window). An incognito RezUp page immediately writes honestcv.firstSeen, so "fresh context" means only that key present.
Worker code lives behind a redirected wrangler config (dist/honestcv/wrangler.json from the Vite Cloudflare plugin): wrangler dev/deploy serve the built worker, so run npm run build after any worker/index.ts edit before local smoke tests or deploys.
QA traffic marking (unified convention)
Real-browser QA: set localStorage['honestcv.qa']='1' via context.addInitScript() BEFORE the first goto — this suppresses the first-party /api/hit pageview beacon and all /api/ev funnel events client-side.
Scripted probes (curl/fetch): send header x-qa: 1 on any request that could hit /api/hit or /api/ev. The Worker also drops empty-UA requests and UAs matching /headless|bot|crawl|spider|curl|wget|python|node-fetch|go-http/. Marked traffic is accepted (no behavior change) but never counted in first-party analytics.
Funnel events are ev:<day>:<event> daily counters (builder-start / export / ai-use / return), at most one per browser per day — no user identifiers. Cloudflare Web Analytics (RUM) was removed for this host; the first-party beacon is the only pageview source.
I11/I12 notes (main d59f861)
/examples/ now has 15 role pages (added: accountant, administrative-assistant, graphic-designer, human-resources, product-manager, retail-associate, warehouse-worker). Fast validity check from the page console/CDP: fetch /examples/, regex href="(/examples/[a-z-]+/)", HEAD each — expect count 15, all 200.
I12: every static-page footer (guides, templates, /vs/, hubs, about, legal) has an "Examples" → /examples/ link, and each guide's "Keep reading" list ends with "Resume examples by role" + "RezUp vs other resume builders".
Pitfall: CDP Emulation.clearDeviceMetricsOverride may not visually restore a previously-emulated tab — open a fresh tab (Ctrl+T) to get desktop layout back.
PR #188 review-round-1 notes (main 00363ae)
Landing "/" is prerendered at build time (scripts/prerender.mjs): curl of / returns ~65KB with landing HTML inside <div id="root">…; /builder, /ats-checker and 404s serve spa.html (~4.7KB, id="root"></div> empty). Diffing these curls is the fastest no-flash proof; a 404 route (e.g. /no-such-page) should be HTTP 404 with the same 4.7KB shell. Hydration check: src/main.tsx hydrates iff root has a child — verify template-gallery filter chips (e.g. "Serif (9)" → 9 thumbnails) and hero CTAs still work after load.
Worker AI abuse gate (worker/index.ts): POST /api/ai/* body >60KB → 413; rewrite text >5000 chars → 400 "That text is too long to rewrite in one go — split it up."; per-IP 30/day → 429 code rate_limited. IMPORTANT: every unlicensed POST to /api/ai/* increments the shared per-IP KV counter — keep test requests to 1-2 and never trigger the 429 deliberately from a shared box. Neither the gate rejections nor failed upstream calls consume the per-client free quota (/api/ai/quota stays unchanged).
CORS: OPTIONS /api/* with a foreign Origin returns access-control-allow-origin: https://cv.zalize.com (never echoes the foreign origin); localhost origins are echoed.
While the AI relay is broken, the builder Tailor flow surfaces "The AI returned an unexpected format — please try again." (not the "temporarily unavailable (NNN)" wording) — quota still unspent.
PR #191 notes (main 5bbd1e0, version 25515d64)
AI relay is LIVE again (Oct/relay fix): "AI polish summary" returns the 3-variant "Pick a summary" modal, and Tailor returns per-line suggestions. Each successful call decrements the free quota by 1 (footer + button title + "N free AI uses left" labels all update). Calls can complete in ~2–4s, much faster than the 15–40s copy — grab the wait-hint screenshot within ~1s of clicking.
Inline AI buttons render <p role="status">Rewriting… usually takes 15–40 seconds (Ns)</p> next to the clicked button while busy, and all AI buttons disable while any call is in flight. Locked users get title="N free AI uses left" on every AI button (native tooltip appears after ~2s hover).
Tailor/bundle dialogs show "Usually takes 15–40 seconds — …" only during the busy state (after clicking "Get tailoring suggestions"), not in the idle dialog.
Chrome's CDP remote-debugging port on this box is NOT always 9222 — discover it with ps aux | grep -o 'remote-debugging-port=[0-9]*' (seen: 29229). requests may be missing; use urllib.request + websocket.create_connection(..., suppress_origin=True).
I29–I30 notes (main 03c69b0, worker 1773fa78)
I30: builder empty-state role picker <select id="example-role"> now renders <optgroup> per sector — expect exactly 5 optgroups (Tech & data / Healthcare & education / Business & finance / Customer-facing & office / Trades & transport) and 20 role options (+1 placeholder). examples.json entries carry a sector field. A flat list (0 optgroups) means the change regressed.
I29: /templates/ hub is grouped under 4 style h2s — Banded headings (5) / Serif (7) / Minimal (3) / Modern sans (7); link-integrity: regex href="(/templates/[a-z-]+/)" → 22 unique, all 200; ItemList JSON-LD numberOfItems 22.
I25–I27 notes (main dffceec, worker 6b8d2655)
Examples library is now 20 roles (was 15). /examples/examples.json → 20 entries; builder empty-state picker <select id="example-role"> → 21 options (placeholder + 20). New slugs: electrician, truck-driver, financial-analyst, medical-assistant, restaurant-server.
I25 exampleToResume: a trailing ", YYYY" in the education school string moves to the education end-date field (e.g. Data Analyst → degree "B.S. Industrial Engineering", school "Georgia Tech", end date "2021"; preview shows the year right-aligned as a date). If school still contains ", 2021" the fix regressed.
I27 hub /examples/ is grouped under 5 h2s (Tech & data / Business & finance / Healthcare & education / Trades & transport / Customer-facing & office); link-integrity: regex href="(/examples/[a-z-]+/)" → 20 unique, all 200; ItemList JSON-LD numberOfItems 20. Landing template-gallery blurb: "20 complete resume examples by role".
Autosave pitfall: clearing honestcv.resume via CDP while a builder tab is open gets re-saved by that tab's autosave — close/reload extra builder tabs first, then clear and reload.
I18–I22 notes (main ee9d87e)
I22: the builder empty-state card (clean honestcv.resume) now has "Or start from your role:" <select id="example-role"> with 15 roles; choosing one applies the example via the same applyExample() as the ?example=<slug> deep link (shared confirm/keep-template rules). The picker only exists in the empty state — with content present, test the confirm rule via the deep link instead.
I20: /guides/ hub is grouped under 6 <h2>s (Start here / Writing the content / Tailoring to a job / Your situation / What to include — and leave off / Beyond the resume); link-integrity check: regex href="(/guides/[a-z-]+/)" on the hub HTML → expect 34 unique, all 200.
I21: example-page .exrole is flex flex-wrap (date wraps below at 375px, no float); each example page has a "How to write yours" list: 4 guides + All resume guides.
I18: /guides/ and /examples/ each carry one application/ld+json ItemList (34 and 15 items) — verify by parsing the JSON from curl output.
I13/I14/I16 notes (main 05140fa)
I16 deep link: every /examples// primary CTA is now /builder?example=<slug>. Builder fetches /examples/examples.json, maps it via exampleToResume (split "2022 – Present" dates, comma-joined skills, degree/school split — the year stays in the school field, certifications from "·" parts). If contact.fullName || summary is non-empty a window.confirm ("Replace your current resume content with this example? Your saved copies are unaffected.") gates it; the ?example param is stripped via replaceState so reload never re-triggers. Template preserved only if != classic (same rule as #133).
I14 gate: honestcv.shared='1' alone (no honestcv.subscribed) now skips the email gate on download — only the "Final check before download" quality nudge may appear (that's a different, pre-existing dialog; click "Download anyway").
I13: landing template-gallery section ends with "15 complete resume examples by role" → /examples/; hub meta description starts "15 complete, honest resume examples by role…".
Note: curl to cv.zalize.com needs a browser-ish UA — plain python urllib gets 403 from Cloudflare; curl works.
I7/I8 notes (main 239d644)
Builder quota hint: with no JD the Tailor row shows "Paste a job description to enable tailoring"; once a JD is pasted, locked users see "N free AI uses left" right next to "Tailor to this job" (licensed users see nothing). Toggle by cutting/pasting the JD textarea content.
AI outage copy: upstream failure now shows "The AI service is temporarily unavailable (NNN) — please retry in a minute. None of your free AI uses were spent." inside the Tailor dialog; verify /api/ai/quota unchanged before/after.
375px /ats-checker: the label "Your resume (paste or upload)" + "Upload PDF / DOCX" button row is flex flex-wrap — expect two stacked lines at 375px with scrollWidth 375.
/examples/ pages (hub + 8 role pages, e.g. /examples/teacher/) are static HTML from scripts/build-seo.mjs, not React routes — clicking their internal links does full navigations; footer of React pages has a "Resume examples" link. All examples pages passed axe A/AA and 375px overflow checks; only console noise is the known cloudflareinsights beacon ERR_BLOCKED_BY_CLIENT.
RezUp is a React 19 + Vite SPA served by a Hono Cloudflare Worker (honestcv, repo ~/repos/honestcv), live at https://cv.zalize.com. Resume state is browser localStorage (honestcv.resume); license state is also in localStorage. Clear localStorage to get a fresh locked state.
Free/traffic mode (FREE_MODE=true worker var)
Check curl -s https://cv.zalize.com/api/billing/status — {"freeMode":true} means launch/free mode and the paywall paths below are bypassed:
Builder header shows a "Beta free trial" badge (was "Free during launch" before PR #112) instead of "Unlock — $9.99 once".
First PDF/DOCX click (unsubscribed) opens a "Downloads are included in the beta trial" email dialog (was "Downloads are free during launch"; button is now "Unlock downloads"). Any valid-looking email works (no verification); it POSTs /api/leads with plan free-download and stores localStorage key honestcv.subscribed. The pending download then starts automatically; subsequent downloads skip the dialog. Clear both honestcv.license and honestcv.subscribed to re-test the gate.
Set localStorage['honestcv.qa']='1' before browsing so the site's analytics excludes the QA session. In Playwright, set it with context.addInitScript(() => localStorage.setItem('honestcv.qa','1')) BEFORE any goto — setting it after the first navigation lets that page's beacon fire unflagged and pollutes first-party analytics.
The "N free AI uses left" badge only renders when the AI API returns a numeric freeRemaining; for QA clients (with honestcv.qa set) the server returns null, so free-quota counter assertions cannot be verified on this identity — plan around it or flag as untested.
/builder and /ats-checker are lazy-loaded routes: waitForSelector for a form control (textarea, select, name input) before interacting — filling right after load races hydration and silently no-ops.
At 375px the builder preview pane is hidden behind the Edit|Preview switcher — assert loaded content via form input values, not body.innerText (the name lives in the 3rd input; the first two are copy-name/search fields).
Bundle tools (Cover letter / Interview prep) open for everyone (no lock icon), consuming the anonymous free AI quota (12 per client per 30 days, sent via x-client-id).
Standalone /ats-checker: check button disabled until resume text ≥30 chars; scoring is client-side.
SEO set expanded: /vs/resume-io, /vs/resume-genius, /guides/{ats-friendly-resume,resume-summary-examples,resume-keywords}, /templates/{classic,modern,compact,executive}; sitemap.xml has 18 URLs; IndexNow key at /88d13cb021bb7d759cc09d7b95af03fc.txt.
AI relay retest recipe (PR #118, glm-5.2 relay)
Fresh quota: delete localStorage['honestcv.clientId'] before loading /builder — a new id is generated and the 12-call free quota resets. Quota footer text ("N free AI rewrites left") decrements for summary/bullet rewrites; Cover letter / Interview prep calls did not visibly change the footer count in one run.
All three tools verified live: "AI polish summary" → 3-variant dialog (~30–60s); "Cover letter" / "Interview prep" buttons sit below the preview → dialog with Company field / Generate (~30–40s). Clicking Generate with an empty JD returns instantly with: Paste the job description in "Target job" first — both tools tailor to it.
The "AI polish summary" button appears disabled while the request is in flight — wait, don't reclick.
Anti-fabrication check: seed the summary with a vague claim (e.g. "significantly improved checkout conversion"); good output keeps it non-numeric or uses [add %], never invents a figure. Cover letter/interview brief should honestly acknowledge JD gaps (e.g. "have not worked in payments directly").
PR #5 features (AI variants, guidance, sub-scores, autosave)
AI multi-variant rewrite: "AI polish summary" / "AI rewrite bullets" send variants:true to /api/ai/rewrite; a "Pick a summary/rewrite" dialog shows 3 options labeled Concise / Impact-focused / Keyword-focused. Clicking one applies it. "AI clean up skills" stays single-output. One variants call = one quota unit; failed calls do NOT decrement quota.
AI relay 403s: the upstream relay may start returning fast (<1s) 403 errors ("The AI service returned an error (403). Please retry.") after a successful call — this reproduced for ~25 min in one run and is relay-side rate limiting/auth, not a payload issue. Diagnose by counting /api/ai/rewrite entries with performance.getEntriesByType('resource') (successes take 40–90s; 403s ~0.6–1.2s).
Bullet guidance is local/rule-based (src/lib/guidance.ts): a bullet starting with "responsible for" and containing no digit shows two amber "⚠ Line N:" warnings under the Experience textarea (max 2 issues per line, max 4 lines shown).
ATS card shows Keywords/Structure sub-scores and clickable + keyword chips that append to Skills and live-update the score. The standalone /ats-checker page does NOT show sub-scores (builder only).
Autosave: header "Saved" flips to "Saving…" while typing (400ms debounce); hidden below the sm breakpoint.
PR #6 features (undo, reorder, import, accent, final check)
Deployment cache: the live page may serve a stale JS bundle after a new deploy — hard refresh (Ctrl+Shift+R) and grep the bundle for a new-feature string (e.g. curl -s https://cv.zalize.com/assets/index-*.js | grep -c 'Final check before download') before testing.
Global undo: header Undo2 button (title "Undo (Ctrl+Z)"), disabled until an edit; snapshots are throttled (~700ms). Ctrl+Z triggers resume-undo only when focus is NOT in an input/textarea (inside a field it does native text undo) — blur by clicking page background first.
Reorder: ArrowUp/ArrowDown per Experience role header and Education row; disabled at list ends; verify order change in the live PREVIEW, not just the editor.
Import from text: "Import from text" button top-right of editor column; import replaces the whole resume and is undoable. Parenthesis-mangling parser bugs (phone losing leading (, Company ( residue from date ranges) were fixed in worker version 6be274e2 — but still check imported field contents character-for-character, not just non-emptiness (src/lib/importText.ts).
Accent swatches: 8 dots after the template buttons (aria-label Accent color #hex). Verify PDF carry-over objectively: decompress the PDF content stream and look for the accent's normalized RGB (e.g. #1d4ed8 ≈ 0.114 0.306 0.847 rg), or render the page to PNG.
Final check dialog: appears on PDF/DOCX click only when ATS structural checks fail or bullet-quality warnings exist (keyword score does NOT count). "Keep editing" closes without downloading; "Download anyway" downloads; a clean resume downloads immediately with no dialog.
Templates 4→8: Minimal / Bold / Elegant / Engineer added in src/lib/templates.ts with two new axes: headerAlign ('left' on all 4 new ones vs 'center' on the original 4) and nameCase ('upper' only on Bold). Verify per-template from preview pixels: left vs centered header, Bold uppercase name + thick #1d4ed8 rules. A user-chosen accent swatch overrides the template accent (so Elegant/Engineer may not show purple/green) — assert alignment/case/divider, not hue, unless localStorage accentColor is cleared.
Download carry-over: check header alignment in exports — PDF via pdftoppm render (uppercase name at left margin) and DOCX via word/document.xml (w:jc w:val="left", no center near the name).
/ats-checker sub-scores: with resume only → "Structure N/100" + hint "Add a job description to get a keyword match score." (no Keyword span); with a JD → both "Keyword match X/100" and "Structure Y/100".
pSEO: /templates/{minimal,bold,elegant,engineer} return 200 after trailing-slash redirect with distinct titles; each cross-links the other 7 templates and CTA-links /builder.
Mobile (~420px): template row wraps to 2 chip rows + swatch row with no horizontal overflow; note the first (black) swatch wraps up next to the "Engineer" chip and can read as a 9th template dot. Reorder/undo/delete icons are ~28–32px tap targets.
Drag reorder (Experience/Education): GripVertical handle at the card's upper-left is the ONLY drag source (draggable on the handle); the whole card is the drop target and highlights border-primary bg-primary/5 while hovered mid-drag. Arrows remain. To test a real HTML5 drag with computer-use: mouse_move onto the handle first, then left_mouse_down (no coordinate arg — it errors), several mouse_move steps over the target, zoom mid-drag to capture the highlight, then left_mouse_up. Negative-test by dragging from the card body (should do nothing).
Custom sections: "Custom sections (optional)" panel → title + one bullet per line textarea; renders as a template-styled heading + bullets in preview/PDF/DOCX. Delete (trash) removes it from editor, Section order list and preview. Verify exports via pdftotext and word/document.xml.
Section order panel: collapsed by default; lists the 6 built-ins (Summary, Experience, Projects, Education, Skills, Certifications) + one row per custom section. Supports both arrows and the same grip-handle drag. Order syncs to preview/PDF/DOCX (check heading byte offsets in document.xml). Empty sections (e.g. Certifications with no content) are omitted from outputs — not a bug.
Templates 8→12: Ivy (serif, center, title-case, green), Slate (sans, left, thick rules, gray), Corporate (serif, center, UPPERCASE name, thick rules, dark red), Startup (sans, left, no divider rules, orange). Accent-swatch override still applies — assert case/align/divider axes, not hue.
pSEO: /templates/{ivy,slate,corporate,startup} → 200, distinct titles, /builder CTAs, beacon script. Landing/paywall copy says "All 12" (grep bundle for absence of "All 4").
Templates 12→22: Horizon, Metro, Scholar, Ink, Coral, Atlas, Prairie, Quartz, Ruby, Cobalt. Horizon/Metro/Scholar/Ink/Ruby have band: true — accent-tinted band behind heading text. accentTint(hex) = 12% mix toward white (e.g. #0e7490 → #e2eef2). Band template thumbnails show a full-width tinted strip instead of the plain accent bar.
Band verification per surface: preview — h3 has inline background tint; PDF — decompress content streams and look for the tint's normalized RGB fill (e.g. 0.886 0.933 0.949 rg for #e2eef2) plus real drawText headings (pdftotext must extract them); DOCX — word/document.xml has <w:shd w:fill="e2eef2" w:val="clear"/> on heading paragraphs and NO bottom border there.
Deep-link pitfall: /builder?template=horizon applies Horizon, but clicking "Load an example resume" afterwards resets the template to Classic (example data carries its own templateId) — re-select the template after loading.
ScoreRing (/ats-checker): animated ring + count-up; span[role="img"] with aria-label="Score N out of 100". Screenshot immediately after clicking "Check my ATS score" to catch mid-count-up. Reduced motion: launch a second Chrome with --force-prefers-reduced-motion — final value + full ring on first frame.
375px emulation: Chrome's minimum window width (~500 CSS px) prevents wmctrl-resizing to 375 — instead use CDP Emulation.setDeviceMetricsOverride (websocket-client with suppress_origin=True to dodge the 403 origin check on port 29229); clear with Emulation.clearDeviceMetricsOverride.
axe without chromedriver: @axe-core/cli fails (no chromedriver on the box); instead inject axe.min.js (cdnjs) via CDP Runtime.evaluate and run axe.run() in the live page.
Static asset cache pitfall: after deploys, /og.png (and similar public/ assets) can stay stale at the Cloudflare edge (cf-cache-status: HIT) even when the JS bundle is fresh — compare live md5 against the branch file; a cache purge may be needed.
pSEO expanded: /templates/{horizon,metro,scholar,ink,coral,atlas,prairie,quartz,ruby,cobalt}/ all 200 with SVG layout previews and cross-links. Landing copy says "22" (grep bundle for absence of "All 12").
PR #126 / E1-E2 (AI Tailor, Resume health report)
Code-split pitfall: Builder is now a separate chunk (/assets/Builder-*.js) — grepping the main index-*.js bundle for feature strings ("Tailor to this job", "Full health report") finds nothing; fetch the Builder chunk URL from the index bundle's import list instead.
E1 Tailor: button in "Target job" section, disabled until JD pasted (hint "Paste a job description to enable tailoring"). Dialog → "Get tailoring suggestions" → busy "Analyzing your resume against the JD…" (~30-60s, 1 quota call). Rows: where label, original struck-through, emerald suggestion, Accept / Keep original per row + "Accept all remaining"; statuses "Applied to your resume" / "Kept your original". Accept updates editor field AND preview live. Empty resume content → instant inline error "Add a summary or experience bullets first — tailoring rewords your real content." (no quota use). Pitfall: after accepting a row the dialog layout shifts — re-locate buttons before clicking the next row.
E2 Health report: link under Resume strength card "Full health report — N/100 across 6 checks" → dialog "Resume health report — N/100" with 6 progressbars (Completeness, Quantified impact, Action verbs, Brevity, Buzzword-free, Consistency) + findings + heuristic disclaimer. Score reacts to content (cleared summary/bullets drops it; undo restores). All local, no AI.
Quota counter: footer "N free AI rewrites left" renders on load (fetched from GET /api/ai/quota since I1/PR #135, no consumption); any AI call decrements it by exactly 1.
CDP emulation stuck pitfall: Emulation.clearDeviceMetricsOverride from a new websocket connection may not clear an override set by a closed connection — send setDeviceMetricsOverride {width:0,height:0,deviceScaleFactor:0,mobile:false} then clearDeviceMetricsOverride on the same connection to restore desktop.
Fresh-user setup: set honestcv.qa='1' first, then clear everything else (localStorage.clear() then re-set qa). Onboarding keys: honestcv.tourDone (Dismiss), honestcv.shared (set on any download), honestcv.seen.tailor / honestcv.seen.health (badge one-timers). Checklist shows only when BOTH tourDone and shared are absent.
Checklist ([data-testid="getting-started"], top of /builder editor): 4 steps auto-check — name filled, JD pasted, Tailor button clicked (dialog can be closed without an AI call — the click alone sets the step and honestcv.seen.tailor), any download completed. Note: step 3 (tailorUsed) is React session state and un-checks after reload; steps 1/2/4 persist. Step 4 sets honestcv.shared, which also hides the checklist on next load.
Badges: "New" on Tailor button and "Full health report" link; first click sets the seen key and removes the badge immediately and across reloads.
Fresh-user download gate: two dialogs before the file lands — beta email gate ("Downloads are included in the beta trial", use qa-beta@zalize.com) then a "Final check before download" quality nudge (click "Download anyway"). Checklist step 4 only checks after the actual download.
To re-test the Dismiss flow separately, just remove the onboarding keys and reload — no need for a second browser profile.
PR #129 / Design system D1-D5 (fonts, motion, chips, explainers)
Fonts: self-hosted /fonts/inter-latin.woff2 + /fonts/sora-latin.woff2 (preloaded in index.html). App h1-h3 → Sora via h1:not([data-resume-preview] *) etc.; the resume preview headings must stay template fonts (Classic = Georgia). Check with document.fonts.check('700 16px Sora') + computed fontFamily on app h1 vs [data-resume-preview] h2/h3.
Hero rise-in: .animate-rise on badge/h1/p/CTA with --rise-delay 0/60/120/180ms; catch it by hard-reloading and screenshotting immediately (hero appears blank mid-animation).
HOVER PITFALL (environment): Tailwind v4 gates all hover: variants behind @media (hover: hover), and BOTH the managed Chrome and a plain second Chrome on this box report hover: none (matchMedia false) — the blink-settings hoverType flags and CDP Emulation.setEmulatedMedia hover features do NOT fix it. Workaround to demo hover styles: patch the stylesheet in-page (test-only): walk document.styleSheets CSSMediaRules and set mediaText='all' for the (hover:hover) rule, then hover normally and read computed translate/boxShadow. active: press styles (e.g. scale 0.98) are NOT media-gated and work natively (use mouse_move + left_mouse_down, read getComputedStyle(el).scale, then move off the link before mouse_up to avoid navigating).
Mobile chips: landing/builder filter chips are min-h-11 (44px) at 375px, sm:min-h-8 (32px) desktop — measure via CDP device metrics 375x812.
ATS expander: /ats-checker → "see an example score" (0 AI quota) → collapsed <details> "What do these scores mean?" under the sub-scores; expands to Keyword match / Structure / What to do ("Aim for 70+").
Health explainers: each of the 6 dimensions in the health dialog has an italic plain-language line (e.g. Quantified impact → "Numbers make claims believable — “cut costs 18%” beats “reduced costs” every time.").
Download gate: the beta email gate reappears even with honestcv.shared=1 set manually — the unlock is keyed separately (enter qa-beta@zalize.com once).
PR #130 / Text size (S/M/L) + Line spacing (Compact/Normal/Relaxed)
Controls: in the builder design bar after Letter/A4. S/M/L buttons have aria-label="Text size small/medium/large" + aria-pressed; Spacing buttons found by title starting with Compact/Normal/Relaxed + aria-pressed (no aria-label, named by visible text).
Preview check (objective): read computed style of [aria-label="Resume preview"] — zoom = 0.92/1/1.08 and lineHeight = 21.12/23.2/25.92px (root em 16px; component adds +0.1 to LINE_SPACING). IMPORTANT: read the style ~1s AFTER clicking — reading synchronously in the same evaluate returns the pre-render value.
Persistence pitfall: fontScale/lineSpacing are saved into honestcv.resume localStorage with a debounced autosave (~1-2s) — reloading immediately after a toggle click loses the last change. Wait 2-3s before F5 when testing persistence.
PDF verification: pdf-lib output streams don't grep for Tf easily; use pdftotext -bbox and compare the bounding box of a fixed word (e.g. the name "Jordan") between exports — S should be exactly 0.92× of M (width and height). Compare max yMax for line-spacing density, pdfinfo | grep Pages for page count.
DOCX verification: unzip word/document.xml; at L/Relaxed expect w:sz 21→23 (body), 22→24 (headings), 24→26 (title), 40→43 (name) and w:spacing w:line="270" (= round(240×1.52/1.35)); default is line=240 equivalents. python-docx (pip install python-docx) parses it for an "opens correctly" check without Word/LibreOffice.
CWV quick check: PerformanceObserver with buffered:true for largest-contentful-paint and layout-shift injected right after navigation; note results are warm-CDN, not lab-cold numbers.
PR #132 / ATS explainability, email-gate privacy, mobile pane switcher, themed example
ATS weights: in the Builder ATS card, ×70%/×30% spans render ONLY when a JD is pasted (ats.keywordScore !== null); without a JD only Structure N shows, no weights. The <details> "How this score is calculated" sits under the sub-score row and has two text variants (JD formula vs 6-point checklist); the "A 100 means every rule passes…" sentence appears only at score 100 — the easiest 100 is the no-JD structure-only state with the example resume.
/ats-checker: "What do these scores mean?" expander (after "see an example score") should list 4 items — Keyword match / Structure / How it's combined / What to do.
Email gate privacy: remove honestcv.subscribed then click PDF; the dialog footer must have the "What we send… never sold or shared… never leaves this browser" paragraph + Privacy policy link to /privacy/. Entering qa+…@example.com unlocks and immediately triggers the download.
Mobile pane switcher (375px): bottom bar [aria-label="Switch between editing and preview"] with Edit / "Preview & score" buttons (aria-pressed, min-h 44px); panes toggle via hidden lg:block — check offsetParent of main > div (editor) and #preview. Bar is lg:hidden; main has pb-20. The old floating Preview FAB should be gone.
Example template behavior (fixed in PR #133, worker c39f69bd): sampleResume() sets templateId:'modern'; the Builder loader now spreads ...(resume && resume.templateId !== emptyResume().templateId ? { templateId: resume.templateId } : {}). Expected: fresh/Classic state → example loads as Modern; a deliberately pre-picked non-Classic template (e.g. Startup) is preserved. Verify via JSON.parse(localStorage['honestcv.resume']).templateId after loading. (Original bug: the pre-#133 unconditional spread always kept Classic.)
Teal PDF check: extract content-stream color ops — inflate streams with python zlib and regex ([\d.]+ [\d.]+ [\d.]+) (rg|RG); Modern teal = 0.0588 0.4627 0.4314 (#0f766e). Monochrome-only ops (0.12/0.35 grays) = untheme bug. pdftoppm -png renders a visual proof page.
Quota counter (I1b): footer shows "N free AI rewrites left" on load before any AI call, fetched from GET /api/ai/quota with x-client-id: localStorage['honestcv.clientId']. Set a fresh random clientId to get a full quota (limit was 12 in free mode). The GET is read-only — repeated fetches must return the same freeRemaining. Fetch it from the page console (fetch('/api/ai/quota',{headers:{'x-client-id':localStorage.getItem('honestcv.clientId')}})) rather than curl.
Autosave flush (I1a): type a marker in Full name, immediately hide the tab (switch tab) or reload; the marker must survive in localStorage['honestcv.resume'] — the debounce now flushes on pagehide/visibilitychange-hidden.
⚠️ AI 503 quota-burn pitfall — FIXED by I4 (worker commit 3fc682f): pre-I4, failed 503 calls decremented quota (12→11→10). Post-I4, quota is consumed only after a successful upstream call — verified live: one Tailor 503 left /api/ai/quota at freeRemaining:12 and the footer counter unchanged after reload. Recipe: fresh random honestcv.clientId, read /api/ai/quota from the page console before/after one failed attempt. I5 adds one internal retry on 429/5xx (not directly observable from the UI). If an AI relay outage recurs, 503s are still production incidents; busy-line UI can't be verified while the backend is down.
Checklist step 3 persistence: post-#137 the Tailor step stays checked after reload (was session-only pre-#135 batch).
Volunteer guide: /guides/volunteer-work-on-resume/ — 12 TOC anchors under nav[aria-label="On this page"]; anchor click updates the URL hash and jumps; listed on /guides/ hub; @media (max-width:640px){.toc ol{columns:1}}.
/about/: static page with "About RezUp" h1, What we promise / How we compare / Press kit; footer "About" link on landing + builder.
Key flows and how to test them (paid mode)
Locked vs unlocked: header shows "Unlock — $9.99 once" when locked; after activation it shows a "Career Bundle" (or plan) badge and PDF/DOCX buttons work.
License activation: open the upgrade dialog (click PDF while locked or the Unlock button), use "Already paid? Re-activate with your license key". Seeded test keys (e.g. CV-QA01-TEST-2026-GATE) are KV-backed bundle licenses. Activation is instant, no reload needed.
Checkout (Lemon Squeezy — sole provider; Paddle was removed): click a buy button → LS overlay should open. ⚠️ This is LIVE — never enter card details. If it fails, check /api/billing/status (should be {"checkoutEnabled":true,"provider":"lemonsqueezy"}); checkout also requires all LS_* Worker secrets to be set.
Downloads: files land in ~/Downloads. Verify PDF with pdftotext file.pdf - (must extract real text) and DOCX by unzipping word/document.xml. Note: clicking PDF opens the PDF in a new Chrome tab AND downloads it — switch back to the builder tab before clicking DOCX.
AI endpoints (/api/ai/rewrite, cover letter, interview prep): relayed to an LLM (model set in wrangler.jsonc LLM_MODEL). These are SLOW (~60s for a summary rewrite) and longer generations may fail with a Cloudflare 524 timeout — retry once, but repeated 524s are a real product issue, not an environment problem. 5 free rewrites per client when locked; 402 → upgrade dialog after exhaustion.
Bundle tools (Cover letter / Interview prep) require an active bundle license AND a pasted job description in "Target job".
ATS score is computed client-side and updates live when the JD or resume fields change.
Mobile check: wmctrl -r :ACTIVE: -b remove,maximized_vert,maximized_horz && wmctrl -r :ACTIVE: -e 0,100,0,390,760, then verify document.documentElement.scrollWidth <= window.innerWidth.
SEO: static pages at /vs/zety, /vs/livecareer, /resume-builder-one-time-payment, /free-ats-resume-checker; sitemap.xml lists 5 URLs; robots.txt allows all.
CSP pitfall (cross-origin features): cv.zalize.com ships a strict Content-Security-Policy with connect-src 'self' (check with ). Any new feature that fetches an external API from the browser (e.g. "pull from Resume Center" hitting resume-forge.wookat520.workers.dev) will fail with a generic even when the remote API sends . When you see , always distinguish CSP vs CORS: curl the API with an header (CORS OK ⇒ suspect CSP), then check the CSP header. Fix requires adding the API origin to in the server/edge config that sets the CSP.
Devin Secrets Needed
None — the seeded test license key is provided by the user per run.
R84 font/export QA notes
PDF/DOCX download buttons trigger the launch email gate on a fresh client — enter a throwaway email then "Download anyway"; files land in ~/Downloads. Verify fonts objectively with pdffonts (preinstalled) and unzip -p file.docx word/document.xml | grep w:rFonts.
The rendered ResumePreview root is the only div with an inline style.fontFamily — use that selector to assert the active font family.
On an empty throwaway client, "Load an example resume" resets designer settings (template/fontFamily) — load the example BEFORE toggling design options.
Budget-limited AI-call QA: arm a background CDP Network.enable listener (capture requestWillBeSent postData + getResponseBody on loadingFinished) BEFORE the single UI click, and cross-check whole-run AI traffic with performance.getEntriesByType('resource') — reliable as long as no page reload occurs between example load and the check.
Builder undo (Ctrl+Z) QA: keep focus on the just-clicked button and press Ctrl+Z immediately — do NOT click "neutral" whitespace first (the top-left logo navigates away and wipes the in-memory undo history; section headers toggle collapse).
Entry drag-reorder needs a slow drag: mouse-down on the ⠿ handle, several small mouse_move steps, brief pause before mouse_up; a fast drop is silently ignored.
Since R101, Copies-dialog rows have a ghost Pencil rename button (aria-label="Rename copy <name>", 40×40 mobile / 28×28 desktop) — a stable selector for measurements. updateResumeVersion bumps updatedAt on ANY patch including a rename, so a timestamp bump after rename is not data corruption — assert the copy's data deep-equal instead. Escape inside the rename input cancels the rename and keeps the dialog open (via onEscapeKeyDown+preventDefault() on the DialogContent — a child's React-keydown stopPropagation() can never block Radix dismissal because DismissableLayer uses a capture-phase document keydown listener; always test the real keypress in the browser rather than trusting such a code change).
Since R102, Copies-dialog edit mode is a two-input container (aria-label="Rename <name>" / "Folder for <name>", datalist #builder-version-folders of existing folder names) whose container onBlur commits only when relatedTarget leaves the container — Tab between the inputs is safe, any outside click commits. A copy's folder is an optional key on the record (cleared = key absent after the JSON round-trip); the row metadata line renders . Dashboard folder chips live in , and both grid and list views show in the card metadata.
Este SKILL.md es muy grande, por eso SkillsMP muestra aqui solo la primera seccion.Ver en GitHub
button[aria-label^="Remove saved coursework"]
name — institution
honestcv.publicationLibrary
publications
data
Save publication N to library
button[aria-label^="Remove saved publication"]
title — venue
title — venue
honestcv.certLibrary
certItems
data
Save certification N to library
button[aria-label^="Remove saved certification"]
name — issuer
name — issuer
honestcv.referenceLibrary
data
kind
''|'personal'|'professional'
''
Save reference N to library
button[aria-label^="Remove saved reference"]
name — employer
name — title, employer
email · phone · Personal/Professional reference
honestcv.awardLibrary
data
Save award N to library
button[aria-label^="Remove saved award"]
name — organization
document.scripts
index-*.js
AI busy line: Tailor dialog shows role=status "Usually takes 10–20 seconds…" while busy — screenshot within the first seconds of the call.
CDP note: newer QA Chrome rejects websocket Origin — pass suppress_origin=True to websocket.create_connection. Debug port is in ps aux | grep remote-debugging-port (e.g. 29229). Inject axe by fetching axe.min.js locally and sending it via Runtime.evaluate (page CSP blocks external script tags from the console).
Import dialog (Builder): open via the Import resume (PDF/DOCX/text) button. The "or pull from Resume Center" row sits between the Upload button row and the paste textarea; error text inserts below that row and shifts layout, so re-locate elements after an error appears. parseShareId (src/lib/resumeCenter.ts) accepts bare 4–64 char ids and URLs containing /s/ or /api/export/ — as of Aug 2026 it rejected canonical resume-forge /share/:id links (and the /s/:id route 404s on resume-forge itself); re-check both if testing this feature.
Byte-level restore proof: before deleting the qa.<round>.backup key, compute diffs/extra against it in the same CDP evaluation (restore → compare → only then remove qa.*) so restoration is provable rather than inferred from key counts.
Ending mobile emulation: a plain pkill of the CDP-hold process has restored desktop width cleanly in recent rounds — try that first, and fall back to the documented metric-reset workaround only if innerWidth stays 375 (a leftover hold process can survive the first pkill — check and kill -9 before the metric reset).
Assistant panel QA: the empty chat shows stacked quick-task buttons; once the chat is non-empty the same QUICK_TASKS render as rounded-full pills pinned above the composer (R78) — either sends the exact prompt through the same request path.
Asserting scoreSummary in captured /api/ai/assistant bodies: it is the LAST key in the JSON body and gets cut once chat turns grow — don't truncate postData logs; grep the raw Network.getRequestPostData result.
BulletGuidance (builder Experience) caps output at 4 flagged lines and 2 issues per line, issue order per line: weak-opener → first-person → no-metric → filler → buzzword → punctuation → length. To prove a specific flag, craft a bullet where it lands in the first two (e.g. punctuation needs a bullet with a metric and no buzzword).
BUZZWORDS matching reports the FIRST array match ('synergy' sorts before 'team player'), so assert on the actual matched term, not the one you typed first.
pgrep -f <pattern> inside a one-shot exec matches its own bash wrapper — use pgrep -af 'python3 /tmp/<script>' to find real leftover CDP-hold processes; stale holds from prior rounds can survive and must be killed before new mobile emulation.
Each assistant send costs 1 free AI quota — run assistant tests on a throwaway clientId (clear honestcv.* → fresh client gets a full quota) and restore the baseline afterwards.
Assistant replies often return in <2s, so a post-click DOM probe misses the busy state — arm a ~25ms setInterval poller sampling disabled on the target buttons BEFORE clicking, then read the samples.
Do NOT use CDP offline emulation to fake a stuck assistant request: Chrome queues the fetch and delivers it after network restore, consuming a real AI quota send.
To render chat-dependent UI without spending quota, save honestcv.assistantChat JSON before Clear chat and restore it via CDP + reload.
BulletGuidance's positive state (R80) is a p.text-emerald-700, not an li — assert green vs amber with p.text-emerald-700 and li.text-amber-700 selectors.
Clearing honestcv.* via CDP does not refresh the visible page — always reload after the clear before asserting throwaway state.
Never combine pkill/pgrep -f <script> with follow-up commands in one exec call — the pattern matches the wrapper bash -c and kills/false-positives itself; filter with | grep -v 'bash -c' and kill in a separate call.
In the Experience editor, filling role/company inserts warning lines that shift layout — the "Need ideas? Show bullet starters" link sits directly above the AI buttons and is easy to misclick; re-screenshot after any field edit before clicking AI buttons.
A successful AI call also fires a non-AI POST /api/ev {"e":"ai-use"} analytics beacon — don't count it as an extra AI request.
/api/ai/suggest-bullet (R81) returns {text, freeRemaining}; capture the response body via Network.getResponseBody right after responseReceived to byte-verify the appended line matches the server text.
To assert the ATS "Fix →" jump ring (R82), poll for the class substring ring-primary/60 together with ring-2 — plain .ring-2 matches ~7 permanent elements and gives false positives.
Screen-tool↔CSS coordinate mapping at desktop 1600: tool = css×0.64 with a +88px vertical browser-chrome offset — use CDP getBoundingClientRect plus this mapping when a small link misclicks.
The top-nav "ATS Checker" link navigates the SAME tab away from /builder — navigate back before builder-targeted CDP lookups.
The type action into Builder textareas occasionally drops the first character of a line — verify typed text via localStorage/DOM before asserting.
The HealthDialog (score breakdown) is reachable two ways: "Full health report — N/100" link in the Resume strength card (desktop left column) and "See full score breakdown" in the ATS score card (the only practical entry on the mobile Preview & score pane). Its Fix buttons are min-h-10 (40px) on mobile but 16px on desktop (sm:min-h-0) — assert touch targets only under 375px emulation.
folder
honestcv.resumeVersions
<date> · <folder> · ATS n/100
role=group aria-label="Filter copies by folder"
· folder
Since R103, Copies-dialog rows also have a ghost Copy duplicate button (aria-label="Duplicate copy <name>", 40×40 mobile / 28×28 desktop, between the pencil and Open). duplicateResumeVersion spreads {...source} so all fields including folder carry over with a fresh id/name/updatedAt — duplicates sort to the top of "Last edited" views, and the builder dialog and dashboard card duplicate share this lib fn. The duplicate is never auto-opened: activeVersionId and the editor draft stay on the source.
Since R104, the Target job section has a native <select id="experienceLevel"> (label "Experience level", 44px mobile / 36px desktop). Its value annotates the role field of ALL AI POST bodies via aiTargetRole(): "<role> (<label>)" when a level is set, "<label> position" when the role is empty, plain role when Auto. Cheapest way to verify AI payloads without burning drafts: hook window.fetch to capture /api/ bodies, use "AI polish summary" (rewrite endpoint), and dismiss the variants dialog without applying; "Draft from my resume" only appears when the summary is empty.
Since R105, the Target job section has a #targetCompany Input (label "Company", 44px mobile / 36px desktop like the role input) whose value aiTargetRole() appends as " at <Company>" to the AI role string ("Position at <Company>" when everything else is empty). Zero-quota payload capture: wrap window.fetch to intercept /api/ai/* bodies and Promise.reject before the network call — the app shows a harmless error toast, server-side quota is untouched, and you can assert many state combos with one real call as ground truth. saveResume does NOT sanitize (only loadResume does), so "property absent" invariants (empty targetCompany, invalid experienceLevel) only materialize after a reload + next save. BundleToolDialog resets its Company field from initialCompany on every open since R105, so cover→resignation no longer leaks the target company.