Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,13 @@ jobs:
- 'src/**'
- 'schemas/**'
- 'tests/e2e/**'
# #630 Phase 1: tests/e2e/clickhouse-http-transport.spec.js now
# imports this ONE shared spike fixture server directly — a
# real cross-tree dependency the PR path filter didn't know
# about before. Deliberately narrow (not `tests/spike/**`):
# the rest of that historical spike suite is not a dependency
# of the root e2e suite and stays out of ordinary PR CI.
- 'tests/spike/clickhouse-client/fault-server.mjs'
- 'playwright.config.js'
- 'build/**'
- 'package.json'
Expand Down
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,29 @@ auto-generated per-PR notes; this file is the curated, human-readable history.

## [Unreleased]

### Added
- **#630 Phase 1: characterize native Fetch/Response/cancellation semantics
ahead of the `@altinity/clickhouse-http` extraction.** No production
behavior changed — `src/net/clickhouse-http-transport.ts`,
`clickhouse-transport.types.ts`, and `ch-client.ts` are untouched. Strengthened
`tests/unit/clickhouse-transport-contract.ts` and
`tests/unit/clickhouse-http-transport.test.ts` to prove strict native
`Response` identity (2xx and non-2xx), no hidden body consumption, exactly
one injected Fetch call (including pre-aborted input), exact SQL/Authorization
fidelity, raw invalid-UTF-8 byte safety, live `origin()`/`fetch()` accessors,
and current URL-serialization literals (zero/empty/reserved values). Added a
real-browser Chromium/WebKit proof
(`tests/e2e/clickhouse-http-transport.{html,spec.js}`) covering pre-abort,
abort-while-awaiting-headers, native post-header body cancellation,
no-callbacks-after-cancellation, concurrent-request isolation, and
abort-after-completion — driven through the actual production transport, not
a synthetic stream. `tests/spike/clickhouse-client/fault-server.mjs` gained
byte-safe request-body capture, an opt-in CORS mode, and a deterministic
post-header-hold fixture to support this; `.github/workflows/ci.yml`'s e2e
path filter now includes that one shared fixture file. This is a
characterization-only unit (issue #630, phase 1 of 8); the package
extraction itself begins in phase 2.

### Changed
- **ADR-0005 reverted to Rejected (`@clickhouse/client-web` not adopted);
briefly Accepted for part of one day.** A 2026-08-07 decision-methodology
Expand Down
305 changes: 305 additions & 0 deletions tests/e2e/clickhouse-http-transport.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,305 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>#630 Phase 1 — native Fetch/Response/cancellation transport harness</title>
</head>
<body>
<!-- #630 Phase 1 — the real production transport (`/src/net/clickhouse-http-transport.js`,
served as raw ESM by build/e2e-serve.mjs, type-stripped from the .ts
source with no bundling) driven against a real cross-origin fault
server, in a real browser. Every helper below is orchestration only —
it counts calls, captures the exact url/init the injected fetch was
given, and retains the native Response — it never re-derives chUrl,
request construction, SQL handling, signal handling, or body
consumption, all of which stay inside the imported production module.
Native `Response` objects cannot cross Playwright's page.evaluate()
serialization boundary, so every scenario below does its own identity
assertion INSIDE the page realm and returns only plain, serializable
results (booleans/numbers/strings) to the Node-side spec. -->
<script type="module">
import { createHttpTransport } from '/src/net/clickhouse-http-transport.js';

// A counting wrapper around the real, native `window.fetch` — the ONLY
// thing under `deps.fetch()`. It never replaces/derives the signal,
// never clones or wraps the Response, never consumes a body, never
// retries, never normalizes a value: it delegates exactly once per call
// and remembers what it was given / what it got back.
function makeWrapper() {
const calls = [];
let lastResponse = null;
async function wrapped(url, init) {
calls.push({
url: String(url),
method: init && init.method,
body: init && init.body,
authorization: init && init.headers && init.headers.Authorization,
hasSignal: !!(init && init.signal),
});
const resp = await window.fetch(url, init);
lastResponse = resp;
return resp;
}
return {
fn: wrapped,
calls,
get count() { return calls.length; },
get lastResponse() { return lastResponse; },
};
}

function makeTransport(baseUrl) {
const wrapper = makeWrapper();
const transport = createHttpTransport({ fetch: () => wrapper.fn, origin: () => baseUrl });
return { transport, wrapper };
}

async function nameOf(promise) {
try {
await promise;
return null;
} catch (e) {
return e && e.name;
}
}

