Files
bruno 7b5aa6fcc9
CI / fmt, clippy and tests (ubuntu-latest) (push) Successful in 21m0s
CI / browser end-to-end (Chromium + fixture) (push) Skipped
CI / fmt, clippy and tests (windows-latest) (push) Canceled after 0s
Add initial llm-bridge project
2026-09-18 18:01:07 -04:00

388 lines
15 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>ChatGPT fixture</title>
<!--
Local fake of the ChatGPT web UI used by the browser end-to-end tests.
The Rust driver opens this file with a file:// URL and drives it over CDP:
it inserts text through Input.insertText / execCommand / key events, clicks
button[data-testid=send-button], then polls the DOM until the answer settles.
DOM contract (selectors live in the test provider TOML):
- editor div#prompt-textarea.ProseMirror[contenteditable=true]
- send button button[data-testid=send-button] (disabled while empty)
- answer containers div[data-message-author-role=assistant] (one per answer)
the text lives in div.answer-body inside it, and the
toolbar (with the copy button) is a sibling of the
answer node, exactly like the real page: the capture
must not pick the word "Copy" up as answer text
- answer toolbar div.message-toolbar > button[data-testid=copy-button]
copies the answer as Markdown, like the real UI
- streaming indicator button[data-testid=stop-button] (only while generating)
- file input input[type=file] (hidden)
- attachment chips div[data-testid=attachment-chip]
- new chat a[data-testid=create-new-chat-button]
- login mode form[action="/auth/login"]
- captcha mode div#challenge-form
- error mode div[role=alert]
Query parameters:
conv=1 after the first send, set location.hash to #c=<uuid> and keep it
login=1 render the sign-in form instead of the editor
captcha=1 render a bot challenge
error=1 render an error banner; sending produces no answer
ratelimit=1 the page text contains "too many requests"; sending produces no answer
empty=1 sending shows the stop button but never writes any text
delay=<ms> minimum generation time (default 1200)
loading=1 the editor only appears after 3 seconds
rewrite=1 the answer is rewritten mid-stream before settling
rich=1 the answer is rendered as rich HTML (heading, list, code
block, table) and the copy button copies it as Markdown
-->
<style>
body { font: 15px/1.5 system-ui, sans-serif; margin: 0; background: #212121; color: #ececec; }
header { padding: 10px 16px; border-bottom: 1px solid #3a3a3a; display: flex; gap: 12px; }
#messages { padding: 16px; height: 320px; overflow: auto; }
.msg { max-width: 70%; margin: 8px 0; padding: 8px 12px; border-radius: 10px; white-space: pre-wrap; }
[data-message-author-role="user"] { background: #2f2f2f; margin-left: auto; }
[data-message-author-role="assistant"] { background: #303030; }
footer { padding: 12px 16px; border-top: 1px solid #3a3a3a; }
#prompt-textarea { min-height: 48px; padding: 10px; background: #303030; border-radius: 12px;
border: 1px solid #4a4a4a; outline: none; }
#prompt-textarea:focus { border-color: #7aa2f7; }
button { font: inherit; padding: 6px 12px; border-radius: 8px; border: 1px solid #4a4a4a;
background: #303030; color: #ececec; cursor: pointer; }
button[disabled] { opacity: .45; cursor: default; }
#attachments { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
[data-testid="attachment-chip"] { background: #3a3a3a; border-radius: 6px; padding: 2px 8px; font-size: 13px; }
.message-toolbar { display: flex; gap: 6px; margin: 2px 0 10px; }
.message-toolbar button { font-size: 12px; padding: 2px 8px; }
.answer-body table { border-collapse: collapse; margin: 6px 0; }
.answer-body th, .answer-body td { border: 1px solid #555; padding: 2px 8px; }
.answer-body pre { background: #1b1b1b; padding: 8px; border-radius: 6px; overflow: auto; }
#toolbar { display: flex; justify-content: space-between; align-items: center; margin-top: 8px; }
.banner { margin: 12px 16px; padding: 10px 12px; border-radius: 8px; background: #4a2020; }
#challenge-form { margin: 24px 16px; padding: 20px; background: #3a3a3a; border-radius: 10px; }
form { margin: 40px auto; width: 320px; padding: 24px; background: #2a2a2a; border-radius: 12px; }
input[type="email"], input[type="password"] { width: 100%; padding: 8px; margin: 4px 0 12px;
background: #303030; border: 1px solid #4a4a4a;
border-radius: 8px; color: inherit; }
</style>
</head>
<body>
<div id="app"></div>
<script>
(function () {
"use strict";
var params = new URLSearchParams(location.search);
var mode = function (name) { return params.get(name) === "1"; };
var state = {
generating: false,
token: 0,
attachments: [],
conversation: null,
answerCount: 0
};
// A reopened conversation keeps its identifier so that the driver can prove
// that it is continuing the same web thread.
var hashMatch = /#c=([0-9a-zA-Z-]+)/.exec(location.hash);
if (hashMatch) { state.conversation = hashMatch[1]; }
function answer(prompt) {
var trimmed = prompt.trim();
var text;
if (trimmed.toLowerCase().indexOf("ok") !== -1) {
text = "ok";
} else {
text = "Echo: " + trimmed + " — this answer was generated by the local ChatGPT fixture page.";
}
if (state.attachments.length) {
text = text + " (attached: " + state.attachments.join(", ") + ")";
}
return text;
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
// The rich rendering of one answer: a heading, a list, a fenced code block
// and a table, i.e. exactly the constructs a plain innerText capture loses.
function richHtml(prompt) {
return (
"<h2>Answer</h2>" +
"<p>Echo: <strong>" + escapeHtml(prompt.trim()) + "</strong></p>" +
"<ul><li>first point</li><li>second point</li></ul>" +
'<pre><code class="language-rust">fn main() {\n println!("hi");\n}</code></pre>' +
"<table><thead><tr><th>Name</th><th>Value</th></tr></thead>" +
"<tbody><tr><td>alpha</td><td>1</td></tr><tr><td>beta</td><td>2</td></tr></tbody></table>"
);
}
// What the real copy button puts in the clipboard: the Markdown rendering.
function richMarkdown(prompt) {
return (
"## Answer\n\n" +
"Echo: **" + prompt.trim() + "**\n\n" +
"- first point\n" +
"- second point\n\n" +
"```rust\n" +
'fn main() {\n println!("hi");\n}\n' +
"```\n\n" +
"| Name | Value |\n" +
"| --- | --- |\n" +
"| alpha | 1 |\n" +
"| beta | 2 |\n"
);
}
// Give one assistant message the toolbar the real page shows next to it.
// The copy button is a sibling of the answer node, not a child, so a capture
// that reads the whole turn would pick the word "Copy" up as answer text.
function attachToolbar(turn, prompt) {
var toolbar = document.createElement("div");
toolbar.className = "message-toolbar";
var copy = document.createElement("button");
copy.setAttribute("data-testid", "copy-button");
copy.setAttribute("aria-label", "Copy");
copy.textContent = "Copy";
copy.addEventListener("click", function (event) {
event.preventDefault();
var markdown = richMarkdown(prompt);
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(markdown)["catch"](function () {});
}
});
toolbar.appendChild(copy);
turn.appendChild(toolbar);
}
// Give one assistant message the toolbar the real page shows next to it.
// The copy button is a sibling of the answer node, not a child, so a capture
// that reads the whole turn would pick the word "Copy" up as answer text.
function attachToolbar(turn, prompt) {
var toolbar = document.createElement("div");
toolbar.className = "message-toolbar";
var copy = document.createElement("button");
copy.setAttribute("data-testid", "copy-button");
copy.setAttribute("aria-label", "Copy");
copy.textContent = "Copy";
copy.addEventListener("click", function (event) {
event.preventDefault();
var markdown = richMarkdown(prompt);
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(markdown)["catch"](function () {});
}
});
toolbar.appendChild(copy);
turn.appendChild(toolbar);
}
function render() {
var app = document.getElementById("app");
if (mode("login")) {
app.innerHTML =
'<form action="/auth/login" method="post">' +
'<h2>Sign in (fixture)</h2>' +
'<label>Email<input type="email" name="email" /></label>' +
'<label>Password<input type="password" name="password" /></label>' +
'<button type="submit">Continue</button>' +
'</form>';
return;
}
var banner = "";
if (mode("error")) {
banner = '<div role="alert">Something went wrong. Please try again.</div>';
}
if (mode("ratelimit")) {
banner += '<div class="banner">Too many requests right now, please try again later.</div>';
}
var challenge = mode("captcha") ? '<div id="challenge-form">Verify you are human</div>' : "";
app.innerHTML =
'<header><a data-testid="create-new-chat-button" href="#">New chat</a><span>ChatGPT fixture</span></header>' +
banner + challenge +
'<div id="messages"></div>' +
'<footer>' +
'<div id="attachments"></div>' +
'<div id="prompt-textarea" class="ProseMirror" contenteditable="true"></div>' +
'<div id="toolbar">' +
'<input type="file" multiple hidden />' +
'<button data-testid="send-button" disabled>Send</button>' +
'</div>' +
'</footer>';
var editor = document.getElementById("prompt-textarea");
var send = document.querySelector("[data-testid='send-button']");
var fileInput = document.querySelector("input[type='file']");
function refresh() { send.disabled = editor.innerText.trim().length === 0 || state.generating; }
editor.addEventListener("input", refresh);
editor.addEventListener("keyup", refresh);
editor.addEventListener("blur", refresh);
window.setInterval(refresh, 100);
editor.addEventListener("keydown", function (event) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
submit();
}
});
send.addEventListener("click", function (event) { event.preventDefault(); submit(); });
fileInput.addEventListener("change", function () {
var strip = document.getElementById("attachments");
for (var i = 0; i < fileInput.files.length; i += 1) {
var file = fileInput.files[i];
state.attachments.push(file.name);
var chip = document.createElement("div");
chip.setAttribute("data-testid", "attachment-chip");
chip.textContent = file.name;
strip.appendChild(chip);
}
});
var newChat = document.querySelector("[data-testid='create-new-chat-button']");
newChat.addEventListener("click", function (event) {
event.preventDefault();
state.token += 1;
state.generating = false;
state.answerCount = 0;
state.attachments = [];
document.getElementById("messages").innerHTML = "";
document.getElementById("attachments").innerHTML = "";
if (state.conversation) { history.replaceState(null, "", location.pathname + location.search); }
state.conversation = null;
editor.innerText = "";
refresh();
});
refresh();
}
function stopButton() { return document.querySelector("[data-testid='stop-button']"); }
function setStopButton(visible) {
var existing = stopButton();
if (visible && !existing) {
var button = document.createElement("button");
button.setAttribute("data-testid", "stop-button");
button.textContent = "Stop";
button.addEventListener("click", function () { state.token += 1; state.generating = false; setStopButton(false); });
document.getElementById("toolbar").appendChild(button);
} else if (!visible && existing) {
existing.remove();
}
}
function submit() {
var editor = document.getElementById("prompt-textarea");
var prompt = editor.innerText;
if (!prompt.trim() || state.generating) { return; }
var messages = document.getElementById("messages");
var user = document.createElement("div");
user.setAttribute("data-message-author-role", "user");
user.textContent = prompt;
messages.appendChild(user);
editor.innerText = "";
editor.dispatchEvent(new Event("input", { bubbles: true }));
if (mode("conv") && !state.conversation) {
state.conversation = (crypto.randomUUID ? crypto.randomUUID() : String(Date.now()));
location.hash = "#c=" + state.conversation;
}
if (mode("login") || mode("captcha")) { return; }
if (mode("error") || mode("ratelimit")) { return; }
state.generating = true;
state.token += 1;
var token = state.token;
setStopButton(true);
// The page wraps one exchange in a "turn" element, like the real UI: the
// answer node holds the text only, and the toolbar is its sibling.
var turn = document.createElement("div");
turn.className = "turn";
turn.setAttribute("data-testid", "conversation-turn");
var node = document.createElement("div");
node.setAttribute("data-message-author-role", "assistant");
var body = document.createElement("div");
body.className = "answer-body";
node.appendChild(body);
turn.appendChild(node);
messages.appendChild(turn);
state.answerCount += 1;
if (mode("rich")) {
attachToolbar(turn, prompt);
}
var full = answer(prompt);
var draft = mode("rewrite") ? "Draft answer being rewritten" : null;
var delay = parseInt(params.get("delay") || "1200", 10);
var steps = 22;
var written = "";
if (mode("empty")) {
window.setTimeout(function () {
if (token !== state.token) { return; }
state.generating = false;
setStopButton(false);
}, delay);
return;
}
var index = 0;
var timer = window.setInterval(function () {
if (token !== state.token) { window.clearInterval(timer); return; }
index += 1;
var target = full;
if (draft && index * 2 < steps) { target = draft; }
else if (draft && index === Math.floor(steps / 2) + 1) { written = ""; }
var chars = Math.ceil(target.length * (index / steps));
written = target.slice(0, chars);
body.innerText = written;
if (index >= steps) {
window.clearInterval(timer);
// The settled answer is rendered: the rich modes turn it into HTML,
// which is what a plain text capture cannot represent.
try {
if (mode("rich")) {
body.innerHTML = richHtml(prompt);
} else {
body.innerText = full;
}
} catch (e) {
body.innerText = full;
}
state.generating = false;
setStopButton(false);
var send = document.querySelector("[data-testid='send-button']");
if (send) { send.disabled = document.getElementById("prompt-textarea").innerText.trim().length === 0; }
}
}, Math.max(20, Math.floor(delay / steps)));
}
window.fixtureState = state;
if (mode("loading")) {
window.setTimeout(render, 3000);
} else {
render();
}
})();
</script>
</body>
</html>