From cf3f997e5ed3b8ab4583cb7dcb0b4d75d30aec25 Mon Sep 17 00:00:00 2001 From: JSvandijk Date: Tue, 5 May 2026 05:46:23 +0200 Subject: [PATCH] Improve proxy guidance and upload assertions --- README.md | 2 + SECURITY.md | 2 + docs/PROXY-DEPLOYMENT.md | 44 ++++ docs/SECURITY-AUDIT.md | 23 ++ docs/STARTER-ISSUES.md | 45 ++-- docs/UPSTREAM-FIT.md | 3 +- docs/WEBVIEW-HARNESS.md | 1 + docs/evidence/v1.1.0-emulator/README.md | 6 +- lib/upload-injection.js | 147 +++++++++++ package-lock.json | 8 +- package.json | 8 +- server.js | 145 +---------- test/test-upload-injection.js | 321 ++++++++++++++++++++++++ 13 files changed, 578 insertions(+), 177 deletions(-) create mode 100644 docs/PROXY-DEPLOYMENT.md create mode 100644 lib/upload-injection.js create mode 100644 test/test-upload-injection.js diff --git a/README.md b/README.md index 145eb7c..4d7bec2 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,7 @@ If you want the technical breakdown instead of the public pitch, read [docs/ARCH | Runtime verification | [docs/RUNTIME-VERIFICATION.md](docs/RUNTIME-VERIFICATION.md) | | Security audit | [docs/SECURITY-AUDIT.md](docs/SECURITY-AUDIT.md) | | WebView harness | [docs/WEBVIEW-HARNESS.md](docs/WEBVIEW-HARNESS.md) | +| Proxy deployment | [docs/PROXY-DEPLOYMENT.md](docs/PROXY-DEPLOYMENT.md) | | Release runbook | [docs/RELEASE-RUNBOOK.md](docs/RELEASE-RUNBOOK.md) | | Publishing checklist | [docs/PUBLISHING-CHECKLIST.md](docs/PUBLISHING-CHECKLIST.md) | | Showcase | [docs/SHOWCASE.md](docs/SHOWCASE.md) | @@ -227,6 +228,7 @@ If you want the technical breakdown instead of the public pitch, read [docs/ARCH - HTTPS is preferred when you can issue a trusted certificate. - Cleartext HTTP remains supported for Tailscale or another trusted private network because that is a core self-hosted use case, but the app now warns before every HTTP session. - The app intentionally stays scoped to one configured server host inside the WebView; other destinations open outside the app. +- The optional proxy is self-hosted infrastructure for trusted networks. Check [docs/PROXY-DEPLOYMENT.md](docs/PROXY-DEPLOYMENT.md) before exposing it through any reverse proxy. See [docs/SECURITY-AUDIT.md](docs/SECURITY-AUDIT.md) for the current audit notes, fixes, and remaining tradeoffs. diff --git a/SECURITY.md b/SECURITY.md index 64d0ad6..796f9a9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -33,3 +33,5 @@ Target response windows: - Use trusted certificates for HTTPS where possible. - Do not commit TLS keys, certificates, `.env` files, or signed build artifacts to the repository. - Review WebView and proxy settings carefully before broader deployment. + +Proxy deployment boundaries are documented in [docs/PROXY-DEPLOYMENT.md](docs/PROXY-DEPLOYMENT.md). diff --git a/docs/PROXY-DEPLOYMENT.md b/docs/PROXY-DEPLOYMENT.md new file mode 100644 index 0000000..89128fa --- /dev/null +++ b/docs/PROXY-DEPLOYMENT.md @@ -0,0 +1,44 @@ +# Proxy Deployment Checklist + +Use the optional proxy as self-hosted infrastructure for a T3 Code session you already control. It is not a hardened public edge service. + +## Appropriate Use + +- Use it on Tailscale, a trusted LAN, or behind a reverse proxy you already operate. +- Prefer HTTPS with a certificate trusted by the client device. +- Use `PROXY_HTTP=true` only behind Tailscale Serve or another HTTPS-terminating private reverse proxy. +- Keep `T3_TARGET` pointed at a T3 Code instance on a private host or loopback address. +- Keep `GET /__t3mobile/health` available for smoke tests and support checks. + +## Do Not Use It This Way + +- Do not expose the proxy directly to the public internet as an unauthenticated gateway. +- Do not use self-signed certificates on networks you do not control. +- Do not point `T3_TARGET` at an untrusted upstream host. +- Do not publish `.env` files, TLS keys, certificates, signed APKs, or local diagnostic output. +- Do not describe the proxy as production-hardened public infrastructure. + +## Minimum Setup + +1. Set `T3_TARGET` to your local T3 Code URL, for example `http://127.0.0.1:3773`. +2. Set `PUBLIC_URL` to the URL your phone will open. +3. For built-in HTTPS mode, set `SSL_KEY_PATH` and `SSL_CERT_PATH` to trusted certificate files. +4. For HTTP mode behind another HTTPS layer, set `PROXY_HTTP=true` and keep the HTTP listener private. +5. Run `npm test` before sharing setup instructions or publishing a release. + +## Verification + +Run the automated proxy smoke test: + +```bash +npm run test:proxy +``` + +Then verify the deployed proxy from the phone network path: + +- `GET /__t3mobile/health` returns JSON and does not expose filesystem paths. +- The reported upstream status is healthy. +- The root page loads through the expected HTTPS or trusted private-network path. +- Browser dev tools or proxy logs do not show TLS key paths, tokens, or private filesystem paths. + +If any of those checks fail, treat the deployment as not verified. diff --git a/docs/SECURITY-AUDIT.md b/docs/SECURITY-AUDIT.md index 1921db0..7ac4a14 100644 --- a/docs/SECURITY-AUDIT.md +++ b/docs/SECURITY-AUDIT.md @@ -270,6 +270,29 @@ Current behavior: - health results are cached for 5 seconds to prevent probe amplification +## Maintenance Updates (May 5, 2026) + +### 15. Proxy deployment boundaries documented + +Previous behavior: + +- proxy deployment warnings were spread across README, SECURITY, and audit notes + +Current behavior: + +- `docs/PROXY-DEPLOYMENT.md` now provides a concrete checklist for trusted-network use, unsafe public exposure patterns, minimum setup, and verification + +### 16. Upload-injection path has automated assertions + +Previous behavior: + +- upload-button behavior was stronger visually than mechanically asserted + +Current behavior: + +- `npm test` now runs DOM-level assertions for proxy upload button placement, hidden file input creation, paste-event dispatch, and fallback placement +- Android WebView upload injection has source-marker checks for the critical IDs, composer selectors, paste flow, observer, and fallback path + ## Recommended Next Security Steps - add optional host allowlisting for more than one trusted origin if the product needs it diff --git a/docs/STARTER-ISSUES.md b/docs/STARTER-ISSUES.md index 3cbed18..76e8ffe 100644 --- a/docs/STARTER-ISSUES.md +++ b/docs/STARTER-ISSUES.md @@ -20,40 +20,41 @@ Labels: - `docs` - `help wanted` -## 2. Add automated assertion coverage for the upload-button path +## 2. Harden DOM targeting for composer upload injection Why it matters: -- the upload flow is one of the core product claims -- it is still stronger visually than it is mechanically asserted +- upstream UI drift is a realistic long-term risk +- this is a good contributor-sized reliability issue Suggested scope: -- expand the local harness or verification flow so the injected control can be asserted more directly -- document what is and is not guaranteed by the automated check +- review the current selector strategy in `MainActivity.java` +- reduce brittle assumptions where possible +- keep fallback placement behavior documented Labels: - `android` -- `help wanted` +- `good first issue` -## 3. Harden DOM targeting for composer upload injection +## 3. Add iPhone PWA runtime evidence Why it matters: -- upstream UI drift is a realistic long-term risk -- this is a good contributor-sized reliability issue +- the PWA path has automated proxy coverage but still needs real-device proof +- iOS-specific install and safe-area behavior should be checked on hardware Suggested scope: -- review the current selector strategy in `MainActivity.java` -- reduce brittle assumptions where possible -- keep fallback placement behavior documented +- install the PWA on a real iPhone or iPad +- capture connection, launch-from-home-screen, and proxy health evidence +- update `IPHONE-GUIDE.md` if device behavior differs from the current notes Labels: -- `android` -- `good first issue` +- `docs` +- `help wanted` ## 4. Add a release demo clip for the README and release page @@ -72,20 +73,20 @@ Labels: - `community` - `docs` -## 5. Improve proxy deployment guidance for self-hosted users +## 5. Add release signing verification notes Why it matters: -- the proxy is useful but easy to misunderstand -- better deployment guidance lowers support overhead +- Android update trust depends on stable signing identity +- signing continuity is the highest-risk release operations gap Suggested scope: -- expand docs around trusted-network assumptions -- add a short “when not to expose this” checklist -- keep the guidance aligned with `SECURITY.md` +- document the intended public release signing fingerprint +- add a release-time manual verification step for signer continuity +- keep private signing material out of the repo Labels: -- `proxy` -- `docs` +- `android` +- `security` diff --git a/docs/UPSTREAM-FIT.md b/docs/UPSTREAM-FIT.md index 846a889..843f349 100644 --- a/docs/UPSTREAM-FIT.md +++ b/docs/UPSTREAM-FIT.md @@ -19,7 +19,7 @@ This project is most credible to `pingdotgg/t3code` when it stays narrow and rel ## Current Validation Last checked: 2026-05-05 on `main` after the ESLint 10 and Node runtime update in -[`JSvandijk/t3code-mobile#19`](https://github.com/JSvandijk/t3code-mobile/pull/19). +[`JSvandijk/t3code-mobile#19`](https://github.com/JSvandijk/t3code-mobile/pull/19), plus the follow-up proxy deployment and upload-assertion maintenance. - `npm test` passes from a clean local checkout after `npm ci`. - JavaScript syntax checks pass. @@ -27,6 +27,7 @@ Last checked: 2026-05-05 on `main` after the ESLint 10 and Node runtime update i - `manifest.json` validation passes. - Release checks pass for version `1.1.0`. - HTML unit tests pass: 18/18. +- Upload-injection tests pass: 5/5. - Proxy smoke test passes, including HTML injection, static assets, and `GET /__t3mobile/health`. - `cmd /c build-apk.bat` builds a dev-signed APK successfully. diff --git a/docs/WEBVIEW-HARNESS.md b/docs/WEBVIEW-HARNESS.md index c20af70..058d3c9 100644 --- a/docs/WEBVIEW-HARNESS.md +++ b/docs/WEBVIEW-HARNESS.md @@ -66,6 +66,7 @@ Use `npm run harness:redirect` to confirm: - If those files are absent, the harness generates a temporary self-signed certificate automatically. - Override paths with `SSL_KEY_PATH` and `SSL_CERT_PATH` if needed. - The harness also exposes `/status` for quick local checks. +- `npm test` runs DOM-level upload injection assertions for the proxy path and source-marker checks for the Android WebView path. - Pair this guide with [RUNTIME-VERIFICATION.md](RUNTIME-VERIFICATION.md) when you are collecting release evidence. ## Recommended Evidence To Capture diff --git a/docs/evidence/v1.1.0-emulator/README.md b/docs/evidence/v1.1.0-emulator/README.md index 206f70e..640e5d3 100644 --- a/docs/evidence/v1.1.0-emulator/README.md +++ b/docs/evidence/v1.1.0-emulator/README.md @@ -18,7 +18,7 @@ This evidence set does not prove: - behavior on a physical Android device - microphone permission flow - real T3 Code upstream behavior beyond the local harness and existing repo smoke tests -- upload-button behavior by machine-readable assertion +- Android upload-button behavior by current runtime evidence ## Environment @@ -150,5 +150,5 @@ This evidence set was captured against commit `7b7680807bd97f06802fd98783096dd37 ## Status Summary - Proven by emulator runtime evidence: connect screen, diagnostics path, pairing-link input acceptance, cleartext warning, base URL normalization, invalid HTTPS blocking -- Proven by automated checks: manifest integrity, release gates, proxy smoke path, proxy header expectations -- Still not proven here: physical-device behavior, microphone permission path, and machine-verifiable upload-button assertion +- Proven by current automated checks: manifest integrity, release gates, proxy smoke path, proxy header expectations, proxy upload-injection DOM assertions +- Still not proven here: physical-device behavior, microphone permission path, and Android upload-button behavior on the latest runtime diff --git a/lib/upload-injection.js b/lib/upload-injection.js new file mode 100644 index 0000000..0914e1e --- /dev/null +++ b/lib/upload-injection.js @@ -0,0 +1,147 @@ +const uploadInjectionScript = ` + (function() { + if (window.__t3MobileObserverAttached) return; + window.__t3MobileObserverAttached = true; + + function getComposerTarget() { + var selectors = [ + '[data-chat-composer-form] textarea', + '[data-chat-composer-form] [contenteditable="true"]', + '[role="textbox"]', + 'textarea', + '[contenteditable="true"]' + ]; + for (var i = 0; i < selectors.length; i++) { + var node = document.querySelector(selectors[i]); + if (node) return node; + } + return null; + } + + function getFileInput() { + var fi = document.getElementById('t3-file-input'); + if (fi && fi.parentNode) return fi; + if (fi) fi.remove(); + fi = document.createElement('input'); + fi.type = 'file'; + fi.accept = 'image/*'; + fi.style.display = 'none'; + fi.id = 't3-file-input'; + fi.addEventListener('change', handleFile); + document.body.appendChild(fi); + return fi; + } + + function handleFile(e) { + var file = e.target.files[0]; + if (!file) return; + var dt = new DataTransfer(); + dt.items.add(file); + var target = getComposerTarget(); + if (target) { + target.focus(); + target.dispatchEvent(new ClipboardEvent('paste', { + bubbles: true, cancelable: true, clipboardData: dt + })); + } + e.target.value = ''; + } + + function findAnchorButton() { + var footer = document.querySelector('[data-chat-composer-footer]'); + if (footer) { + var buttons = footer.querySelectorAll('button'); + for (var i = buttons.length - 1; i >= 0; i--) { + if (buttons[i].querySelectorAll('circle').length === 3) return buttons[i]; + } + if (buttons.length) return buttons[buttons.length - 1]; + } + var composer = document.querySelector('[data-chat-composer-form]'); + if (composer) { + var composerButtons = composer.querySelectorAll('button'); + if (composerButtons.length) return composerButtons[composerButtons.length - 1]; + } + return null; + } + + function getFallbackContainer() { + var composer = document.querySelector('[data-chat-composer-form]'); + if (!composer) return null; + var container = document.getElementById('t3-mobile-actions'); + if (container && container.parentNode === composer) return container; + if (container) container.remove(); + container = document.createElement('div'); + container.id = 't3-mobile-actions'; + container.style.display = 'flex'; + container.style.justifyContent = 'flex-end'; + container.style.marginTop = '8px'; + composer.appendChild(container); + return container; + } + + function ensureButton() { + var anchor = findAnchorButton(); + var existing = document.getElementById('t3-img-btn'); + var fallback = getFallbackContainer(); + if (anchor && anchor.parentNode) { + if (existing && existing.parentNode === anchor.parentNode) return true; + if (existing) existing.remove(); + } else if (fallback) { + if (existing && existing.parentNode === fallback) return true; + if (existing) existing.remove(); + } else { + return false; + } + var btn = document.createElement('button'); + btn.id = 't3-img-btn'; + btn.type = 'button'; + btn.title = 'Add image'; + btn.setAttribute('aria-label', 'Add image'); + btn.className = anchor ? anchor.className : ''; + if (!anchor) { + btn.style.minWidth = '40px'; + btn.style.minHeight = '40px'; + btn.style.borderRadius = '12px'; + btn.style.border = '1px solid rgba(148, 163, 184, 0.25)'; + btn.style.background = 'transparent'; + btn.style.color = 'inherit'; + btn.style.display = 'inline-flex'; + btn.style.alignItems = 'center'; + btn.style.justifyContent = 'center'; + } + btn.innerHTML = ''; + btn.addEventListener('click', function(ev) { + ev.preventDefault(); + ev.stopPropagation(); + getFileInput().click(); + }); + if (anchor && anchor.parentNode) { + anchor.parentNode.insertBefore(btn, anchor.nextSibling); + } else { + fallback.appendChild(btn); + } + return true; + } + + function init() { + ensureButton(); + var observerTicking = false; + new MutationObserver(function() { + if (observerTicking) return; + observerTicking = true; + requestAnimationFrame(function() { + observerTicking = false; + ensureButton(); + }); + }).observe(document.body, { childList: true, subtree: true }); + } + + if (document.body) { + init(); + } else { + document.addEventListener('DOMContentLoaded', init); + } + })(); +`; + +module.exports = { uploadInjectionScript }; diff --git a/package-lock.json b/package-lock.json index e05f61d..62eb360 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "http-proxy": "^1.18.1" }, "devDependencies": { - "eslint": "^10.2.1", + "eslint": "^10.3.0", "selfsigned": "^5.5.0" }, "engines": { @@ -514,9 +514,9 @@ } }, "node_modules/eslint": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.1.tgz", - "integrity": "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.3.0.tgz", + "integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index f1126fa..d127b9b 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,8 @@ "start": "node server.js", "build:apk": "build-apk.bat", "generate:icons": "node generate-icons.js", - "check:js": "node --check server.js && node --check generate-icons.js && node --check sw.js && node --check check-release.js && node --check smoke-proxy.js && node --check webview-harness.js && node --check lib/html.js && node --check test/test-html.js", - "lint": "eslint server.js smoke-proxy.js check-release.js webview-harness.js generate-icons.js sw.js lib/html.js test/test-html.js", + "check:js": "node --check server.js && node --check generate-icons.js && node --check sw.js && node --check check-release.js && node --check smoke-proxy.js && node --check webview-harness.js && node --check lib/html.js && node --check lib/upload-injection.js && node --check test/test-html.js && node --check test/test-upload-injection.js", + "lint": "eslint server.js smoke-proxy.js check-release.js webview-harness.js generate-icons.js sw.js lib/html.js lib/upload-injection.js test/test-html.js test/test-upload-injection.js", "check:manifest": "node -e \"JSON.parse(require('fs').readFileSync('manifest.json', 'utf8')); console.log('manifest.json OK')\"", "check:repo": "node check-release.js", "check:release": "npm run check:repo", @@ -17,7 +17,7 @@ "harness:http": "node webview-harness.js --mode=http", "harness:https-bad-cert": "node webview-harness.js --mode=https-bad-cert", "harness:redirect": "node webview-harness.js --mode=redirect", - "test:units": "node test/test-html.js", + "test:units": "node test/test-html.js && node test/test-upload-injection.js", "test": "npm run check:js && npm run lint && npm run check:manifest && npm run check:repo && npm run test:units && npm run test:proxy" }, "keywords": [ @@ -49,7 +49,7 @@ "http-proxy": "^1.18.1" }, "devDependencies": { - "eslint": "^10.2.1", + "eslint": "^10.3.0", "selfsigned": "^5.5.0" } } diff --git a/server.js b/server.js index 985fb2b..3a53927 100644 --- a/server.js +++ b/server.js @@ -6,6 +6,7 @@ const fs = require('fs'); const path = require('path'); const pkg = require('./package.json'); const { escapeHtml, injectBeforeHeadClose } = require('./lib/html'); +const { uploadInjectionScript } = require('./lib/upload-injection'); const repoPath = (targetPath) => path.resolve(__dirname, targetPath); const fromEnvOrDefault = (name, fallback) => process.env[name] || fallback; @@ -174,149 +175,7 @@ const pwaInject = ` } `; diff --git a/test/test-upload-injection.js b/test/test-upload-injection.js new file mode 100644 index 0000000..5b522f2 --- /dev/null +++ b/test/test-upload-injection.js @@ -0,0 +1,321 @@ +const assert = require('assert'); +const vm = require('vm'); +const fs = require('fs'); +const path = require('path'); +const { uploadInjectionScript } = require('../lib/upload-injection'); + +let passed = 0; + +function test(name, fn) { + try { + fn(); + passed++; + } catch (e) { + console.error(`FAIL: ${name}`); + console.error(` ${e.stack || e.message}`); + process.exit(1); + } +} + +class FakeElement { + constructor(tagName) { + this.tagName = tagName.toUpperCase(); + this.children = []; + this.parentNode = null; + this.attributes = {}; + this.listeners = {}; + this.style = {}; + this.id = ''; + this.type = ''; + this.title = ''; + this.className = ''; + this.accept = ''; + this.files = []; + this.value = ''; + this.innerHTML = ''; + this.focused = false; + this.clicked = false; + } + + appendChild(child) { + if (child.parentNode) child.remove(); + child.parentNode = this; + this.children.push(child); + return child; + } + + insertBefore(child, referenceNode) { + if (child.parentNode) child.remove(); + child.parentNode = this; + const index = this.children.indexOf(referenceNode); + if (index === -1) { + this.children.push(child); + } else { + this.children.splice(index, 0, child); + } + return child; + } + + remove() { + if (!this.parentNode) return; + const index = this.parentNode.children.indexOf(this); + if (index !== -1) this.parentNode.children.splice(index, 1); + this.parentNode = null; + } + + setAttribute(name, value) { + this.attributes[name] = String(value); + if (name === 'id') this.id = String(value); + if (name === 'class') this.className = String(value); + } + + getAttribute(name) { + if (name === 'id') return this.id || null; + if (name === 'class') return this.className || null; + return Object.prototype.hasOwnProperty.call(this.attributes, name) + ? this.attributes[name] + : null; + } + + addEventListener(type, listener) { + if (!this.listeners[type]) this.listeners[type] = []; + this.listeners[type].push(listener); + } + + dispatchEvent(event) { + event.target = event.target || this; + const listeners = this.listeners[event.type] || []; + for (const listener of listeners) { + listener(event); + } + return true; + } + + click() { + this.clicked = true; + this.dispatchEvent({ + type: 'click', + preventDefault() {}, + stopPropagation() {}, + }); + } + + focus() { + this.focused = true; + } + + querySelector(selector) { + return querySelectorFrom(this, selector); + } + + querySelectorAll(selector) { + return querySelectorAllFrom(this, selector); + } +} + +class FakeDocument { + constructor() { + this.body = new FakeElement('body'); + this.eventListeners = {}; + } + + createElement(tagName) { + return new FakeElement(tagName); + } + + getElementById(id) { + return walk(this.body).find((node) => node.id === id) || null; + } + + querySelector(selector) { + return querySelectorFrom(this.body, selector); + } + + querySelectorAll(selector) { + return querySelectorAllFrom(this.body, selector); + } + + addEventListener(type, listener) { + this.eventListeners[type] = listener; + } +} + +function walk(root) { + const nodes = []; + const visit = (node) => { + nodes.push(node); + for (const child of node.children) visit(child); + }; + visit(root); + return nodes; +} + +function querySelectorFrom(root, selector) { + return querySelectorAllFrom(root, selector)[0] || null; +} + +function querySelectorAllFrom(root, selector) { + const parts = selector.trim().split(/\s+/); + return walk(root).filter((node) => matchesSelectorChain(node, parts)); +} + +function matchesSelectorChain(node, parts) { + if (!matchesSimpleSelector(node, parts[parts.length - 1])) return false; + let ancestor = node.parentNode; + for (let i = parts.length - 2; i >= 0; i--) { + while (ancestor && !matchesSimpleSelector(ancestor, parts[i])) { + ancestor = ancestor.parentNode; + } + if (!ancestor) return false; + ancestor = ancestor.parentNode; + } + return true; +} + +function matchesSimpleSelector(node, selector) { + const attrMatch = selector.match(/^\[([^=\]]+)(?:="([^"]+)")?\]$/); + if (attrMatch) { + const actual = node.getAttribute(attrMatch[1]); + return attrMatch[2] === undefined ? actual !== null : actual === attrMatch[2]; + } + return node.tagName.toLowerCase() === selector.toLowerCase(); +} + +function createContext(document) { + const clipboardEvents = []; + return { + context: { + document, + window: {}, + console, + requestAnimationFrame: (fn) => fn(), + MutationObserver: class { + constructor(callback) { + this.callback = callback; + } + observe() {} + }, + DataTransfer: class { + constructor() { + this.items = { + values: [], + add: (file) => this.items.values.push(file), + }; + } + }, + ClipboardEvent: class { + constructor(type, options) { + this.type = type; + this.clipboardData = options.clipboardData; + this.bubbles = options.bubbles; + this.cancelable = options.cancelable; + clipboardEvents.push(this); + } + }, + }, + clipboardEvents, + }; +} + +function runUploadScript(document) { + const { context, clipboardEvents } = createContext(document); + vm.runInNewContext(uploadInjectionScript, context); + return { context, clipboardEvents }; +} + +function buildComposer({ withFooterAnchor = true } = {}) { + const document = new FakeDocument(); + const form = document.createElement('form'); + form.setAttribute('data-chat-composer-form', ''); + const textarea = document.createElement('textarea'); + form.appendChild(textarea); + + if (withFooterAnchor) { + const footer = document.createElement('div'); + footer.setAttribute('data-chat-composer-footer', ''); + const anchor = document.createElement('button'); + anchor.className = 'composer-button'; + anchor.appendChild(document.createElement('circle')); + anchor.appendChild(document.createElement('circle')); + anchor.appendChild(document.createElement('circle')); + footer.appendChild(anchor); + form.appendChild(footer); + } + + document.body.appendChild(form); + return { document, form, textarea }; +} + +test('proxy upload injection places image button next to composer footer action', () => { + const { document } = buildComposer({ withFooterAnchor: true }); + runUploadScript(document); + + const button = document.getElementById('t3-img-btn'); + assert.ok(button, 'expected injected image button'); + assert.strictEqual(button.getAttribute('aria-label'), 'Add image'); + assert.strictEqual(button.parentNode.getAttribute('data-chat-composer-footer'), ''); + assert.strictEqual(button.parentNode.children.indexOf(button), 1); +}); + +test('proxy upload button creates hidden image file input on click', () => { + const { document } = buildComposer({ withFooterAnchor: true }); + runUploadScript(document); + + document.getElementById('t3-img-btn').click(); + const input = document.getElementById('t3-file-input'); + assert.ok(input, 'expected hidden file input'); + assert.strictEqual(input.type, 'file'); + assert.strictEqual(input.accept, 'image/*'); + assert.strictEqual(input.style.display, 'none'); + assert.strictEqual(input.clicked, true); +}); + +test('proxy upload change dispatches paste event into composer target', () => { + const { document, textarea } = buildComposer({ withFooterAnchor: true }); + const { clipboardEvents } = runUploadScript(document); + + document.getElementById('t3-img-btn').click(); + const input = document.getElementById('t3-file-input'); + input.files = [{ name: 'photo.png', type: 'image/png' }]; + input.dispatchEvent({ type: 'change', target: input }); + + assert.strictEqual(textarea.focused, true); + assert.strictEqual(input.value, ''); + assert.strictEqual(clipboardEvents.length, 1); + assert.strictEqual(clipboardEvents[0].type, 'paste'); + assert.strictEqual(clipboardEvents[0].clipboardData.items.values[0].name, 'photo.png'); +}); + +test('proxy upload injection falls back when composer footer is unavailable', () => { + const { document, form } = buildComposer({ withFooterAnchor: false }); + runUploadScript(document); + + const fallback = document.getElementById('t3-mobile-actions'); + const button = document.getElementById('t3-img-btn'); + assert.ok(fallback, 'expected fallback action container'); + assert.ok(button, 'expected injected fallback button'); + assert.strictEqual(fallback.parentNode, form); + assert.strictEqual(button.parentNode, fallback); +}); + +test('Android WebView upload injection still contains core reliability markers', () => { + const source = fs.readFileSync( + path.join(__dirname, '..', 'apk', 'app', 'src', 'main', 'java', 'com', 't3code', 'app', 'MainActivity.java'), + 'utf8', + ); + + for (const marker of [ + 'data-chat-composer-form', + 'data-chat-composer-footer', + 't3-file-input', + 't3-img-btn', + 't3-mobile-actions', + 'ClipboardEvent', + 'MutationObserver', + 'Using fallback upload button placement', + ]) { + assert.ok(source.includes(marker), `missing Android upload marker: ${marker}`); + } +}); + +console.log(`upload injection tests OK (${passed} passed)`);