diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 15:01:02 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 15:01:02 -0300 |
| commit | 36486007ccada8c31d30e8f528d21233cabce32c (patch) | |
| tree | d14a108845e4c984ec922ba98d153c8d68da6ebb | |
| download | autofill-ceafecfbd5171c08d15dc0ca123d6d9a2582000d.tar.gz | |
Initial importv0.1.0
| -rw-r--r-- | .gitignore | 5 | ||||
| -rw-r--r-- | .prettierignore | 3 | ||||
| -rw-r--r-- | .prettierrc.json | 3 | ||||
| -rw-r--r-- | LICENSE | 15 | ||||
| -rw-r--r-- | Makefile | 39 | ||||
| -rw-r--r-- | README.md | 232 | ||||
| -rw-r--r-- | demo.html | 160 | ||||
| -rw-r--r-- | package.json | 49 | ||||
| -rw-r--r-- | scripts/size.js | 15 | ||||
| -rw-r--r-- | src/global.js | 32 | ||||
| -rw-r--r-- | src/index.js | 614 | ||||
| -rw-r--r-- | test/browser.test.js | 336 | ||||
| -rw-r--r-- | test/fixture.html | 62 | ||||
| -rw-r--r-- | test/server.js | 171 |
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 +} @@ -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); + }, + }; +} |