// ── Scenario 1 — request and Response fidelity (bullets 1, 2, 4, 5, 7) ──
// Uses the 'ordinary-query' fixture (any 2xx works; content is irrelevant
// to this scenario's assertions) and an independently authored
// pathological SQL literal — never derived from chUrl/the production
// helper under test.
const SCENARIO1_SQL = ' -- leading comment\n\tSELECT \'héllo\', 1 -- trailing comment\nFORMAT CSV; \n';
const SCENARIO1_AUTHORIZATION = 'Bearer test-token-abc123';
window.__scenario1 = async function (baseUrl, queryId) {
const { transport, wrapper } = makeTransport(baseUrl);
const resp = await transport.send({
sql: SCENARIO1_SQL,
defaultFormat: 'JSONCompact',
settings: { wait_end_of_query: 0, empty_setting: '' }, // numeric zero + empty-string setting
params: {
query_id: queryId,
space_val: 'a b', // spaces
reserved_val: 'a&b=c?d#e', // reserved characters
empty_param: '', // empty-string parameter
},
authorization: SCENARIO1_AUTHORIZATION,
});
const identity = resp === wrapper.lastResponse;
const bodyUsedBeforeConsume = resp.bodyUsed;
await resp.text(); // consume only AFTER the bodyUsed snapshot above
return {
identity,
count: wrapper.count,
url: wrapper.calls[0].url,
sqlMatches: wrapper.calls[0].body === SCENARIO1_SQL,
authorization: wrapper.calls[0].authorization,
bodyUsedBeforeConsume,
status: resp.status,
};
};

// ── Scenario 2 — non-2xx untouched (bullet 3) ───────────────────────────
window.__scenario2 = async function (baseUrl, queryId) {
const { transport, wrapper } = makeTransport(baseUrl);
const resp = await transport.send({
sql: 'SELECT 1',
defaultFormat: 'JSON',
params: { query_id: queryId },
authorization: 'Bearer tok',
});
const identity = resp === wrapper.lastResponse;
const bodyUsedBeforeConsume = resp.bodyUsed;
const text = await resp.text();
return { identity, count: wrapper.count, status: resp.status, bodyUsedBeforeConsume, text };
};

// ── Scenario 3 — pre-aborted request (bullet 9) ─────────────────────────
window.__scenario3 = async function (baseUrl, queryId) {
const { transport, wrapper } = makeTransport(baseUrl);
const controller = new AbortController();
controller.abort();
const rejectedName = await nameOf(transport.send({
sql: 'SELECT 1',
defaultFormat: 'JSON',
params: { query_id: queryId },
authorization: 'Bearer tok',
signal: controller.signal,
}));
// One task turn for any deferred network activity to surface before
// the Node side inspects the fault server's request log.
await new Promise((r) => setTimeout(r, 50));
return { count: wrapper.count, rejectedName };
};

// ── Scenario 4 — abort while awaiting headers (bullet 10) ───────────────
// Two-phase: the Node side must observe the fault server has already
// logged the POST (real dispatch happened) before instructing the page
// to abort — otherwise this couldn't distinguish "cancelled before
// dispatch" from "cancelled while genuinely awaiting headers".
window.__scenario4Start = function (baseUrl, queryId) {
const { transport, wrapper } = makeTransport(baseUrl);
const controller = new AbortController();
const promise = transport.send({
sql: 'SELECT 1',
defaultFormat: 'JSON',
params: { query_id: queryId },
authorization: 'Bearer tok',
signal: controller.signal,
});
window.__scenario4State = { wrapper, controller, promise };
return true;
};
window.__scenario4AbortAndAwait = async function () {
const { wrapper, controller, promise } = window.__scenario4State;
controller.abort();
const rejectedName = await nameOf(promise);
return { count: wrapper.count, rejectedName };
};

// ── Scenario 5 — native post-header body lifetime (bullet 11) ──────────
// Two-phase: phase 1 settles send() and reads the immediate first chunk;
// phase 2 starts a second, genuinely pending native reader.read() (the
// fixture holds the next chunk for POST_HEADER_ABORT_HOLD_MS) and THEN
// aborts the original signal.
window.__scenario5Start = async function (baseUrl, queryId) {
const { transport, wrapper } = makeTransport(baseUrl);
const controller = new AbortController();
const resp = await transport.send({
sql: 'SELECT 1',
defaultFormat: 'JSON',
params: { query_id: queryId },
authorization: 'Bearer tok',
signal: controller.signal,
});
const identity = resp === wrapper.lastResponse;
const bodyUsedBeforeRead = resp.bodyUsed;
const reader = resp.body.getReader();
const first = await reader.read();
const firstText = first.value ? new TextDecoder().decode(first.value) : null;
window.__scenario5State = { controller, reader, resp };
return { identity, bodyUsedBeforeRead, firstDone: first.done, firstText };
};
// `reader.read()` is issued BEFORE `controller.abort()` (both inside the
// same synchronous turn of this one page.evaluate() call) so the pending
// read is genuinely in flight — not merely "about to be issued" — at the
// moment of cancellation.
window.__scenario5AbortAndReadNext = async function () {
const { controller, reader, resp } = window.__scenario5State;
const pending = reader.read();
controller.abort();
const rejectedName = await nameOf(pending);
return { rejectedName, sendResponseStatus: resp.status, sendResponseOk: resp.ok };
};

