diff --git a/.changeset/quickstart-page-matches-readme.md b/.changeset/quickstart-page-matches-readme.md new file mode 100644 index 0000000..babcfa2 --- /dev/null +++ b/.changeset/quickstart-page-matches-readme.md @@ -0,0 +1,7 @@ +--- +"@cosyte/mllp": patch +--- + +The Quickstart page now opens with the same program as the README: a server and a client on loopback, one message sent and its acknowledgement read. + +Before this, the page opened with framing bytes by hand while the README opened with a server and a client, so a reader arriving from npm and one arriving from the documentation site met two different first programs. The framing, tolerance and encoder examples follow it unchanged. The test suite now fails if the page's first program or the output printed beside it differs from the README's, runs it against the built package, and compares what it prints with that output. Documentation and test change only. diff --git a/docs-content/quickstart.md b/docs-content/quickstart.md index 2b6575c..047f610 100644 --- a/docs-content/quickstart.md +++ b/docs-content/quickstart.md @@ -2,17 +2,69 @@ id: quickstart title: Quickstart description: >- - Frame an HL7 v2 message and read it back, tolerate a real sender's quirks, then send and receive - over a connection with the commit contract in place. + Start a server on loopback, send it an HL7 v2 message and read the acknowledgement it built, then + frame bytes yourself and tolerate a real sender's quirks. sidebar_position: 3 --- # Quickstart `@cosyte/mllp` is **transport, not parsing**: it frames HL7 v2 bytes onto the wire, correlates the -ACK that comes back, and never inspects the payload. The core primitive is the frame -(`VT + payload + FS + CR`) and everything else (the client, the server, reconnect, TLS) is built on -it. This page starts there, then shows the client and server you will actually deploy. +ACK that comes back, and never inspects the payload. This page starts with the program the README +opens with, a server and a client on loopback, then goes down to the frame (`VT + payload + FS + CR`) +that everything else (the client, the server, reconnect, TLS) is built on. + +## Send a message and read the acknowledgement + +Start a server, send it a message, and read the acknowledgement it built. Auto-ACK is on by default, +and the server awaits your handler, which is the durable commit step, before it answers. The payload +API is **Buffer-first** everywhere: HL7 v2 messages are raw bytes with caller-managed charset +decoding. + +```ts runnable +import { createStarterClient, createStarterServer } from "@cosyte/mllp"; + +const committed: Buffer[] = []; + +// Port 0: the OS picks a free port. The host defaults to loopback, 127.0.0.1. +const server = await createStarterServer({ + port: 0, + onMessage: async (payload) => { + committed.push(payload); // your durable commit: a throw here answers AE, never AA + }, +}); + +const client = await createStarterClient({ host: "127.0.0.1", port: server.getStats().port ?? 0 }); + +const ack = await client.send( + Buffer.from( + "MSH|^~\\&|SENDING_APP|SENDING_FAC|RECEIVING_APP|RECEIVING_FAC|20260101120000||ADT^A01|CTRL0001|P|2.5.1\r", + ), +); + +// Log the shape of the acknowledgement, never its field values. +const msa = ack + .toString("utf8") + .split("\r") + .find((segment) => segment.startsWith("MSA|")) + ?.split("|"); +console.log("acknowledgement code:", msa?.[1]); +console.log("MSA-2 echoes the control id sent:", msa?.[2] === "CTRL0001"); +console.log("messages committed:", committed.length); + +await client.close(); +await server.close(); +``` + +```text +acknowledgement code: AA +MSA-2 echoes the control id sent: true +messages committed: 1 +``` + +`MSA-2` echoes the control id you sent, byte for byte, which is what lets the client match the answer +to the message. The acknowledgement's own `MSH-7` and `MSH-10` change on every run. A handler that +throws is answered `AE`, never `AA`: see [ACKs & the commit contract](./acks.md). ## Frame a message, read it back @@ -133,8 +185,9 @@ This is the page to internalize before you put the package in front of a clinica > **About runnable examples.** The blocks tagged ` ```ts runnable ` above are extracted by the docs > build, executed against the package, and their `// =>` results asserted, so a documented example -> can never silently drift from the code. They stay at the framing layer because it runs -> deterministically in-process; the client/server blocks open real sockets, so they are shown as +> can never silently drift from the code. The first one opens a loopback socket on a port the +> operating system picks and closes both ends, so it runs as it stands. The two client and server +> blocks after the framing examples name a fixed port and a database of yours, so they are shown as > plain ` ```ts ` illustrations. For socket-free _integration_ tests, the `@cosyte/mllp/testing` > subpath's in-memory transport wires a `Connection` end-to-end with no ports and no certs: > [Testing & verification](./testing.md). diff --git a/documentation/release-0.1.0-readiness.md b/documentation/release-0.1.0-readiness.md index 176d87d..044b367 100644 --- a/documentation/release-0.1.0-readiness.md +++ b/documentation/release-0.1.0-readiness.md @@ -76,6 +76,7 @@ documentation of what already ships and contributor-only tooling, stays `patch`. | `plain-owls-supply.md` | not present, added by the Diffie-Hellman parameter change | **minor** | "**`tls.dhParameters` on `ServerTlsOptions`, PEM content, server side only.**" | | `first-use-examples-executed.md` | present, unlisted when the examples change found it | patch | "Docs: the README's usage example is now one complete program the test suite runs, and the quickstart's first example is held to a committed synthetic fixture." | | `what-0-1-means.md` | not present, added by the change that added `examples/` and the release metadata | **minor** | "`0.1.0` is the first release of `@cosyte/mllp` whose public API we ask you to build on." It adds no symbol; it is the release's own statement of what the `0.1` line covers and promises, and it moves nothing, since the minor rows above already take the release to `0.1.0`. | +| `quickstart-page-matches-readme.md` | not present, added by the change that made the docs quickstart open with the README's program | patch | "Documentation and test change only." | **Only the frontmatter bump-type line moved.** Every summary is byte-identical to what it was, and the diff for the five corrected files is one line each. A summary is the changelog entry for its diff --git a/test/docs-content.test.ts b/test/docs-content.test.ts index e66e627..ec2aacc 100644 --- a/test/docs-content.test.ts +++ b/test/docs-content.test.ts @@ -1,8 +1,9 @@ import { execFileSync } from "node:child_process"; import { readFileSync, rmSync } from "node:fs"; import { join } from "node:path"; +import { format } from "node:util"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { docSnippetSuite, @@ -14,6 +15,7 @@ import { compileErrors, fences, fixturesByContent, + section, wirePayloadLiteral, } from "./_helpers/first-use.js"; @@ -24,9 +26,11 @@ import { * runners). Blocks tagged ` ```ts runnable throws ` must throw; plain ` ```ts ` blocks are * illustrative and are not executed. * - * `@cosyte/mllp` is **transport, not parsing**, so the runnable blocks stay at the framing layer - * (`encodeFrame` / `FrameReader`), the transport primitive, which runs deterministically - * in-process. The client/server examples open real sockets and so are illustrative-only. + * `@cosyte/mllp` is **transport, not parsing**. The quickstart opens with the README's program, a + * server and a client on a loopback port the operating system picks, which closes both ends and so + * runs to completion in-process; every other runnable block stays at the framing layer + * (`encodeFrame` / `FrameReader`). The client/server examples that name a fixed port and a + * database of the reader's are illustrative-only. * * Snippets import the package the way a consumer does, against the **built** ESM artifact, not the * source tree. The harness executes each block as a standalone ES module, so it can't resolve the @@ -77,14 +81,19 @@ docSnippetSuite({ /** * The quickstart's FIRST example is the first thing a reader runs from the docs site, so it is held - * to more than the sweep above: it must be the block the sweep executes, the HL7 payload it puts on - * the wire must be a byte-for-byte copy of a committed fixture (the page is public, and `test/` is - * the corpus `pnpm phi-scan` reads), and a changed value in it must turn this suite red. Temp - * modules for these runs live in their own directory inside the root and are removed afterwards. + * to more than the sweep above: it must be the block the sweep executes, it must be the same program + * the README's Usage section opens with (two first-use routes that disagree send a reader two ways + * at once), it must print exactly the output shown beside it, the HL7 payload it puts on the wire + * must be a byte-for-byte copy of a committed fixture (the page is public, and `test/` is the corpus + * `pnpm phi-scan` reads), and a changed value in it must turn this suite red. Temp modules for these + * runs live in their own directory inside the root and are removed afterwards. */ const QUICKSTART = readFileSync(join(root, "docs-content", "quickstart.md"), "utf8"); -const QUICKSTART_FIRST = fences(QUICKSTART)[0]; +const QUICKSTART_FENCES = fences(QUICKSTART); +const QUICKSTART_FIRST = QUICKSTART_FENCES[0]; +const QUICKSTART_FIRST_SHOWN = QUICKSTART_FENCES[1]; const QUICKSTART_FIRST_RUNNABLE = extractRunnableSnippets(QUICKSTART)[0]; +const README_USAGE = fences(section(readFileSync(join(root, "README.md"), "utf8"), "## Usage")); const FIRST_USE_TMP = join(root, ".cosyte-first-use-snippets"); const FIXTURE_DIR = join(root, "test", "fixtures", "first-use"); /** @@ -100,12 +109,36 @@ afterAll(() => { rmSync(FIRST_USE_TMP, { recursive: true, force: true }); }); +/** + * Run a snippet against the built package and return what it printed, one `console.log` call per + * line, formatted the way Node prints it. The snippet runs in this process, so the spy sees it. + */ +async function printedBy(code: string): Promise { + const lines: string[] = []; + const spy = vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => { + lines.push(format(...args)); + }); + try { + await runSnippet(code, { resolve: resolveSnippetImport, tmpDir: FIRST_USE_TMP }); + } finally { + spy.mockRestore(); + } + return lines.join("\n"); +} + describe("the quickstart's first example", () => { it("AC-ML1: is a runnable TypeScript block, so the snippet sweep executes it", () => { expect(QUICKSTART_FIRST?.lang).toBe("ts"); expect(QUICKSTART_FIRST?.tags).toContain("runnable"); expect(QUICKSTART_FIRST?.tags).not.toContain("throws"); expect(QUICKSTART_FIRST_RUNNABLE?.code).toBe(QUICKSTART_FIRST?.body); + expect(QUICKSTART_FIRST_SHOWN?.lang).toBe("text"); + }); + + it("is the same program, with the same output, as the README's first Usage example", () => { + expect(README_USAGE[0]?.lang).toBe("ts"); + expect(QUICKSTART_FIRST?.body).toBe(README_USAGE[0]?.body); + expect(QUICKSTART_FIRST_SHOWN?.body).toBe(README_USAGE[1]?.body); }); it( @@ -122,22 +155,19 @@ describe("the quickstart's first example", () => { "AC-ML1: a block that does not compile is reported, so it turns this suite red", () => { const code = QUICKSTART_FIRST?.body ?? ""; - expect(code.split("received[0]?.equals").length - 1).toBe(1); - const mutated = code.replace("received[0]?.equals", "received[0].equals"); + expect(code.split("msa?.[1]").length - 1).toBe(1); + const mutated = code.replace("msa?.[1]", "msa[1]"); expect(compileErrors(root, "@cosyte/mllp", SOURCE_ENTRY, mutated)).toEqual([ - expect.stringContaining("TS2532"), + expect.stringContaining("TS18048"), ]); }, COMPILE_TIMEOUT, ); - it("AC-ML1: runs against the built package and every claimed value holds", async () => { + it("AC-ML1: runs against the built package and prints exactly the output shown beside it", async () => { expect(QUICKSTART_FIRST_RUNNABLE).toBeDefined(); if (QUICKSTART_FIRST_RUNNABLE === undefined) return; - await runSnippet(QUICKSTART_FIRST_RUNNABLE, { - resolve: resolveSnippetImport, - tmpDir: FIRST_USE_TMP, - }); + expect(await printedBy(QUICKSTART_FIRST_RUNNABLE.code)).toBe(QUICKSTART_FIRST_SHOWN?.body); }); it("AC-ML4: the payload it frames is a byte-for-byte copy of a first-use fixture", () => { @@ -146,10 +176,24 @@ describe("the quickstart's first example", () => { expect(fixturesByContent(root, FIXTURE_DIR).get(payload ?? "")).toBeDefined(); }); - it("AC-ML3: a changed claimed value turns the run red", async () => { + it("AC-ML4: the payload the framing example frames is a byte-for-byte copy of a first-use fixture", () => { + const payload = wirePayloadLiteral(extractRunnableSnippets(QUICKSTART)[1]?.code ?? ""); + expect(payload).toBeDefined(); + expect(fixturesByContent(root, FIXTURE_DIR).get(payload ?? "")).toBeDefined(); + }); + + it("AC-ML3: a changed input value changes what it prints, so the comparison above goes red", async () => { const code = QUICKSTART_FIRST_RUNNABLE?.code ?? ""; - expect(code.split("frame[0]; // => 0x0b").length - 1).toBe(1); - const mutated = code.replace("frame[0]; // => 0x0b", "frame[0]; // => 0x0c"); + expect(code.split("|CTRL0001|").length - 1).toBe(1); + const printed = await printedBy(code.replace("|CTRL0001|", "|CTRL0002|")); + expect(printed).not.toBe(QUICKSTART_FIRST_SHOWN?.body); + expect(printed).toContain("MSA-2 echoes the control id sent: false"); + }); + + it("AC-ML3: a changed claimed value in the framing example turns the run red", async () => { + const framing = extractRunnableSnippets(QUICKSTART)[1]?.code ?? ""; + expect(framing.split("frame[0]; // => 0x0b").length - 1).toBe(1); + const mutated = framing.replace("frame[0]; // => 0x0b", "frame[0]; // => 0x0c"); await expect( runSnippet(mutated, { resolve: resolveSnippetImport, tmpDir: FIRST_USE_TMP }), ).rejects.toThrow(); @@ -157,8 +201,8 @@ describe("the quickstart's first example", () => { it("AC-ML3: a changed input value leaves the fixture corpus, so the suite goes red", () => { const code = QUICKSTART_FIRST_RUNNABLE?.code ?? ""; - expect(code.split("|MSG00001|").length - 1).toBe(1); - const mutated = code.replace("|MSG00001|", "|MSG00002|"); + expect(code.split("|CTRL0001|").length - 1).toBe(1); + const mutated = code.replace("|CTRL0001|", "|CTRL0002|"); expect( fixturesByContent(root, FIXTURE_DIR).get(wirePayloadLiteral(mutated) ?? ""), ).toBeUndefined();