388 lines
15 KiB
HTML
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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">");
|
|
}
|
|
|
|
// 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>
|