summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 15:01:02 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 15:01:02 -0300
commit36486007ccada8c31d30e8f528d21233cabce32c (patch)
treed14a108845e4c984ec922ba98d153c8d68da6ebb
downloadautofill-ceafecfbd5171c08d15dc0ca123d6d9a2582000d.tar.gz
Initial importv0.1.0
-rw-r--r--.gitignore5
-rw-r--r--.prettierignore3
-rw-r--r--.prettierrc.json3
-rw-r--r--LICENSE15
-rw-r--r--Makefile39
-rw-r--r--README.md232
-rw-r--r--demo.html160
-rw-r--r--package.json49
-rw-r--r--scripts/size.js15
-rw-r--r--src/global.js32
-rw-r--r--src/index.js614
-rw-r--r--test/browser.test.js336
-rw-r--r--test/fixture.html62
-rw-r--r--test/server.js171
14 files changed, 1736 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..26be789
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+dist/
+*.log
+.DS_Store
+bun.lock
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..190ddbf
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,3 @@
+dist/
+node_modules/
+bun.lock
diff --git a/.prettierrc.json b/.prettierrc.json
new file mode 100644
index 0000000..de753c5
--- /dev/null
+++ b/.prettierrc.json
@@ -0,0 +1,3 @@
+{
+ "printWidth": 100
+}
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..5e9104d
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,15 @@
+ISC License
+
+Copyright (c) 2026 Murilo Ijanc <[email protected]>
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+PERFORMANCE OF THIS SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..3545904
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,39 @@
+BUN ?= bun
+
+.DEFAULT_GOAL := help
+.PHONY: help install test lint format build check clean
+
+help:
+ @echo 'Targets:'
+ @echo ' install install the development dependencies'
+ @echo ' test run the browser test suite (bun test + Chromium)'
+ @echo ' lint prettier --check + a bundle syntax check (read-only)'
+ @echo ' format apply prettier (writes)'
+ @echo ' build bundle to dist/ and report the gzipped size'
+ @echo ' check lint + test + build, in that order'
+ @echo ' clean remove build artefacts'
+
+node_modules: package.json
+ $(BUN) install
+ @touch node_modules
+
+install: node_modules
+
+test: node_modules
+ $(BUN) test
+
+lint: node_modules
+ $(BUN) x prettier --check .
+ $(BUN) build ./src/global.js --target browser --format iife > /dev/null
+
+format: node_modules
+ $(BUN) x prettier --write .
+
+build: node_modules
+ rm -rf dist
+ $(BUN) run build
+
+check: lint test build
+
+clean:
+ rm -rf dist
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..242859a
--- /dev/null
+++ b/README.md
@@ -0,0 +1,232 @@
+# cepis-autofill
+
+Drop-in address autofill for Brazilian checkouts, on top of the
+[public CEP API at cep.is](https://cep.is). The shopper types a CEP; the
+widget fills the street, neighbourhood, city and state fields you point it
+at. Official Correios data (DNE).
+
+One `<script>` tag, no build step, no framework, no dependencies.
+**3.3 KB minified and gzipped** (7.7 KB minified, 7859 bytes).
+
+```html
+<script src="cepis-autofill.js"></script>
+<script>
+ CepisAutofill.attach({
+ cep: "#cep",
+ logradouro: "#endereco",
+ bairro: "#bairro",
+ localidade: "#cidade",
+ uf: "#estado",
+ numero: "#numero",
+ });
+</script>
+```
+
+Nothing about any particular e-commerce platform is baked in: which field
+is which is configuration. See
+[Mapping your platform's fields](#mapping-your-platforms-fields).
+
+## Install
+
+Copy `dist/cepis-autofill.min.js` next to your theme's other assets and load
+it with a `<script>` tag. It defines the global `CepisAutofill` and nothing
+else.
+
+Bundling instead? `dist/cepis-autofill.esm.js` is the same code as an ES
+module:
+
+```js
+import { attach } from "./cepis-autofill.esm.js";
+```
+
+Requires a browser with `fetch` and `AbortController` — every browser since 2017. Run `make build` to produce `dist/`.
+
+## Usage
+
+Give `attach` the field the shopper types into, plus the fields to fill.
+Every selector accepts a CSS string or an element:
+
+```js
+const handle = CepisAutofill.attach({
+ cep: "#cep",
+ logradouro: "#endereco",
+ bairro: "#bairro",
+ localidade: "#cidade",
+ uf: "#estado",
+ numero: "#numero",
+ focusNumero: true,
+});
+```
+
+A lookup goes out once the field holds 8 digits and the shopper stops
+typing. The `01001-000` mask is accepted. Repeat lookups of the same CEP are
+served from memory, so going back to fix the house number costs nothing.
+
+A `<select>` for the state is matched by option value or label, against both
+the two-letter UF and the full state name — `SP` and `São Paulo` both work.
+The `numero` field is never written to; it exists so `focusNumero` knows
+where to send the cursor.
+
+`attach` returns a handle:
+
+```js
+handle.lookup(); // run now, skipping the debounce
+handle.lookup("01001-000"); // …for a specific CEP
+handle.detach(); // remove listeners, abort anything in flight
+```
+
+Call `handle.lookup()` when your checkout writes into the CEP field without
+firing an `input` event — a "use my saved address" button, for instance.
+
+### Options
+
+| option | default | what it does |
+| ----------------------------------------------------------------------------------------------------------------------- | ---------------- | --------------------------------------------------------- |
+| `cep` | — | the field the shopper types into (required) |
+| `logradouro`, `complemento`, `unidade`, `bairro`, `localidade`, `uf`, `estado`, `regiao`, `ibge`, `gia`, `ddd`, `siafi` | — | fields to fill; map only the ones you have |
+| `numero` | — | house-number field, for `focusNumero` |
+| `baseUrl` | `https://cep.is` | API root; point it at your own instance |
+| `debounceMs` | `250` | quiet period after the last keystroke |
+| `timeoutMs` | `8000` | deadline for one request |
+| `overwrite` | `"auto"` | what to do with a field that already has a value |
+| `focusNumero` | `false` | move focus to `numero` after a successful lookup |
+| `status` | auto | where to announce progress |
+| `messages` | see below | override any status message |
+| `onResult` | — | `(address, { cep }) => void`, after the fields are filled |
+| `onError` | — | `(error, { cep }) => void` |
+| `onStatus` | — | `(state, message) => void` for a custom UI |
+
+`overwrite` decides what happens to a field that is not empty:
+
+- `"auto"` — fill empty fields, replace values this widget wrote itself, and
+ leave anything the shopper typed alone. A manual correction survives the
+ next CEP.
+- `false` — only ever fill empty fields.
+- `true` — always overwrite.
+
+The widget writes through the native value setter and dispatches `input` and
+`change`, so validation in the page — including React's — sees the values.
+It never reformats what the shopper typed in the CEP field.
+
+### Status and accessibility
+
+Unless you pass `status`, a `<div role="status" aria-live="polite">` is
+inserted right after the CEP field and referenced from its
+`aria-describedby`. It is created empty, at a fixed minimum height, so
+announcing something never shifts the page. Focus is never taken, except by
+`focusNumero`, which moves it only while the CEP field still has focus.
+
+Style it through `.cepis-autofill-status[data-cepis-state="…"]`, where the
+state is `idle`, `loading`, `found` or `error`. The message text carries the
+meaning on its own — do not use colour as the only signal:
+
+```css
+.cepis-autofill-status[data-cepis-state="error"]::before {
+ content: "! ";
+}
+```
+
+Pass `status: "#my-node"` to use an element of your own, or `status: false`
+to render nothing and drive your own UI from `onStatus`.
+
+### Messages and errors
+
+Every failure has its own message, in Portuguese, and reaches both the
+status region and `onError`:
+
+| `error.code` | message | worth retrying? |
+| ------------------ | ------------------------------------------------------------------------- | --------------- |
+| `invalid` | CEP inválido. Digite os 8 dígitos. | no |
+| `not_found` | CEP não encontrado. Confira o número ou preencha o endereço manualmente. | no |
+| `rate_limited` | Muitas consultas em pouco tempo. Aguarde alguns segundos e tente de novo. | later |
+| `server` | O serviço de CEP está indisponível. Preencha o endereço manualmente. | later |
+| `network` | Não foi possível consultar o CEP. Verifique sua conexão e tente de novo. | later |
+| `timeout` | A consulta do CEP demorou demais. Tente de novo ou preencha manualmente. | later |
+| `invalid_response` | Resposta inesperada do serviço de CEP. Preencha o endereço manualmente. | probably not |
+
+Override any of them with `messages`:
+
+```js
+CepisAutofill.attach({
+ cep: "#cep",
+ localidade: "#cidade",
+ messages: { not_found: "Não achamos esse CEP. Digite o endereço." },
+});
+```
+
+`rate_limited` carries `retryAfterMs` and `server` carries `status`.
+
+### Looking a CEP up yourself
+
+`CepisAutofill.lookup` is the request layer on its own — same cache, same
+errors, no DOM:
+
+```js
+const address = await CepisAutofill.lookup("01001-000");
+address.logradouro; // "Praça da Sé"
+address.uf; // "SP"
+```
+
+It resolves to the API's own JSON: Portuguese field names, empty strings
+rather than nulls, and `geo` present only for the roughly 57% of CEPs that
+have a coordinate.
+
+## Mapping your platform's fields
+
+Every checkout names its fields differently, and the same platform names
+them differently between themes, versions and one-page checkout apps. **No
+per-platform preset is shipped here, because none could be verified against
+a live store.** Finding yours takes a minute:
+
+1. Open your checkout, right-click the CEP field, Inspect.
+2. Read the `id` or `name` on the `<input>`. An `id` gives you `#billing_postcode`;
+ a `name` gives you `[name="postalCode"]`.
+3. Do the same for street, neighbourhood, city, state and number.
+4. Paste this in the console to confirm every selector resolves:
+
+```js
+["#cep", "#endereco", "#bairro", "#cidade", "#estado", "#numero"].forEach((s) =>
+ console.log(s, document.querySelector(s)),
+);
+```
+
+Anything printing `null` is wrong. A selector that resolves to nothing is
+skipped silently, which is what makes a wrong one hard to spot.
+
+Two things worth knowing for checkouts that re-render (VTEX, Shopify and
+most React-based ones):
+
+- Pass **CSS strings, not elements**. Strings are resolved on every use and
+ the listener is delegated from `document`, so a form that is destroyed and
+ rebuilt keeps working. Elements are held directly and stop working when
+ that node is replaced.
+- Load the widget after the checkout has rendered its form once, or call
+ `attach` from whatever hook the platform gives you for "checkout ready".
+
+Where the CEP field is a component that writes its value without firing
+`input`, keep the handle and call `handle.lookup()` from the component's own
+change hook.
+
+## Development
+
+```sh
+make # list the targets
+make test # bun test — drives a real Chromium against a fake API
+make lint # prettier --check plus a bundle syntax check
+make format # apply prettier
+make build # bundle to dist/ and print the gzipped size
+make check # lint, then test, then build
+```
+
+The tests stand up two `Bun.serve` instances — one serving the checkout
+fixture with a freshly built bundle, one impersonating cep.is — and drive
+Chromium through Playwright: real keystrokes, real `fetch`, real aborts.
+They need a Chromium build; install one with
+`bunx playwright install chromium`, or point `CEPIS_CHROMIUM` at an existing
+binary.
+
+`demo.html` runs against the real API. Run `make build` and open it.
+
+## Licence
+
+ISC. See [LICENSE](LICENSE).
diff --git a/demo.html b/demo.html
new file mode 100644
index 0000000..eae1028
--- /dev/null
+++ b/demo.html
@@ -0,0 +1,160 @@
+<!doctype html>
+<html lang="pt-BR">
+ <head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <title>cepis-autofill — demonstração</title>
+ <style>
+ :root {
+ color-scheme: light dark;
+ }
+ body {
+ font:
+ 16px/1.5 system-ui,
+ sans-serif;
+ margin: 0 auto;
+ max-width: 34rem;
+ padding: 2rem 1rem 4rem;
+ }
+ h1 {
+ font-size: 1.4rem;
+ }
+ p {
+ color: #555;
+ }
+ form {
+ display: grid;
+ gap: 0.25rem;
+ }
+ label {
+ display: block;
+ font-size: 0.85rem;
+ font-weight: 600;
+ margin-top: 0.75rem;
+ }
+ input,
+ select {
+ border: 1px solid #999;
+ border-radius: 4px;
+ box-sizing: border-box;
+ font: inherit;
+ padding: 0.5rem;
+ width: 100%;
+ }
+ .row {
+ display: grid;
+ gap: 0.75rem;
+ grid-template-columns: 2fr 1fr;
+ }
+ .cepis-autofill-status {
+ font-size: 0.85rem;
+ margin-top: 0.25rem;
+ }
+ .cepis-autofill-status[data-cepis-state="loading"]::before {
+ content: "… ";
+ }
+ .cepis-autofill-status[data-cepis-state="found"] {
+ color: #146c2e;
+ }
+ .cepis-autofill-status[data-cepis-state="found"]::before {
+ content: "✓ ";
+ }
+ .cepis-autofill-status[data-cepis-state="error"] {
+ color: #a4262c;
+ }
+ .cepis-autofill-status[data-cepis-state="error"]::before {
+ content: "! ";
+ }
+ @media (prefers-color-scheme: dark) {
+ p {
+ color: #aaa;
+ }
+ .cepis-autofill-status[data-cepis-state="found"] {
+ color: #6dd48a;
+ }
+ .cepis-autofill-status[data-cepis-state="error"] {
+ color: #ff9a9e;
+ }
+ }
+ </style>
+ </head>
+ <body>
+ <h1>cepis-autofill</h1>
+ <p>
+ Digite um CEP — por exemplo <code>01001-000</code>, <code>20040-020</code> ou
+ <code>99999-999</code> para ver o erro. A consulta vai para <code>https://cep.is</code>.
+ </p>
+
+ <form id="checkout" autocomplete="on">
+ <label for="cep">CEP</label>
+ <input id="cep" name="cep" inputmode="numeric" autocomplete="postal-code" />
+
+ <label for="logradouro">Endereço</label>
+ <input id="logradouro" name="logradouro" autocomplete="address-line1" />
+
+ <div class="row">
+ <div>
+ <label for="bairro">Bairro</label>
+ <input id="bairro" name="bairro" />
+ </div>
+ <div>
+ <label for="numero">Número</label>
+ <input id="numero" name="numero" inputmode="numeric" />
+ </div>
+ </div>
+
+ <div class="row">
+ <div>
+ <label for="cidade">Cidade</label>
+ <input id="cidade" name="cidade" autocomplete="address-level2" />
+ </div>
+ <div>
+ <label for="uf">UF</label>
+ <select id="uf" name="uf" autocomplete="address-level1">
+ <option value="">--</option>
+ <option value="AC">Acre</option>
+ <option value="AL">Alagoas</option>
+ <option value="AM">Amazonas</option>
+ <option value="AP">Amapá</option>
+ <option value="BA">Bahia</option>
+ <option value="CE">Ceará</option>
+ <option value="DF">Distrito Federal</option>
+ <option value="ES">Espírito Santo</option>
+ <option value="GO">Goiás</option>
+ <option value="MA">Maranhão</option>
+ <option value="MG">Minas Gerais</option>
+ <option value="MS">Mato Grosso do Sul</option>
+ <option value="MT">Mato Grosso</option>
+ <option value="PA">Pará</option>
+ <option value="PB">Paraíba</option>
+ <option value="PE">Pernambuco</option>
+ <option value="PI">Piauí</option>
+ <option value="PR">Paraná</option>
+ <option value="RJ">Rio de Janeiro</option>
+ <option value="RN">Rio Grande do Norte</option>
+ <option value="RO">Rondônia</option>
+ <option value="RR">Roraima</option>
+ <option value="RS">Rio Grande do Sul</option>
+ <option value="SC">Santa Catarina</option>
+ <option value="SE">Sergipe</option>
+ <option value="SP">São Paulo</option>
+ <option value="TO">Tocantins</option>
+ </select>
+ </div>
+ </div>
+ </form>
+
+ <script src="dist/cepis-autofill.js"></script>
+ <script>
+ CepisAutofill.attach({
+ cep: "#cep",
+ logradouro: "#logradouro",
+ bairro: "#bairro",
+ localidade: "#cidade",
+ uf: "#uf",
+ numero: "#numero",
+ focusNumero: true,
+ });
+ </script>
+ </body>
+</html>
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..798ab4b
--- /dev/null
+++ b/package.json
@@ -0,0 +1,49 @@
+{
+ "name": "cepis-autofill",
+ "version": "0.1.0",
+ "description": "Drop-in address autofill widget for Brazilian checkouts, on top of the CEP API at cep.is",
+ "keywords": [
+ "cep",
+ "correios",
+ "checkout",
+ "autofill",
+ "address",
+ "brasil",
+ "brazil",
+ "widget"
+ ],
+ "homepage": "https://cep.is",
+ "license": "ISC",
+ "author": "Murilo Ijanc <[email protected]>",
+ "type": "module",
+ "sideEffects": [
+ "./src/global.js"
+ ],
+ "main": "./dist/cepis-autofill.esm.js",
+ "module": "./dist/cepis-autofill.esm.js",
+ "unpkg": "./dist/cepis-autofill.min.js",
+ "jsdelivr": "./dist/cepis-autofill.min.js",
+ "exports": {
+ ".": "./dist/cepis-autofill.esm.js",
+ "./package.json": "./package.json"
+ },
+ "files": [
+ "dist",
+ "README.md",
+ "LICENSE"
+ ],
+ "scripts": {
+ "test": "bun test",
+ "lint": "prettier --check .",
+ "format": "prettier --write .",
+ "build": "bun run build:iife && bun run build:min && bun run build:esm && bun run size",
+ "build:iife": "bun build ./src/global.js --target browser --format iife --outfile dist/cepis-autofill.js",
+ "build:min": "bun build ./src/global.js --target browser --format iife --minify --outfile dist/cepis-autofill.min.js",
+ "build:esm": "bun build ./src/index.js --target browser --format esm --outfile dist/cepis-autofill.esm.js",
+ "size": "bun run scripts/size.js"
+ },
+ "devDependencies": {
+ "playwright-core": "^1.62.0",
+ "prettier": "^3.9.6"
+ }
+}
diff --git a/scripts/size.js b/scripts/size.js
new file mode 100644
index 0000000..7583e86
--- /dev/null
+++ b/scripts/size.js
@@ -0,0 +1,15 @@
+import { gzipSync } from "bun";
+
+const files = [
+ "dist/cepis-autofill.js",
+ "dist/cepis-autofill.min.js",
+ "dist/cepis-autofill.esm.js",
+];
+
+for (const path of files) {
+ const bytes = await Bun.file(path).bytes();
+ const gzipped = gzipSync(bytes).length;
+ console.log(
+ `${path.padEnd(28)} ${String(bytes.length).padStart(6)} B ${String(gzipped).padStart(5)} B gzipped`,
+ );
+}
diff --git a/src/global.js b/src/global.js
new file mode 100644
index 0000000..217a9a0
--- /dev/null
+++ b/src/global.js
@@ -0,0 +1,32 @@
+/**
+ * Entry point for the `<script>` build: publishes the same API as the module
+ * on `window.CepisAutofill`.
+ *
+ * @module
+ */
+
+import {
+ attach,
+ clearCache,
+ lookup,
+ normalizeCep,
+ CepisAutofillError,
+ MESSAGES,
+ VERSION,
+} from "./index.js";
+
+const CepisAutofill = {
+ attach,
+ lookup,
+ normalizeCep,
+ clearCache,
+ CepisAutofillError,
+ MESSAGES,
+ VERSION,
+};
+
+if (typeof window !== "undefined") {
+ window.CepisAutofill = CepisAutofill;
+}
+
+export default CepisAutofill;
diff --git a/src/index.js b/src/index.js
new file mode 100644
index 0000000..d2f531e
--- /dev/null
+++ b/src/index.js
@@ -0,0 +1,614 @@
+/**
+ * Drop-in address autofill for Brazilian checkouts, on top of the public CEP
+ * API at cep.is.
+ *
+ * The shopper types a CEP; the widget fills the street, neighbourhood, city and
+ * state fields you point it at. Field mapping is configuration: nothing about
+ * any particular e-commerce platform is baked in.
+ *
+ * @module
+ */
+
+/** Version of this widget. */
+export const VERSION = "0.1.0";
+
+const DEFAULT_BASE_URL = "https://cep.is";
+const DEFAULT_DEBOUNCE_MS = 250;
+const DEFAULT_TIMEOUT_MS = 8000;
+
+const NOT_DIGIT = /\D+/g;
+const DIACRITIC = /[\u0300-\u036f]/g;
+
+const ADDRESS_FIELDS = [
+ "cep",
+ "logradouro",
+ "complemento",
+ "unidade",
+ "bairro",
+ "localidade",
+ "uf",
+ "estado",
+ "regiao",
+ "ibge",
+ "gia",
+ "ddd",
+ "siafi",
+];
+
+/**
+ * The message shown for each state, in Portuguese. Override any of them
+ * through the `messages` option of {@link attach}.
+ */
+export const MESSAGES = {
+ idle: "",
+ loading: "Buscando endereço…",
+ found: "Endereço encontrado.",
+ not_found: "CEP não encontrado. Confira o número ou preencha o endereço manualmente.",
+ invalid: "CEP inválido. Digite os 8 dígitos.",
+ rate_limited: "Muitas consultas em pouco tempo. Aguarde alguns segundos e tente de novo.",
+ server: "O serviço de CEP está indisponível. Preencha o endereço manualmente.",
+ network: "Não foi possível consultar o CEP. Verifique sua conexão e tente de novo.",
+ timeout: "A consulta do CEP demorou demais. Tente de novo ou preencha manualmente.",
+ invalid_response: "Resposta inesperada do serviço de CEP. Preencha o endereço manualmente.",
+};
+
+/**
+ * Everything this widget throws or hands to `onError`.
+ *
+ * `code` is what you branch on. It is the decision, not the HTTP status:
+ *
+ * | code | when | worth retrying? |
+ * | --- | --- | --- |
+ * | `invalid` | not 8 digits, or the API rejected the format | no |
+ * | `not_found` | well-formed, absent from the DNE database | no |
+ * | `rate_limited` | 429; `retryAfterMs` carries what the server asked for | later |
+ * | `server` | 5xx; `status` carries the code | later |
+ * | `network` | DNS, refused connection, TLS, offline | later |
+ * | `timeout` | the request outlived `timeoutMs` | later |
+ * | `invalid_response` | a success status with an unreadable body | probably not |
+ * | `aborted` | superseded by a newer keystroke; never surfaced to the shopper | no |
+ */
+export class CepisAutofillError extends Error {
+ /**
+ * @param {string} code One of the codes in the table above.
+ * @param {string} message Human message, in Portuguese.
+ * @param {{status?: number, retryAfterMs?: number, cep?: string, cause?: unknown}} [details]
+ */
+ constructor(code, message, details = {}) {
+ super(message, details.cause === undefined ? undefined : { cause: details.cause });
+ this.name = "CepisAutofillError";
+ this.code = code;
+ this.status = details.status;
+ this.retryAfterMs = details.retryAfterMs;
+ this.cep = details.cep;
+ }
+}
+
+/**
+ * Return the CEP's 8 digits, accepting the `01001-000` mask.
+ *
+ * @param {string} value Anything the shopper typed.
+ * @returns {string} The 8 digits.
+ * @throws {CepisAutofillError} code `invalid`, when 8 digits do not remain.
+ */
+export function normalizeCep(value) {
+ const digits = String(value == null ? "" : value).replace(NOT_DIGIT, "");
+ if (digits.length !== 8) {
+ throw new CepisAutofillError("invalid", MESSAGES.invalid, { cep: digits });
+ }
+ return digits;
+}
+
+const cache = new Map();
+
+/** Drop every cached lookup. Rarely needed: the cache dies with the page. */
+export function clearCache() {
+ cache.clear();
+}
+
+function retryAfterMs(header) {
+ if (!header) {
+ return undefined;
+ }
+ const value = header.trim();
+ if (/^\d+$/.test(value)) {
+ return Number(value) * 1000;
+ }
+ const target = Date.parse(value);
+ return Number.isNaN(target) ? undefined : Math.max(0, target - Date.now());
+}
+
+function apiMessage(body, fallback) {
+ try {
+ const data = JSON.parse(body);
+ if (data && typeof data.mensagem === "string" && data.mensagem !== "") {
+ return data.mensagem;
+ }
+ } catch {
+ return fallback;
+ }
+ return fallback;
+}
+
+/**
+ * Look up one CEP on the API.
+ *
+ * Successful answers and "does not exist" are cached in memory for the life of
+ * the page, keyed by CEP and base URL; transient failures are not. Callers get
+ * the address object exactly as the API returns it — Portuguese field names,
+ * empty strings rather than nulls, and `geo` present only for the CEPs that
+ * have a coordinate.
+ *
+ * @param {string} cep Masked or unmasked.
+ * @param {{baseUrl?: string, timeoutMs?: number, signal?: AbortSignal}} [options]
+ * @returns {Promise<Record<string, any>>} The address.
+ * @throws {CepisAutofillError} See the table on {@link CepisAutofillError}.
+ */
+export async function lookup(cep, options = {}) {
+ const digits = normalizeCep(cep);
+ const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
+ const key = `${baseUrl}|${digits}`;
+
+ const hit = cache.get(key);
+ if (hit) {
+ if (hit.address) {
+ return hit.address;
+ }
+ throw new CepisAutofillError(hit.code, hit.message, { cep: digits });
+ }
+
+ const controller = new AbortController();
+ const forward = () => controller.abort();
+ if (options.signal) {
+ if (options.signal.aborted) {
+ throw new CepisAutofillError("aborted", "consulta cancelada", { cep: digits });
+ }
+ options.signal.addEventListener("abort", forward);
+ }
+
+ let timedOut = false;
+ const timer = setTimeout(() => {
+ timedOut = true;
+ controller.abort();
+ }, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
+
+ let response;
+ let body;
+ try {
+ response = await fetch(`${baseUrl}/${digits}`, {
+ method: "GET",
+ credentials: "omit",
+ headers: { Accept: "application/json" },
+ signal: controller.signal,
+ });
+ body = await response.text();
+ } catch (error) {
+ if (timedOut) {
+ throw new CepisAutofillError("timeout", MESSAGES.timeout, { cep: digits, cause: error });
+ }
+ if (controller.signal.aborted) {
+ throw new CepisAutofillError("aborted", "consulta cancelada", { cep: digits });
+ }
+ throw new CepisAutofillError("network", MESSAGES.network, { cep: digits, cause: error });
+ } finally {
+ clearTimeout(timer);
+ if (options.signal) {
+ options.signal.removeEventListener("abort", forward);
+ }
+ }
+
+ if (response.status === 404) {
+ cache.set(key, { code: "not_found", message: MESSAGES.not_found });
+ throw new CepisAutofillError("not_found", MESSAGES.not_found, { cep: digits });
+ }
+ if (response.status === 400) {
+ throw new CepisAutofillError("invalid", apiMessage(body, MESSAGES.invalid), { cep: digits });
+ }
+ if (response.status === 429) {
+ throw new CepisAutofillError("rate_limited", MESSAGES.rate_limited, {
+ cep: digits,
+ retryAfterMs: retryAfterMs(response.headers.get("Retry-After")),
+ });
+ }
+ if (response.status >= 500) {
+ throw new CepisAutofillError("server", MESSAGES.server, {
+ cep: digits,
+ status: response.status,
+ });
+ }
+ if (!response.ok) {
+ throw new CepisAutofillError("invalid_response", MESSAGES.invalid_response, {
+ cep: digits,
+ status: response.status,
+ });
+ }
+
+ let address;
+ try {
+ address = JSON.parse(body);
+ } catch (error) {
+ throw new CepisAutofillError("invalid_response", MESSAGES.invalid_response, {
+ cep: digits,
+ cause: error,
+ });
+ }
+ if (!address || typeof address !== "object" || Array.isArray(address)) {
+ throw new CepisAutofillError("invalid_response", MESSAGES.invalid_response, { cep: digits });
+ }
+
+ cache.set(key, { address });
+ return address;
+}
+
+function resolve(target) {
+ if (!target) {
+ return null;
+ }
+ if (typeof target === "string") {
+ return document.querySelector(target);
+ }
+ return target.nodeType === 1 ? target : null;
+}
+
+function fold(value) {
+ return String(value ?? "")
+ .normalize("NFD")
+ .replace(DIACRITIC, "")
+ .trim()
+ .toLowerCase();
+}
+
+function assign(element, value) {
+ const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(element), "value");
+ if (setter && setter.set) {
+ setter.set.call(element, value);
+ } else {
+ element.value = value;
+ }
+ element.dispatchEvent(new Event("input", { bubbles: true }));
+ element.dispatchEvent(new Event("change", { bubbles: true }));
+}
+
+function selectOption(element, candidates) {
+ const wanted = candidates.filter(Boolean).map(fold);
+ for (const option of Array.from(element.options)) {
+ if (wanted.includes(fold(option.value)) || wanted.includes(fold(option.textContent))) {
+ assign(element, option.value);
+ return option.value;
+ }
+ }
+ return null;
+}
+
+function writable(element, mode, written) {
+ const current = String(element.value ?? "");
+ if (mode === true) {
+ return true;
+ }
+ if (current.trim() === "") {
+ return true;
+ }
+ if (mode === false) {
+ return false;
+ }
+ return written.get(element) === current;
+}
+
+let regionCount = 0;
+
+/**
+ * Attach the widget to a form.
+ *
+ * Every selector accepts a CSS string or an element. Strings are resolved on
+ * every use and the CEP listener is delegated from `document`, so a checkout
+ * that re-renders its form keeps working; elements are held directly and stop
+ * working if that node is replaced.
+ *
+ * ```js
+ * const handle = CepisAutofill.attach({
+ * cep: "#billing_postcode",
+ * logradouro: "#billing_address_1",
+ * bairro: "#billing_neighborhood",
+ * localidade: "#billing_city",
+ * uf: "#billing_state",
+ * numero: "#billing_number",
+ * });
+ * ```
+ *
+ * @param {object} options
+ * @param {string|Element} options.cep The field the shopper types into. Required.
+ * @param {string|Element} [options.logradouro] Street field.
+ * @param {string|Element} [options.complemento] Complement field.
+ * @param {string|Element} [options.unidade] Unit field.
+ * @param {string|Element} [options.bairro] Neighbourhood field.
+ * @param {string|Element} [options.localidade] City field.
+ * @param {string|Element} [options.uf] State field. A `<select>` is matched by
+ * option value or label, against both the two-letter UF and the state name.
+ * @param {string|Element} [options.estado] State-name field.
+ * @param {string|Element} [options.regiao] Region field.
+ * @param {string|Element} [options.ibge] IBGE code field.
+ * @param {string|Element} [options.gia] GIA code field.
+ * @param {string|Element} [options.ddd] Area code field.
+ * @param {string|Element} [options.siafi] SIAFI code field.
+ * @param {string|Element} [options.numero] House-number field. Never written
+ * to — only used by `focusNumero`.
+ * @param {string} [options.baseUrl] API root. Default `https://cep.is`; point
+ * it at a local instance to test.
+ * @param {number} [options.debounceMs] Quiet period after the last keystroke
+ * before a request goes out. Default `250`.
+ * @param {number} [options.timeoutMs] Deadline for one request. Default `8000`.
+ * @param {boolean|"auto"} [options.overwrite] What to do with a field that
+ * already has a value. `"auto"` (default) replaces empty fields and values
+ * this widget wrote itself, and leaves anything the shopper typed alone;
+ * `false` only ever fills empty fields; `true` always overwrites.
+ * @param {boolean} [options.focusNumero] Move focus to the `numero` field
+ * after a successful lookup, and only while the CEP field still has focus.
+ * Default `false`.
+ * @param {string|Element|false} [options.status] Where to announce progress.
+ * Omit to have an `aria-live` region created right after the CEP field, pass
+ * a selector or element to use your own, or `false` to render nothing and
+ * drive your own UI from `onStatus`.
+ * @param {Record<string, string>} [options.messages] Overrides for
+ * {@link MESSAGES}.
+ * @param {(address: Record<string, any>, context: {cep: string}) => void} [options.onResult]
+ * Called after a successful lookup, once the fields are filled.
+ * @param {(error: CepisAutofillError, context: {cep: string}) => void} [options.onError]
+ * Called for every failure the shopper sees. Never called for `aborted`.
+ * @param {(state: string, message: string) => void} [options.onStatus] Called
+ * on every state change: `idle`, `loading`, `found`, or an error code.
+ * @returns {{detach: () => void, lookup: (cep?: string) => Promise<Record<string, any>|null>, element: () => Element|null}}
+ * `detach()` removes every listener, aborts anything in flight and removes a
+ * status region it created. `lookup()` runs the whole pipeline immediately,
+ * skipping the debounce, for checkouts that fill the CEP field without
+ * firing events.
+ */
+export function attach(options) {
+ if (!options || !options.cep) {
+ throw new TypeError("attach() needs a `cep` selector or element");
+ }
+
+ const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
+ const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
+ const overwrite = options.overwrite ?? "auto";
+ const messages = { ...MESSAGES, ...(options.messages ?? {}) };
+ const written = new WeakMap();
+
+ const state = {
+ seq: 0,
+ timer: 0,
+ controller: null,
+ region: null,
+ describedBy: false,
+ detached: false,
+ };
+
+ const source = () => resolve(options.cep);
+
+ function region() {
+ if (options.status === false) {
+ return null;
+ }
+ if (state.region && state.region.isConnected) {
+ return state.region;
+ }
+ if (options.status) {
+ state.region = resolve(options.status);
+ } else {
+ const anchor = source();
+ if (!anchor) {
+ return null;
+ }
+ const node = document.createElement("div");
+ node.className = "cepis-autofill-status";
+ node.style.minHeight = "1.25em";
+ anchor.insertAdjacentElement("afterend", node);
+ state.region = node;
+ }
+ const node = state.region;
+ if (!node) {
+ return null;
+ }
+ node.setAttribute("role", "status");
+ node.setAttribute("aria-live", "polite");
+ node.setAttribute("aria-atomic", "true");
+ if (!node.id) {
+ regionCount += 1;
+ node.id = `cepis-autofill-status-${regionCount}`;
+ }
+ const anchor = source();
+ if (anchor && !(anchor.getAttribute("aria-describedby") ?? "").includes(node.id)) {
+ const previous = anchor.getAttribute("aria-describedby");
+ anchor.setAttribute("aria-describedby", previous ? `${previous} ${node.id}` : node.id);
+ state.describedBy = true;
+ }
+ return node;
+ }
+
+ function announce(kind) {
+ const message = messages[kind] ?? "";
+ const node = region();
+ if (node) {
+ node.setAttribute(
+ "data-cepis-state",
+ kind === "found" || kind === "loading" ? kind : kind === "idle" ? "idle" : "error",
+ );
+ node.textContent = message;
+ }
+ const anchor = source();
+ if (anchor) {
+ if (kind === "loading") {
+ anchor.setAttribute("aria-busy", "true");
+ } else {
+ anchor.removeAttribute("aria-busy");
+ }
+ anchor.setAttribute(
+ "aria-invalid",
+ kind === "invalid" || kind === "not_found" ? "true" : "false",
+ );
+ }
+ if (options.onStatus) {
+ options.onStatus(kind, message);
+ }
+ }
+
+ function fill(address) {
+ for (const field of ADDRESS_FIELDS) {
+ if (field === "cep" || !options[field]) {
+ continue;
+ }
+ const element = resolve(options[field]);
+ const value = address[field];
+ if (!element || typeof value !== "string" || value === "") {
+ continue;
+ }
+ if (!writable(element, overwrite, written)) {
+ continue;
+ }
+ if (element.tagName === "SELECT") {
+ const chosen = selectOption(
+ element,
+ field === "uf"
+ ? [value, address.estado]
+ : field === "estado"
+ ? [value, address.uf]
+ : [value],
+ );
+ if (chosen !== null) {
+ written.set(element, element.value);
+ }
+ continue;
+ }
+ assign(element, value);
+ written.set(element, value);
+ }
+ }
+
+ async function run(digits) {
+ state.seq += 1;
+ const seq = state.seq;
+ if (state.controller) {
+ state.controller.abort();
+ }
+ const controller = new AbortController();
+ state.controller = controller;
+ announce("loading");
+
+ try {
+ const address = await lookup(digits, { baseUrl, timeoutMs, signal: controller.signal });
+ if (seq !== state.seq || state.detached) {
+ return null;
+ }
+ fill(address);
+ announce("found");
+ if (options.onResult) {
+ options.onResult(address, { cep: digits });
+ }
+ const numero = resolve(options.numero);
+ if (options.focusNumero && numero && document.activeElement === source()) {
+ numero.focus();
+ }
+ return address;
+ } catch (error) {
+ if (seq !== state.seq || state.detached || error.code === "aborted") {
+ return null;
+ }
+ const code = error instanceof CepisAutofillError ? error.code : "network";
+ announce(code);
+ if (options.onError) {
+ options.onError(error, { cep: digits });
+ }
+ return null;
+ } finally {
+ if (state.controller === controller) {
+ state.controller = null;
+ }
+ }
+ }
+
+ function handle(event) {
+ const target = event.target;
+ const matches =
+ typeof options.cep === "string"
+ ? target && target.nodeType === 1 && target.matches(options.cep)
+ : target === options.cep;
+ if (!matches) {
+ return;
+ }
+ schedule(String(target.value ?? ""));
+ }
+
+ function schedule(value) {
+ clearTimeout(state.timer);
+ const digits = value.replace(NOT_DIGIT, "");
+ if (digits.length !== 8) {
+ state.seq += 1;
+ if (state.controller) {
+ state.controller.abort();
+ state.controller = null;
+ }
+ announce(digits.length > 8 ? "invalid" : "idle");
+ return;
+ }
+ state.timer = setTimeout(() => run(digits), debounceMs);
+ }
+
+ announce("idle");
+ document.addEventListener("input", handle, true);
+ document.addEventListener("change", handle, true);
+
+ return {
+ detach() {
+ state.detached = true;
+ state.seq += 1;
+ clearTimeout(state.timer);
+ if (state.controller) {
+ state.controller.abort();
+ state.controller = null;
+ }
+ document.removeEventListener("input", handle, true);
+ document.removeEventListener("change", handle, true);
+ const anchor = source();
+ if (anchor) {
+ anchor.removeAttribute("aria-busy");
+ anchor.removeAttribute("aria-invalid");
+ }
+ if (state.region && state.describedBy && anchor) {
+ const remaining = (anchor.getAttribute("aria-describedby") ?? "")
+ .split(/\s+/)
+ .filter((id) => id && id !== state.region.id)
+ .join(" ");
+ if (remaining) {
+ anchor.setAttribute("aria-describedby", remaining);
+ } else {
+ anchor.removeAttribute("aria-describedby");
+ }
+ }
+ if (state.region && !options.status) {
+ state.region.remove();
+ }
+ state.region = null;
+ },
+
+ lookup(cep) {
+ clearTimeout(state.timer);
+ const anchor = source();
+ const value = cep ?? (anchor ? anchor.value : "");
+ let digits;
+ try {
+ digits = normalizeCep(value);
+ } catch (error) {
+ announce("invalid");
+ if (options.onError) {
+ options.onError(error, { cep: String(value) });
+ }
+ return Promise.resolve(null);
+ }
+ return run(digits);
+ },
+
+ element: source,
+ };
+}
+
+export default { attach, lookup, normalizeCep, clearCache, CepisAutofillError, MESSAGES, VERSION };
diff --git a/test/browser.test.js b/test/browser.test.js
new file mode 100644
index 0000000..634df0d
--- /dev/null
+++ b/test/browser.test.js
@@ -0,0 +1,336 @@
+import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
+import { chromium } from "playwright-core";
+
+import { startApi, startPage } from "./server.js";
+
+function chromiumPath() {
+ if (process.env.CEPIS_CHROMIUM) {
+ return process.env.CEPIS_CHROMIUM;
+ }
+ const root = `${process.env.HOME}/.cache/ms-playwright`;
+ const found = [
+ ...new Bun.Glob("chromium-*/chrome-linux64/chrome").scanSync({ cwd: root, absolute: true }),
+ ].sort();
+ return found.at(-1);
+}
+
+let browser;
+let context;
+let page;
+let api;
+let pages;
+
+beforeAll(async () => {
+ api = startApi();
+ pages = await startPage();
+ browser = await chromium.launch({ headless: true, executablePath: chromiumPath() });
+ context = await browser.newContext();
+});
+
+afterAll(async () => {
+ await context?.close();
+ await browser?.close();
+ api?.stop();
+ pages?.stop();
+});
+
+afterEach(async () => {
+ await page?.close();
+ page = undefined;
+ api.reset();
+});
+
+async function open(options = {}) {
+ page = await context.newPage();
+ await page.goto(pages.url);
+ await page.evaluate((given) => window.__attach(given), { baseUrl: api.url, ...options });
+ return page;
+}
+
+function typeCep(text) {
+ return page.locator("#cep").pressSequentially(text, { delay: 15 });
+}
+
+async function clearCep() {
+ await page.locator("#cep").press("Control+a");
+ await page.locator("#cep").press("Backspace");
+}
+
+function value(selector) {
+ return page.locator(selector).inputValue();
+}
+
+function statusText() {
+ return page.locator(".cepis-autofill-status").textContent();
+}
+
+function events() {
+ return page.evaluate(() => window.__events);
+}
+
+async function waitForRequests(count, timeout = 4000) {
+ const deadline = Date.now() + timeout;
+ while (Date.now() < deadline) {
+ if (api.state.requests.length >= count) {
+ return;
+ }
+ await Bun.sleep(20);
+ }
+ throw new Error(`expected ${count} request(s), saw ${api.state.requests.length}`);
+}
+
+describe("filling", () => {
+ test("a typed CEP fills every mapped field", async () => {
+ await open();
+ await typeCep("01001000");
+
+ await page.waitForFunction(() => document.querySelector("#rua").value !== "");
+
+ expect(await value("#rua")).toBe("Praça da Sé");
+ expect(await value("#bairro")).toBe("Sé");
+ expect(await value("#cidade")).toBe("São Paulo");
+ expect(await value("#uf")).toBe("SP");
+ expect(await value("#numero")).toBe("");
+ expect(await events()).toContain("result:01001000");
+ });
+
+ test("the 01001-000 mask is accepted", async () => {
+ await open();
+ await typeCep("01001-000");
+
+ await page.waitForFunction(() => document.querySelector("#cidade").value !== "");
+
+ expect(await value("#cidade")).toBe("São Paulo");
+ expect(api.state.requests).toEqual(["01001000"]);
+ });
+
+ test("nothing is requested before the eighth digit", async () => {
+ await open();
+ await typeCep("0100100");
+ await Bun.sleep(300);
+
+ expect(api.state.requests).toEqual([]);
+ expect(await statusText()).toBe("");
+ });
+
+ test("focusNumero moves focus to the house number", async () => {
+ await open({ focusNumero: true });
+ await typeCep("01001000");
+
+ await page.waitForFunction(() => document.activeElement.id === "numero");
+ expect(await page.evaluate(() => document.activeElement.id)).toBe("numero");
+ });
+});
+
+describe("requests", () => {
+ test("debounce coalesces a burst of keystrokes into one request", async () => {
+ await open({ debounceMs: 200 });
+ await page.locator("#cep").pressSequentially("01001000", { delay: 5 });
+
+ await page.waitForFunction(() => document.querySelector("#rua").value !== "");
+ await Bun.sleep(300);
+
+ expect(api.state.requests).toEqual(["01001000"]);
+ }, 10000);
+
+ test("a stale response never overwrites a newer one", async () => {
+ api.state.delays["01001000"] = 600;
+ await open();
+
+ await typeCep("01001000");
+ await waitForRequests(1);
+ expect(api.state.requests).toEqual(["01001000"]);
+
+ await page.locator("#cep").press("Control+a");
+ await typeCep("20040020");
+
+ await page.waitForFunction(() => document.querySelector("#cidade").value === "Rio de Janeiro");
+ await Bun.sleep(800);
+
+ expect(await value("#rua")).toBe("Avenida Rio Branco");
+ expect(await value("#cidade")).toBe("Rio de Janeiro");
+ expect(await value("#uf")).toBe("RJ");
+ expect(await events()).toEqual(expect.arrayContaining(["result:20040020"]));
+ expect(await events()).not.toContain("result:01001000");
+ expect(api.state.aborted).toBeGreaterThan(0);
+ }, 15000);
+
+ test("a CEP replaced in one shot does not lose the race either", async () => {
+ api.state.delays["01001000"] = 600;
+ await open();
+
+ await typeCep("01001000");
+ await waitForRequests(1);
+
+ await page.locator("#cep").selectText();
+ await page.keyboard.insertText("20040020");
+
+ await page.waitForFunction(() => document.querySelector("#cidade").value === "Rio de Janeiro");
+ await Bun.sleep(800);
+
+ expect(await value("#rua")).toBe("Avenida Rio Branco");
+ expect(await value("#cidade")).toBe("Rio de Janeiro");
+ expect(await events()).not.toContain("result:01001000");
+ expect(await statusText()).toBe("Endereço encontrado.");
+ }, 15000);
+
+ test("the same CEP is only requested once", async () => {
+ await open();
+ await typeCep("01001000");
+ await page.waitForFunction(() => document.querySelector("#rua").value !== "");
+
+ await clearCep();
+ await typeCep("01001000");
+ await Bun.sleep(400);
+
+ expect(await value("#cidade")).toBe("São Paulo");
+ expect(api.state.requests).toEqual(["01001000"]);
+ }, 10000);
+
+ test("detach() stops every further request", async () => {
+ await open();
+ await page.evaluate(() => window.__handle.detach());
+
+ await typeCep("01001000");
+ await Bun.sleep(400);
+
+ expect(api.state.requests).toEqual([]);
+ expect(await value("#rua")).toBe("");
+ expect(await page.locator(".cepis-autofill-status").count()).toBe(0);
+ });
+});
+
+describe("errors", () => {
+ test("a CEP outside the database says so", async () => {
+ await open();
+ await typeCep("99999999");
+
+ await page.waitForFunction(() =>
+ document.querySelector(".cepis-autofill-status").textContent.includes("não encontrado"),
+ );
+
+ expect(await statusText()).toContain("CEP não encontrado");
+ expect(await value("#rua")).toBe("");
+ expect(await events()).toContain("error:not_found");
+ });
+
+ test("an unreachable API says so", async () => {
+ const dead = startApi();
+ const url = dead.url;
+ dead.stop();
+
+ await open({ baseUrl: url });
+ await typeCep("01001000");
+
+ await page.waitForFunction(() =>
+ document.querySelector(".cepis-autofill-status").textContent.includes("conexão"),
+ );
+
+ expect(await statusText()).toContain("Não foi possível consultar o CEP");
+ expect(await events()).toContain("error:network");
+ }, 10000);
+
+ test("a 500 and a 429 get their own message", async () => {
+ await open();
+ await typeCep("88888888");
+ await page.waitForFunction(() =>
+ document.querySelector(".cepis-autofill-status").textContent.includes("indisponível"),
+ );
+ expect(await statusText()).toContain("O serviço de CEP está indisponível");
+
+ await clearCep();
+ await typeCep("77777777");
+ await page.waitForFunction(() =>
+ document.querySelector(".cepis-autofill-status").textContent.includes("Muitas consultas"),
+ );
+ expect(await events()).toContain("error:rate_limited");
+ }, 10000);
+
+ test("more than eight digits is rejected without a request", async () => {
+ await open();
+ await typeCep("010010009");
+ await Bun.sleep(300);
+
+ expect(api.state.requests).toEqual([]);
+ expect(await statusText()).toContain("CEP inválido");
+ });
+});
+
+describe("what the shopper typed", () => {
+ test("overwrite: false leaves a filled field alone", async () => {
+ await open({ overwrite: false });
+ await page.locator("#rua").pressSequentially("Rua que eu mesmo digitei", { delay: 5 });
+ await typeCep("01001000");
+
+ await page.waitForFunction(() => document.querySelector("#bairro").value !== "");
+
+ expect(await value("#rua")).toBe("Rua que eu mesmo digitei");
+ expect(await value("#bairro")).toBe("Sé");
+ }, 10000);
+
+ test("by default a manual correction survives the next CEP", async () => {
+ await open();
+ await typeCep("01001000");
+ await page.waitForFunction(() => document.querySelector("#rua").value !== "");
+
+ await page.locator("#rua").press("Control+a");
+ await page.locator("#rua").pressSequentially("Praça da Sé, fundos", { delay: 5 });
+
+ await page.locator("#cep").press("Control+a");
+ await typeCep("20040020");
+ await page.waitForFunction(() => document.querySelector("#bairro").value === "Centro");
+
+ expect(await value("#rua")).toBe("Praça da Sé, fundos");
+ expect(await value("#cidade")).toBe("Rio de Janeiro");
+ }, 15000);
+
+ test("overwrite: true replaces it", async () => {
+ await open({ overwrite: true });
+ await page.locator("#rua").pressSequentially("Rua qualquer", { delay: 5 });
+ await typeCep("01001000");
+
+ await page.waitForFunction(() => document.querySelector("#rua").value === "Praça da Sé");
+ expect(await value("#rua")).toBe("Praça da Sé");
+ }, 10000);
+});
+
+describe("accessibility", () => {
+ test("the status region announces each state", async () => {
+ api.state.delays["01001000"] = 400;
+ await open();
+
+ const region = page.locator(".cepis-autofill-status");
+ expect(await region.getAttribute("role")).toBe("status");
+ expect(await region.getAttribute("aria-live")).toBe("polite");
+ expect(await region.getAttribute("aria-atomic")).toBe("true");
+
+ await typeCep("01001000");
+
+ await page.waitForFunction(() =>
+ document.querySelector(".cepis-autofill-status").textContent.includes("Buscando"),
+ );
+ expect(await statusText()).toBe("Buscando endereço…");
+ expect(await page.locator("#cep").getAttribute("aria-busy")).toBe("true");
+
+ await page.waitForFunction(() =>
+ document.querySelector(".cepis-autofill-status").textContent.includes("encontrado"),
+ );
+ expect(await statusText()).toBe("Endereço encontrado.");
+ expect(await page.locator("#cep").getAttribute("aria-busy")).toBeNull();
+
+ const id = await region.getAttribute("id");
+ expect(await page.locator("#cep").getAttribute("aria-describedby")).toContain(id);
+ expect(await events()).toEqual(expect.arrayContaining(["status:loading", "status:found"]));
+ }, 10000);
+
+ test("the region reserves its space before anything is announced", async () => {
+ await open();
+ const before = await page.locator("#checkout").boundingBox();
+
+ await typeCep("01001000");
+ await page.waitForFunction(() => document.querySelector("#rua").value !== "");
+
+ const after = await page.locator("#checkout").boundingBox();
+ expect(after.height).toBe(before.height);
+ });
+});
diff --git a/test/fixture.html b/test/fixture.html
new file mode 100644
index 0000000..6d6535d
--- /dev/null
+++ b/test/fixture.html
@@ -0,0 +1,62 @@
+<!doctype html>
+<html lang="pt-BR">
+ <head>
+ <meta charset="utf-8" />
+ <title>checkout fixture</title>
+ </head>
+ <body>
+ <form id="checkout">
+ <label for="cep">CEP</label>
+ <input id="cep" name="cep" autocomplete="postal-code" />
+
+ <label for="rua">Endereço</label>
+ <input id="rua" name="rua" />
+
+ <label for="numero">Número</label>
+ <input id="numero" name="numero" />
+
+ <label for="bairro">Bairro</label>
+ <input id="bairro" name="bairro" />
+
+ <label for="cidade">Cidade</label>
+ <input id="cidade" name="cidade" />
+
+ <label for="uf">Estado</label>
+ <select id="uf" name="uf">
+ <option value="">Selecione</option>
+ <option value="RJ">Rio de Janeiro</option>
+ <option value="SP">São Paulo</option>
+ </select>
+ </form>
+
+ <script src="/cepis-autofill.js"></script>
+ <script>
+ window.__events = [];
+ window.__attach = function (options) {
+ window.__handle = window.CepisAutofill.attach(
+ Object.assign(
+ {
+ cep: "#cep",
+ logradouro: "#rua",
+ bairro: "#bairro",
+ localidade: "#cidade",
+ uf: "#uf",
+ numero: "#numero",
+ debounceMs: 50,
+ onResult: function (address) {
+ window.__events.push("result:" + address.cep);
+ },
+ onError: function (error) {
+ window.__events.push("error:" + error.code);
+ },
+ onStatus: function (state) {
+ window.__events.push("status:" + state);
+ },
+ },
+ options,
+ ),
+ );
+ };
+ </script>
+ </body>
+</html>
diff --git a/test/server.js b/test/server.js
new file mode 100644
index 0000000..720f4a0
--- /dev/null
+++ b/test/server.js
@@ -0,0 +1,171 @@
+/**
+ * A fake cep.is, stood up with `Bun.serve` so the browser tests never touch the
+ * network. Same shapes and status codes as the real API, plus the counters the
+ * tests assert on.
+ *
+ * @module
+ */
+
+const ADDRESSES = {
+ "01001000": {
+ cep: "01001000",
+ logradouro: "Praça da Sé",
+ complemento: "lado ímpar",
+ unidade: "",
+ bairro: "Sé",
+ localidade: "São Paulo",
+ uf: "SP",
+ estado: "São Paulo",
+ regiao: "Sudeste",
+ ibge: "3550308",
+ gia: "1004",
+ ddd: "11",
+ siafi: "7107",
+ geo: { lat: -23.567202, lon: -46.649659, raio_m: 1084, precisao: "logradouro" },
+ },
+ 20040020: {
+ cep: "20040020",
+ logradouro: "Avenida Rio Branco",
+ complemento: "até 0999 - lado ímpar",
+ unidade: "",
+ bairro: "Centro",
+ localidade: "Rio de Janeiro",
+ uf: "RJ",
+ estado: "Rio de Janeiro",
+ regiao: "Sudeste",
+ ibge: "3304557",
+ gia: "",
+ ddd: "21",
+ siafi: "6001",
+ },
+};
+
+const CORS = {
+ "Access-Control-Allow-Origin": "*",
+ "Content-Type": "application/json; charset=utf-8",
+};
+
+/**
+ * Start the fake API on an ephemeral port.
+ *
+ * The returned `state` is live: set `state.delays[cep]` to hold a response
+ * back, or `state.force` to `{status}` to make every route fail. `requests`
+ * lists every CEP asked for, and `aborted` counts the requests the browser
+ * dropped mid-flight.
+ *
+ * @returns {{url: string, state: {requests: string[], aborted: number, delays: Record<string, number>, force: {status: number}|null}, reset: () => void, stop: () => void}}
+ */
+export function startApi() {
+ const state = { requests: [], aborted: 0, delays: {}, force: null };
+
+ const server = Bun.serve({
+ port: 0,
+ hostname: "127.0.0.1",
+ async fetch(request) {
+ const path = new URL(request.url).pathname.slice(1);
+ state.requests.push(path);
+ request.signal.addEventListener("abort", () => {
+ state.aborted += 1;
+ });
+
+ const delay = state.delays[path] ?? 0;
+ if (delay > 0) {
+ await Bun.sleep(delay);
+ }
+ if (request.signal.aborted) {
+ return new Response(null, { status: 499 });
+ }
+
+ if (state.force) {
+ return new Response(JSON.stringify({ erro: "interno", mensagem: "falha interna" }), {
+ status: state.force.status,
+ headers: {
+ ...CORS,
+ ...(state.force.status === 429 ? { "Retry-After": "3" } : {}),
+ },
+ });
+ }
+ if (!/^\d{8}$/.test(path)) {
+ return new Response(
+ JSON.stringify({ erro: "cep_invalido", mensagem: "CEP deve ter 8 dígitos" }),
+ { status: 400, headers: CORS },
+ );
+ }
+ if (path === "88888888") {
+ return new Response(JSON.stringify({ erro: "interno", mensagem: "falha interna" }), {
+ status: 500,
+ headers: CORS,
+ });
+ }
+ if (path === "77777777") {
+ return new Response(JSON.stringify({ erro: "limite", mensagem: "muitas consultas" }), {
+ status: 429,
+ headers: { ...CORS, "Retry-After": "3" },
+ });
+ }
+ const address = ADDRESSES[path];
+ if (!address) {
+ return new Response(
+ JSON.stringify({ erro: "cep_nao_encontrado", mensagem: "CEP não existe na base do DNE" }),
+ { status: 404, headers: CORS },
+ );
+ }
+ return new Response(JSON.stringify(address), { status: 200, headers: CORS });
+ },
+ });
+
+ return {
+ url: `http://127.0.0.1:${server.port}`,
+ state,
+ reset() {
+ state.requests.length = 0;
+ state.aborted = 0;
+ state.delays = {};
+ state.force = null;
+ },
+ stop() {
+ server.stop(true);
+ },
+ };
+}
+
+/**
+ * Serve the checkout fixture page together with a freshly built `<script>`
+ * bundle of the widget, so the tests exercise the file consumers actually
+ * drop into a page.
+ *
+ * @returns {Promise<{url: string, stop: () => void}>}
+ */
+export async function startPage() {
+ const build = await Bun.build({
+ entrypoints: [new URL("../src/global.js", import.meta.url).pathname],
+ target: "browser",
+ format: "iife",
+ });
+ if (!build.success) {
+ throw new AggregateError(build.logs, "could not build the widget");
+ }
+ const bundle = await build.outputs[0].text();
+ const fixture = await Bun.file(new URL("./fixture.html", import.meta.url).pathname).text();
+
+ const server = Bun.serve({
+ port: 0,
+ hostname: "127.0.0.1",
+ fetch(request) {
+ const path = new URL(request.url).pathname;
+ if (path === "/cepis-autofill.js") {
+ return new Response(bundle, {
+ headers: { "Content-Type": "text/javascript; charset=utf-8" },
+ });
+ }
+ return new Response(fixture, { headers: { "Content-Type": "text/html; charset=utf-8" } });
+ },
+ });
+
+ return {
+ url: `http://127.0.0.1:${server.port}/`,
+ stop() {
+ server.stop(true);
+ },
+ };
+}