Skip to content

Add file upload: setfiles (known input) + upload (file chooser)#2

Open
motin wants to merge 4 commits into
masterfrom
feat/file-upload
Open

Add file upload: setfiles (known input) + upload (file chooser)#2
motin wants to merge 4 commits into
masterfrom
feat/file-upload

Conversation

@motin

@motin motin commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Adds Playwright-style file upload — the one capability missing for forms that take attachments.

Why

App Store Connect's "Reply to App Review" has an "Attach File" button that opens a transient file input via a native chooser. We couldn't upload because (a) the CLI had no file-set command, (b) setting the static input[type=file] via JS/DOM.setFileInputFiles does not route through the React handler — the button creates+clicks its own input, and (c) the file the button uses only exists while the chooser is open. The robust fix is to intercept the chooser, exactly like Playwright's fileChooser / setInputFiles.

What

Two entry points, mirroring Playwright's two paths. Both end in DOM.setFileInputFiles (a file input's .files is read-only to page JS, so eval/value-setters can't populate it; CDP can, and fires trusted input/change):

  • setfiles -s <session> <ref> <path…> — set files on a known, snapshot-able <input type=file>. Resolves the ref to a RemoteObject (objectId), validates it's a file input (else errors pointing at upload), and sets files.
  • upload -s <session> --click <ref> <path…> — arm Page.setInterceptFileChooserDialog, click the trigger, wait for Page.fileChooserOpened, then DOM.setFileInputFiles({ backendNodeId, files }), then disable interception. Handles the transient-input / custom-button case (ASC "Attach File", React dropzones).

Paths resolve from cwd and are validated to exist; multiple paths upload as a multi-file selection.