// ── Scenario 6 — no later streamLines() callbacks after cancellation
// (bullet 12) — single call: the abort is scheduled from inside the
// first onChunk callback, then the scenario itself waits out the
// fixture's hold before returning, so the Node side never needs its
// own timing loop. ───────────────────────────────────────────────────
window.__scenario6 = async function (baseUrl, queryId, holdMs) {
const { transport } = makeTransport(baseUrl);
const controller = new AbortController();
const resp = await transport.send({
sql: 'SELECT 1',
defaultFormat: 'JSON',
params: { query_id: queryId },
authorization: 'Bearer tok',
signal: controller.signal,
});
const lines = [];
let chunkCount = 0;
let aborted = false;
const streamPromise = transport.streamLines(resp.body, {
onLine: (l) => lines.push(l),
onChunk: () => {
chunkCount++;
if (!aborted) {
aborted = true;
controller.abort();
}
},
});
const rejectedName = await nameOf(streamPromise);
const linesAtRejection = lines.length;
const chunksAtRejection = chunkCount;
await new Promise((r) => setTimeout(r, holdMs + 1000)); // comfortably past the fixture's hold
return {
rejectedName,
linesAtRejection,
chunksAtRejection,
linesAfterWait: lines.length,
chunksAfterWait: chunkCount,
};
};

// ── Scenario 7 — concurrent-request isolation (bullet 13) ──────────────
// ONE shared transport instance for both A and B — a transport-global
// cancellation-state bug would only be observable this way.
window.__scenario7 = async function (baseUrl, queryIdA, queryIdB, holdMs) {
const { transport } = makeTransport(baseUrl);
const controllerA = new AbortController();
const controllerB = new AbortController();
const [respA, respB] = await Promise.all([
transport.send({ sql: 'SELECT 1', defaultFormat: 'JSON', params: { query_id: queryIdA }, authorization: 'Bearer tok', signal: controllerA.signal }),
transport.send({ sql: 'SELECT 1', defaultFormat: 'JSON', params: { query_id: queryIdB }, authorization: 'Bearer tok', signal: controllerB.signal }),
]);
const readerA = respA.body.getReader();
const readerB = respB.body.getReader();
await readerA.read(); // immediate first chunk, both sides
await readerB.read();
const pendingA = readerA.read(); // now both genuinely pending on the fixture's hold
const pendingB = readerB.read();
controllerA.abort();
const aRejectedName = await nameOf(pendingA);
const bResult = await pendingB; // B must NOT be affected by A's abort
const bFirstHeldText = bResult.value ? new TextDecoder().decode(bResult.value) : null;
const bNext = await readerB.read();
return { aRejectedName, bFirstHeldDone: bResult.done, bFirstHeldText, bCompletedCleanly: bNext.done };
};

// ── Scenario 8 — abort after full completion has no effect (bullet 14) ─
window.__scenario8 = async function (baseUrl, queryId) {
const { transport } = makeTransport(baseUrl);
const controller = new AbortController();
const resp = await transport.send({
sql: 'SELECT 1',
defaultFormat: 'JSON',
params: { query_id: queryId },
authorization: 'Bearer tok',
signal: controller.signal,
});
const lines = [];
let chunkCount = 0;
await transport.streamLines(resp.body, { onLine: (l) => lines.push(l), onChunk: () => { chunkCount++; } });
const linesBefore = lines.length;
const chunksBefore = chunkCount;
let abortThrew = false;
try {
controller.abort();
} catch {
abortThrew = true;
}
await new Promise((r) => setTimeout(r, 50));
return { linesBefore, chunksBefore, linesAfter: lines.length, chunksAfter: chunkCount, abortThrew };
};

// ── Extra — invalid UTF-8 raw bytes stay byte-identical at the native
// browser boundary (bullet 6), read via arrayBuffer(), never .text().
window.__scenarioInvalidUtf8 = async function (baseUrl, queryId) {
const { transport, wrapper } = makeTransport(baseUrl);
const resp = await transport.send({
sql: 'SELECT 1',
defaultFormat: 'JSON',
params: { query_id: queryId },
authorization: 'Bearer tok',
});
const identity = resp === wrapper.lastResponse;
const bodyUsedBeforeConsume = resp.bodyUsed;
const buf = await resp.arrayBuffer();
return { identity, bodyUsedBeforeConsume, status: resp.status, bytes: Array.from(new Uint8Array(buf)) };
};

window.__ready = true;
</script>
</body>
</html>
Loading