The browser drive covers caret placement too
fd1d21a — clicking the page margins places the caret, and the editable surface drops its focus ring — shipped with no regression coverage, because it was verified with throwaway scripts that were then deleted. Its author sent over the six assertions they had actually run. This adds them to the harness that now exists, so the coverage outlives the session that wrote the fix. Every one was confirmed to FAIL against deliberately broken code before being kept, which is the rule this branch put in AGENTS.md two commits ago and is worth more than the checks themselves: neutering placeCaretFromPage turns the two margin checks and the typing-lands-there check red, and removing the focus handler's pointer guard yanks the view 1508px and turns the no-yank check red. The check that a click below the last paragraph still works stays green under both, correctly — that path is inside the editable node and belongs to ProseMirror. Three things had to change on the way, all of them the same mistake in different clothes — a probe that could not tell "no" from "I cannot see it": - The caret probe walked up from the selection to find its block, and ran straight past the editor when the caret sat ON it, between two blocks. It reported -1, which reads as "the caret went somewhere else" when the caret was fine. - Two checks aimed at fixed coordinates. A point below the viewport receives a click and does nothing, which looks exactly like broken caret placement; a double click between words selects nothing, which looks exactly like broken selection. Both now search for a point that is actually on screen and clear of the sticky formatting bar, and the window size is pinned so the geometry does not vary by machine. - Two checks asserted on the document's whole markup rather than on the block being changed. "Does the HTML contain <h2>" passes without the control having done anything on a document that already contains one, and its undo is then impossible to observe. Both now watch one block's tag name. The drive also picks the longest seeded document rather than the first: on a short one everything is on screen, and the scroll-related checks pass while measuring nothing. That the window is the scrolling element — which is what makes the no-yank check meaningful at all — is now verified rather than assumed, and written down where the check can see it. One consequence of this branch worth recording: the formatting bar sits between the page header and the paper, so Tab from sign-out now lands on the toolbar rather than in the document. The caret session's sixth check was written as "Tab in from sign-out"; the guarantee underneath it is "a focus that did not come from a pointer scrolls the caret into view", so it is asserted that way instead and survives the next change to the tab order.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YYB9fUAfxXNzkeC3fsFYzR
fixtures/browser-drive.mjs+305 −15scripts/browser-drive.sh+2 −1
CI passed — run details
Intent
<cross-session-message from="uds:/run/user/1001/cc-socks/893751.sock" from-name="click positioning cursor blue box" from-mode="bypass"> Confirmed your undo finding independently, and here are the six caret checks to drop into browser-drive.mjs whenever it suits you. I checked the y-prosemirror source rather than take it on trust: `undoCommand = (state, dispatch) => dispatch == null ? ...canUndo() : undo(state)` is indeed the dispatch-aware pair, and prosemirror-keymap calls `direct(view.state, view.dispatch, view)` — always a real dispatch, never null. So main's Mod-z/Mod-y bindings are correct as they stand and your fix is scoped to the toolbar's own call sites, which is the right place for it. Good catch, and the AGENTS.md entry is the right home for it: it is exactly a tool answering wrongly instead of failing. The harness is a better outcome than what I did — I verified my branch with throwaway playwright scripts I then deleted, so my fix currently has zero regression coverage on main. Since you own that file, here is what I actually asserted, in the order I ran it. Feel free to take, reshape or ignore: 1. Left-margin click, level with the first line of the third block → caret lands at offset 0 of THAT block. Click at `[docs-page]`'s left edge + 20px, `block.top + 8`. Offset 0 is the assertion that matters; "focused" alone passes even when the caret went to the end of the document. 2. Click in the empty space below the last paragraph but inside the editor → caret at the end of the last block. Only meaningful after `window.scrollTo(0, document.body.scrollHeight)`, and mind the viewport: my first attempt clicked below the visible area and silently landed on nothing, which reads exactly like the bug. 3. Click in the bottom margin, below the editable node entirely → same result. This is the path that goes through placeCaretFromPage rather than through ProseMirror. 4. Typing after 2 or 3 lands in the last block, not merely somewhere. `ed.lastElementChild.textContent.includes(...)`, not `ed.innerText.includes(...)`. 5. The no-yank regression: leave the selection at the end, blur, scroll to top, click in the FIRST block → `window.scrollY` must stay ~0. This one fails loudly if anyone ever makes the focus handler unconditional, and it is the check I would most want kept. 6. Tab in from the header's sign-out button with the selection off-screen → the caret's `getBoundingClientRect()` must be inside the viewport. That is the WCAG guarantee standing in for the focus ring; without it the ring's removal is a real AA regression. Plus the cheap one: `getComputedStyle(ed).outlineStyle === 'none'` while focused. Two notes if you do add them. Use the longest seeded document — on a short one, checks 5 and 6 pass trivially because everything is on screen, which is how I nearly convinced myself the focus handler was fine before I lengthened the fixture. And `document.getSelection()` is the honest probe rather than PM's own state: it is what a person actually sees, and it catches the case where the selection is set correctly but the DOM never followed. Agreed that Chrome on the runner is Paul's call — task 13 is the right place for it. I have not touched your branch or main since fd1d21a. </cross-session-message>
Attributed to this commit: 258 g CO₂e (27930898 tokens). This is an attribution, not a measurement — usage counted from this commit's own marker forward to the next one on formatting-and-export, never backward from it. A turn that produced more than one commit books all of its tokens to the last one, and a concurrent session on this branch can add to the same figure. Some of it was priced using the shared default rate, because no model-specific factor row exists yet for at least one model involved. The Carbon view has the repo total, which is never derived from this attribution.
diff --git a/fixtures/browser-drive.mjs b/fixtures/browser-drive.mjs| index a627fff..d4882e9 100644 |
| --- a/fixtures/browser-drive.mjs |
| +++ b/fixtures/browser-drive.mjs |
| @@ -107,21 +107,124 @@ async function settle(describe, predicate, timeoutMs = 5000) { |
| } |
| } |
| +// settleOn polls one probe until it satisfies a predicate. settle |
| +// above watches the whole document; this watches one value, which is |
| +// what an assertion about a single block needs. |
| +async function settleOn(describe, probe, predicate, timeoutMs = 5000) { |
| + const deadline = Date.now() + timeoutMs |
| + let last |
| + for (;;) { |
| + last = await probe() |
| + if (predicate(last)) return last |
| + if (Date.now() > deadline) { |
| + throw new Error(`timed out waiting for ${describe}\n it is: ${JSON.stringify(last)}`) |
| + } |
| + await sleep(150) |
| + } |
| +} |
| + |
| +// settleScrollToTop blurs the editor, scrolls to the top, and waits |
| +// until the position actually holds still. |
| +// |
| +// The page scrolls the window itself — verified, not assumed: nothing |
| +// between [docs-page] and the document element is a scroll container, |
| +// document.scrollingElement is the document element, and |
| +// window.scrollTo moves window.scrollY. That matters because the |
| +// no-yank check below asserts on window.scrollY, and on a layout where |
| +// some inner element did the scrolling that check would pass while |
| +// measuring nothing at all. |
| +async function settleScrollToTop() { |
| + await evaluate(`document.querySelector('[docs-editor]').blur();window.scrollTo(0,0)`) |
| + for (let i = 0; i < 20; i++) { |
| + await sleep(100) |
| + if ((await evaluate(`window.scrollY`)) === 0) { |
| + // Two consecutive readings, because a scroll still in flight |
| + // reads zero once on its way past. |
| + await sleep(100) |
| + if ((await evaluate(`window.scrollY`)) === 0) return |
| + } |
| + await evaluate(`window.scrollTo(0,0)`) |
| + } |
| + throw new Error('the page would not stay scrolled to the top') |
| +} |
| + |
| +// blockTag is the tag name of one top-level block of the document. |
| +async function blockTag(index) { |
| + return evaluate( |
| + `(()=>{const b=document.querySelectorAll('[docs-editor] > *')[${index}];return b?b.tagName:null})()`, |
| + ) |
| +} |
| + |
| const checks = [] |
| function check(name, ok, detail) { |
| checks.push({ name, ok, detail }) |
| console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${ok || !detail ? '' : ' — ' + detail}`) |
| } |
| +// caretInfo reports where the caret is, read from the DOM selection |
| +// rather than from ProseMirror's own state. |
| +// |
| +// The DOM is the honest probe: it is what a person actually sees, and |
| +// it catches the case where the selection is set correctly in the |
| +// editor's state but the DOM never followed. (Credited to the |
| +// caret-placement session, which learned it the hard way.) |
| +const CARET_INFO = `(()=>{ |
| + const sel = document.getSelection() |
| + const ed = document.querySelector('[docs-editor]') |
| + if (!sel || sel.rangeCount === 0 || !ed) return null |
| + const r = sel.getRangeAt(0) |
| + const blocks = Array.from(ed.children) |
| + const rect = r.getBoundingClientRect() |
| + // The caret can sit ON the editor element itself, between two |
| + // blocks, in which case startOffset is a child index rather than a |
| + // text offset. Walking up from there runs straight past the editor |
| + // and reports -1, which reads as "the caret went somewhere else |
| + // entirely" when it did not. |
| + if (r.startContainer === ed) { |
| + return { blockIndex: r.startOffset, blockCount: blocks.length, offset: 0, |
| + onBoundary: true, collapsed: r.collapsed, top: rect.top, bottom: rect.bottom } |
| + } |
| + let node = r.startContainer |
| + while (node && node.parentElement && node.parentElement !== ed) node = node.parentElement |
| + return { |
| + blockIndex: blocks.indexOf(node), |
| + blockCount: blocks.length, |
| + offset: r.startOffset, |
| + onBoundary: false, |
| + inEditor: ed.contains(r.startContainer), |
| + collapsed: r.collapsed, |
| + top: rect.top, |
| + bottom: rect.bottom, |
| + } |
| +})()` |
| + |
| +// pickLongestDocument opens the longest of the seeded documents. |
| +// |
| +// On a short one, the scroll-related checks below pass trivially |
| +// because the whole document is already on screen — which is exactly |
| +// how the caret session nearly convinced itself an unconditional focus |
| +// handler was fine. Measured by fetching each document's own |
| +// server-rendered page rather than by mounting the editor six times. |
| +async function pickLongestDocument() { |
| + return evaluate(`(async () => { |
| + const hrefs = Array.from(document.querySelectorAll('a[href^="/d/"]')).map(a => a.getAttribute('href')) |
| + let best = null, bestLen = -1 |
| + for (const href of hrefs) { |
| + const res = await fetch(href, { credentials: 'same-origin' }) |
| + const html = await res.text() |
| + if (html.length > bestLen) { bestLen = html.length; best = href } |
| + } |
| + return best |
| + })()`) |
| +} |
| + |
| async function run() { |
| await send('Page.enable') |
| await send('Runtime.enable') |
| await send('Page.navigate', { url: base + '/' }) |
| await sleep(1500) |
| - const href = await evaluate( |
| - `(()=>{const a=document.querySelector('a[href^="/d/"]');return a?a.getAttribute('href'):null})()`, |
| - ) |
| + const href = await pickLongestDocument() |
| if (!href) throw new Error('the document list has no documents to open') |
| await send('Page.navigate', { url: base + href }) |
| await sleep(2500) |
| @@ -133,16 +236,44 @@ async function run() { |
| !(await evaluate(`document.querySelector('[docs-editor]').hasAttribute('data-editor-failed')`)), |
| ) |
| - // Double-click a word: the ordinary way a person selects one. |
| - const wordAt = await evaluate( |
| - `(()=>{const e=document.querySelector('[docs-editor] p');const r=e.getBoundingClientRect();` + |
| - `return {x:r.left+40,y:r.top+8}})()`, |
| + // Work on a paragraph chosen by its own type rather than on |
| + // whatever happens to be first: these documents open with a heading, |
| + // and a "make this a heading" check aimed at one is a no-op that |
| + // passes. |
| + const paragraphIndex = await evaluate( |
| + `Array.from(document.querySelectorAll('[docs-editor] > *')).findIndex(e => e.tagName === 'P')`, |
| ) |
| - await click(wordAt.x, wordAt.y, 1) |
| - await click(wordAt.x, wordAt.y, 2) |
| - await sleep(400) |
| - const word = await evaluate(`window.getSelection().toString()`) |
| - check('a double click selects a word', word.length > 0, `selected ${JSON.stringify(word)}`) |
| + if (paragraphIndex < 0) throw new Error('the document has no paragraph to edit') |
| + |
| + // Double-click a word: the ordinary way a person selects one. |
| + // |
| + // Tried at a few offsets along the line rather than at one fixed |
| + // point. A double click that lands between words, on an indent, or |
| + // past the end of a short line selects nothing — which is a fact |
| + // about where the pointer went, not about the editor, and a check |
| + // that cannot tell those apart is worse than no check. |
| + let wordAt = null |
| + let word = '' |
| + for (const dx of [60, 120, 200, 300]) { |
| + const at = await evaluate( |
| + `(()=>{const e=document.querySelectorAll('[docs-editor] > *')[${paragraphIndex}];` + |
| + `const r=e.getBoundingClientRect();const t=document.querySelector('[docs-toolbar]');` + |
| + `const floor=t?t.getBoundingClientRect().bottom:0;const y=r.top+8;` + |
| + `if(y<floor+4||y>window.innerHeight-4||${dx}>r.width-20) return null;` + |
| + `return {x:r.left+${dx},y}})()`, |
| + ) |
| + if (!at) continue |
| + await click(at.x, at.y, 1) |
| + await click(at.x, at.y, 2) |
| + await sleep(400) |
| + word = await evaluate(`window.getSelection().toString()`) |
| + if (word.trim().length > 0) { |
| + wordAt = at |
| + break |
| + } |
| + } |
| + check('a double click selects a word', !!wordAt, `selected ${JSON.stringify(word)}`) |
| + if (!wordAt) throw new Error('could not select a word to format') |
| // Typing. This is the one that was broken, and it is not a toolbar |
| // feature at all — which is exactly why it is asserted here. |
| @@ -184,13 +315,19 @@ async function run() { |
| check('the font control applies the sans mark', false, err.message) |
| } |
| - // A heading, through the block select. |
| + // A heading, through the block select — asserted on the ONE block |
| + // being changed, not on the document's markup as a whole. A seeded |
| + // document that already contains an <h2> makes a |
| + // "does the HTML contain <h2>" check pass without the control having |
| + // done anything, and makes its undo impossible to observe. Both of |
| + // those happened here, on the second document this drive was pointed |
| + // at, which is a good argument for the longest fixture. |
| await evaluate( |
| `(()=>{const s=document.querySelector('[data-docs-select="block"]');s.value='heading-2';` + |
| `s.dispatchEvent(new Event('change',{bubbles:true}));return true})()`, |
| ) |
| try { |
| - await settle('the heading to apply', (html) => html.includes('<h2>')) |
| + await settleOn(`the block to become a heading`, () => blockTag(paragraphIndex), (tag) => tag === 'H2') |
| check('the style control makes a heading', true) |
| } catch (err) { |
| check('the style control makes a heading', false, err.message) |
| @@ -202,15 +339,168 @@ async function run() { |
| ))) |
| await clickSelector('[data-docs-command="undo"]') |
| try { |
| - await settle('the heading to be undone', (html) => !html.includes('<h2>')) |
| + await settleOn(`the heading to be undone`, () => blockTag(paragraphIndex), (tag) => tag === 'P') |
| check('undo reverses the last change', true) |
| } catch (err) { |
| check('undo reverses the last change', false, err.message) |
| } |
| + await caretChecks() |
| + |
| check('the page reported no JavaScript errors', pageProblems.length === 0, pageProblems.join(' | ')) |
| } |
| +// The caret-placement checks. |
| +// |
| +// These cover fd1d21a (clicking the page margins places the caret, and |
| +// the focus ring is gone from the editable surface), which shipped with |
| +// no regression coverage because it was verified with throwaway |
| +// scripts. The assertions are the ones that branch's author actually |
| +// ran, contributed to this harness afterwards; the reasoning in each |
| +// comment is theirs. |
| +async function caretChecks() { |
| + const page = '[docs-page]' |
| + const ed = '[docs-editor]' |
| + |
| + // 1. A click in the left margin, level with a block, puts the caret |
| + // at the START of that block. "Is the editor focused" is not the |
| + // assertion — that passes even when the caret went to the end of |
| + // the document, which is the bug. |
| + // Settle the scroll position BEFORE measuring. Every toolbar command |
| + // ends in view.focus(), and a focus the editor did not attribute to a |
| + // pointer scrolls the caret into view — so a coordinate measured |
| + // immediately after one can be stale by the time it is clicked, and |
| + // the click then lands on the sticky formatting bar instead of the |
| + // paper. That produced a failure that looked exactly like broken |
| + // caret placement and was not. |
| + await settleScrollToTop() |
| + // Aim at a block that is actually on screen and clear of the sticky |
| + // formatting bar, rather than at a fixed index. A point below the |
| + // viewport receives the click and does nothing at all — which looks |
| + // identical to the caret being placed wrongly, and is the trap this |
| + // check exists to avoid rather than to fall into. Which block it |
| + // lands on is not the point; that it lands at the START of the one |
| + // beside the pointer is. |
| + const target = await evaluate( |
| + `(()=>{const p=document.querySelector('${page}');const t=document.querySelector('[docs-toolbar]');` + |
| + `const floor=t?t.getBoundingClientRect().bottom:0;` + |
| + `const blocks=Array.from(document.querySelectorAll('${ed} > *'));` + |
| + `for(let i=0;i<blocks.length;i++){const br=blocks[i].getBoundingClientRect();` + |
| + `if(br.top>floor+12 && br.bottom<window.innerHeight-12 && blocks[i].textContent.trim().length>0){` + |
| + `const pr=p.getBoundingClientRect();return {index:i,x:pr.left+20,y:br.top+8}}}return null})()`, |
| + ) |
| + if (!target) throw new Error('no block is both on screen and clear of the formatting bar') |
| + await click(target.x, target.y) |
| + await sleep(400) |
| + const fromMargin = await evaluate(CARET_INFO) |
| + check( |
| + 'a click in the left margin puts the caret at the start of that line', |
| + !!fromMargin && fromMargin.blockIndex === target.index && fromMargin.offset === 0, |
| + `aimed at block ${target.index}, got ${JSON.stringify(fromMargin)}`, |
| + ) |
| + |
| + // 2. A click in the empty space below the last paragraph but still |
| + // inside the editable node. Only meaningful once scrolled to the |
| + // bottom — a click below the viewport lands on nothing at all, |
| + // which reads exactly like the bug it is supposed to catch. |
| + await evaluate(`window.scrollTo(0, document.body.scrollHeight)`) |
| + await sleep(400) |
| + const belowText = await evaluate( |
| + `(()=>{const e=document.querySelector('${ed}');const r=e.getBoundingClientRect();` + |
| + `const y=r.bottom-12;if(y<0||y>window.innerHeight) return null;return {x:r.left+40,y}})()`, |
| + ) |
| + if (belowText) { |
| + await click(belowText.x, belowText.y) |
| + await sleep(400) |
| + const c = await evaluate(CARET_INFO) |
| + check( |
| + 'a click below the last paragraph puts the caret in the last block', |
| + !!c && c.blockIndex === c.blockCount - 1, |
| + JSON.stringify(c), |
| + ) |
| + } else { |
| + check('a click below the last paragraph puts the caret in the last block', false, 'the point was off screen') |
| + } |
| + |
| + // 3. A click in the page's bottom margin, outside the editable node |
| + // entirely. This is the path that goes through placeCaretFromPage |
| + // rather than through ProseMirror's own hit testing. |
| + const bottomMargin = await evaluate( |
| + `(()=>{const p=document.querySelector('${page}');const e=document.querySelector('${ed}');` + |
| + `const pr=p.getBoundingClientRect();const er=e.getBoundingClientRect();` + |
| + `const y=(er.bottom+pr.bottom)/2;if(y<0||y>window.innerHeight) return null;return {x:pr.left+80,y}})()`, |
| + ) |
| + if (bottomMargin) { |
| + await click(bottomMargin.x, bottomMargin.y) |
| + await sleep(400) |
| + const c = await evaluate(CARET_INFO) |
| + check( |
| + 'a click in the bottom margin puts the caret in the last block', |
| + !!c && c.blockIndex === c.blockCount - 1, |
| + JSON.stringify(c), |
| + ) |
| + } else { |
| + check('a click in the bottom margin puts the caret in the last block', false, 'the point was off screen') |
| + } |
| + |
| + // 4. And typing then lands THERE, not merely somewhere. The last |
| + // element's own text, not the whole editor's, or a character that |
| + // went to the top of the document would still pass. |
| + await send('Input.dispatchKeyEvent', { type: 'keyDown', text: 'Q', key: 'Q', code: 'KeyQ', windowsVirtualKeyCode: 81 }) |
| + await send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Q', code: 'KeyQ', windowsVirtualKeyCode: 81 }) |
| + await sleep(1200) |
| + const lastBlockText = await evaluate(`document.querySelector('${ed}').lastElementChild.textContent`) |
| + check('typing after that click lands in the last block', lastBlockText.includes('Q')) |
| + |
| + // 5. The no-yank regression, and the one most worth keeping. A focus |
| + // event fires BEFORE ProseMirror sets the selection a click asked |
| + // for, so a focus handler that scrolls unconditionally yanks the |
| + // view to wherever the selection was last left. With the selection |
| + // at the end of a long document, clicking in the first block must |
| + // leave the page where it is. |
| + await settleScrollToTop() |
| + const firstBlock = await evaluate( |
| + `(()=>{const b=document.querySelector('${ed} > *');const r=b.getBoundingClientRect();return {x:r.left+40,y:r.top+8}})()`, |
| + ) |
| + await click(firstBlock.x, firstBlock.y) |
| + await sleep(600) |
| + const scrolledTo = await evaluate(`window.scrollY`) |
| + check( |
| + 'clicking near the top does not yank the view to the old selection', |
| + scrolledTo < 40, |
| + `scrollY became ${scrolledTo}`, |
| + ) |
| + |
| + // 6. The other half of that trade: the editable surface has no focus |
| + // ring (views/show.html removes it, on the argument that a caret |
| + // says more), so a focus that did NOT come from a pointer has to |
| + // scroll the caret into view — otherwise focus lands with no |
| + // visible indicator anywhere on screen, which is a WCAG 2.2 AA |
| + // regression and AGENTS.md allows no exemptions. |
| + // |
| + // The caret session ran this as Tab from the sign-out button. That |
| + // no longer reaches the editor: the formatting bar sits between |
| + // the header and the paper, so Tab from sign-out now lands on the |
| + // toolbar. The guarantee being checked is not about the tab order |
| + // though — it is "a non-pointer focus scrolls the caret into |
| + // view" — so it is exercised directly here, and stays true however |
| + // the tab order changes next. |
| + await settleScrollToTop() |
| + await evaluate(`document.querySelector('${ed}').focus()`) |
| + await sleep(700) |
| + const caret = await evaluate(CARET_INFO) |
| + check( |
| + 'a keyboard focus scrolls the caret into view', |
| + !!caret && caret.top >= 0 && caret.bottom <= (await evaluate(`window.innerHeight`)), |
| + JSON.stringify(caret), |
| + ) |
| + |
| + check( |
| + 'the editable surface draws no focus ring (the caret is the indicator)', |
| + (await evaluate(`getComputedStyle(document.querySelector('${ed}')).outlineStyle`)) === 'none', |
| + ) |
| +} |
| + |
| ws.onopen = async () => { |
| try { |
| await run() |
diff --git a/scripts/browser-drive.sh b/scripts/browser-drive.sh| index 8bc5f8d..3d7ad0d 100755 |
| --- a/scripts/browser-drive.sh |
| +++ b/scripts/browser-drive.sh |
| @@ -65,11 +65,12 @@ link="$(grep -o "http://${addr}/auth/verify?token=[A-Za-z0-9_-]*" "${scratch}/se |
| # Following the link in the browser is what leaves the session cookie |
| # in the profile the drive then uses. |
| "$chrome" --headless=new --no-sandbox --disable-gpu --disable-crash-reporter \ |
| - --user-data-dir="${scratch}/profile" --virtual-time-budget=5000 \ |
| + --user-data-dir="${scratch}/profile" --virtual-time-budget=5000 --window-size=1280,900 \ |
| --screenshot="${scratch}/signin.png" "$link" >/dev/null 2>&1 || true |
| "$chrome" --headless=new --no-sandbox --disable-gpu --disable-crash-reporter \ |
| --remote-debugging-port="${debug_port}" --user-data-dir="${scratch}/profile" \ |
| + --window-size=1280,900 \ |
| about:blank > "${scratch}/chrome.log" 2>&1 & |
| chrome_pid=$! |