Implementation

  • New src/core/upload.ts (setFilesByRef, uploadViaChooser); findRefExpr added to dom.ts to resolve a ref to an object handle. Commands in src/commands/{setfiles,upload}.ts, registered in index.ts. Reuses the existing per-target CDP core (no hand-rolled socket — that's what avoids the Origin 403 from Chrome).
  • README + SKILL.md document both paths and when to use which. Also syncs the stale package-lock.json version to match package.json.

Validation

npm install && npm run typecheck && npm run build clean. Ran the build (node dist/index.js …) against a scratch tab with custom HTML containing both a static <input type=file> and a button that creates+clicks a transient input:

  • setfiles e1 <file> → static input's change handler fired (filename+size observed).
  • upload --click e2 <file> → transient input's change handler fired (filename+size observed).
  • Multi-file, wrong-element (button → helpful error), missing-file, and missing---click all behave correctly. No live tabs touched.

🤖 Generated with Claude Code

motin and others added 2 commits June 20, 2026 09:46
Two Playwright-equivalent entry points for getting local files into a page,
both ending in DOM.setFileInputFiles (the only reliable way to populate an
<input type=file> — its .files is read-only to page JS, and a value-setter
won't route through a React onChange):

- `setfiles <ref> <path…>` — resolve a snapshot ref to a RemoteObject and set
  files on a known, static <input type=file>. Fires input/change. Errors
  helpfully (pointing at `upload`) if the ref isn't a file input.
- `upload --click <ref> <path…>` — arm Page.setInterceptFileChooserDialog,
  click the trigger, wait for Page.fileChooserOpened, and set files on the
  backendNodeId Chrome reports. Handles the transient/custom-button case where
  a button creates+clicks its own throwaway input (App Store Connect "Attach
  File", many React dropzones) — setting the static input there is useless.

Paths resolve from cwd and are validated to exist; multiple paths upload as a
multi-file selection. New core in src/core/upload.ts; `findRefExpr` added to
dom.ts to resolve a ref to an object handle. README + SKILL.md updated. Also
syncs the stale package-lock version to match package.json.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DcStnfmjz5VPtqm9Tmh57B
…cess

Field report from real App Store Connect: `upload --click` reported "uploaded 1
file" but nothing staged — input.files stayed 0, no chip. Root cause: ASC's
"Attach File" creates a *transient* <input type=file>, .click()s it, and never
appends it to the document, reading it through a DELEGATED change handler
(React-style). DOM.setFileInputFiles sets .files and fires `change`, but on a
detached node that event has no document ancestors to bubble through, so the
delegated handler never runs. A node-bound listener fires detached; a delegated
one does not — which is why the original synthetic test (direct listener) passed
and ASC didn't.

Fix in uploadViaChooser, after setFileInputFiles:
- Resolve the backendNodeId to a live handle and VERIFY (isConnected, files.length).
- If the input is detached, re-attach it (hidden) to the document and re-dispatch
  bubbling input+change so delegation / React onChange actually fires.
- Return {count, connected, reconnected}. The command now FAILS (exit 1) when
  count===0 instead of claiming success, notes when it had to reconnect, and
  reminds to re-snapshot to confirm the chip appeared.

Validated against a faithful repro covering four handler patterns (direct vs
document-delegated × detached vs appended-then-removed vs appended-stays): the
detached+delegated case (the ASC shape) went from silent no-op to staged; the
others still stage. Real-ASC confirmation still pending a live run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DcStnfmjz5VPtqm9Tmh57B
@motin

motin commented Jun 20, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up from a real App Store Connect run (commit e371f5b).

upload --click reported success but nothing staged. Root cause: ASC's "Attach File" creates a transient <input type=file>, .click()s it, and never appends it to the document, reading it through a delegated change handler (React-style). DOM.setFileInputFiles sets .files and fires change, but on a detached node that event has no document ancestors to bubble through, so the delegated handler never runs. A node-bound listener fires while detached; a delegated one doesn't — which is exactly why the original synthetic test (direct addEventListener on the input) passed and ASC didn't.

Hardening, after setFileInputFiles:

  • Resolve the backendNodeId to a live handle and verify (isConnected, files.length).
  • If detached, re-attach the input (hidden) to the document and re-dispatch bubbling input+change so delegation / React onChange fires.
  • Return {count, connected, reconnected}; the command now fails (exit 1) when count===0 instead of claiming success, and notes when it had to reconnect.

Validated against a repro covering direct-vs-delegated × detached/removed/stays. Caveat noted in the thread: if a page delegates at a deep container that isn't an ancestor of document.body, the body re-attach won't reach it — pending a live ASC confirmation.

motin and others added 2 commits June 20, 2026 14:18
…files

Live App Store Connect debugging showed the previous count===0 check was a false
NEGATIVE: ASC (like many uploaders) resets the input to "" the instant its change
handler consumes the file, so "0 files remain" actually means *delivered*, not
*failed* — the hardened command was reporting a real success as an error.

Fix: install a one-shot `change` listener on the resolved node BEFORE
setFileInputFiles, recording the file count at dispatch time (survives the app's
reset). uploadViaChooser now returns `delivered` alongside `count`:
- delivered === 0  -> the change fired with no file (input torn down first): real
  failure, exit 1.
- delivered >= 1, count 0 -> delivered; app consumed + reset the input (normal).
  Report success but flag that this proves delivery, not staging — verify the UI.
- delivered >= 1, count >= 1 -> set on a plain input that retains the file.

Validated against synthetic patterns (direct/delegated × detached/appended, incl.
an ASC-style reset-after-consume) and against real ASC: the 3.9 MB mp4 is
delivered to ASC's handler (confirmed separately: ASC reads the blob bytes and the
video loads), though ASC's own client-side staging doesn't render a chip for a
programmatically-set file — an ASC-internal limitation beyond the input layer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DcStnfmjz5VPtqm9Tmh57B
…e attachments)

Hard-won from live App Store Connect use: `upload`/`setfiles` stage successfully
even when the app immediately resets input.files to 0 (so judge by the `change`
event / "delivered", not residual files), the staged file often appears as a row
in an attachment LIST rather than a single chip (verify via snapshot/screenshot),
and each successful run ADDS another attachment — blind retries silently create
duplicates. Documents the behavior; no code change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DcStnfmjz5VPtqm9Tmh57B
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant