diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..05b47ca --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,76 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + # Lets CI be run by hand on any branch, from the Actions tab. + workflow_dispatch: + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + ci: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + # package.json has no packageManager field yet, so pnpm's version is pinned + # here. Once the field lands, drop the version input — pnpm/action-setup + # reads the version out of package.json on its own. + - uses: pnpm/action-setup@v4 + with: + version: 10.11.0 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm lint + + - name: Typecheck + run: pnpm check + + # Everything below belongs before the unit tests: the `client` project in + # vite.config.ts runs them in Chromium, through vitest's browser mode. + - name: Resolve Playwright version + id: playwright + run: | + version=$(node -p "require('@playwright/test/package.json').version") + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ steps.playwright.outputs.version }} + + # The browser binaries are cached, the OS-level libraries they need are not, + # so a cache hit still has to run install-deps. + - name: Install Chromium + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: pnpm exec playwright install --with-deps chromium + + - name: Install Chromium system dependencies + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: pnpm exec playwright install-deps chromium + + - name: Unit tests + run: pnpm test:unit -- --run + + # No `pnpm test:e2e` step yet: the repository has no *.e2e.ts files, and + # playwright test exits 1 with "No tests found". Bring the step back with the + # first real e2e test (task 16, 16-test-coverage.md) — the script installs + # chromium itself and starts the preview server on port 4173. + + # vite build + svelte-package + publint. + - name: Build package + run: pnpm build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c3cbeef --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,104 @@ +# Publishing to npm is manual ONLY, through the Run workflow button. +# No push, tag or release triggers: publishing needs external sign-off, so +# nothing here may publish on its own. +# +# Before the first real run (dry_run = false): +# 1. add an NPM_TOKEN secret under Settings → Secrets and variables → Actions +# (an automation token with publish rights); +# 2. make sure permission to publish has been given. +# +# Every release starts with a version bump in package.json and an entry in +# CHANGELOG.md: npm versions are immutable, a number cannot be published twice. +# The "Verify version is not published yet" step checks that before the build — +# including on a dry run, where `npm publish --dry-run` never asks the registry. +# +# The Run workflow button only appears once this file is on the main branch. + +name: Release + +on: + workflow_dispatch: + inputs: + dry_run: + description: 'Rehearse without publishing: build the package and print the tarball contents' + type: boolean + default: true + +jobs: + release: + runs-on: ubuntu-latest + + permissions: + # npm needs id-token to generate provenance. + id-token: write + contents: read + + steps: + - uses: actions/checkout@v4 + + # pnpm's version is pinned — see the comment in ci.yml. + - uses: pnpm/action-setup@v4 + with: + version: 10.11.0 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + registry-url: 'https://registry.npmjs.org' + + - run: pnpm install --frozen-lockfile + + # Ahead of the build and the tests, so that a taken version number fails in + # seconds instead of after several minutes of work. + - name: Verify version is not published yet + run: | + name=$(node -p "require('./package.json').name") + version=$(node -p "require('./package.json').version") + echo "Preparing to publish $name@$version" + + set +e + output=$(npm view "$name@$version" version 2>&1) + status=$? + set -e + + if [ $status -eq 0 ]; then + echo "::error::$name@$version is already published — bump the version in package.json" + exit 1 + fi + if ! echo "$output" | grep -q "E404"; then + echo "::error::Could not check the version against the registry" + echo "$output" + exit 1 + fi + echo "Version $version is free" + + # The same checks ci.yml runs: workflow_dispatch can be launched from any + # branch, so a green CI on main proves nothing about what is being published. + - name: Lint + run: pnpm lint + + - name: Typecheck + run: pnpm check + + # The `client` project in vite.config.ts runs its tests in Chromium. + - name: Install Chromium for vitest browser mode + run: pnpm exec playwright install --with-deps chromium + + - name: Unit tests + run: pnpm test:unit -- --run + + - name: Build package + run: pnpm build + + # A dry run never exercises provenance — the attestation is only created on a + # real upload — so the flag belongs on the publishing step alone. + - name: Publish (dry run) + if: inputs.dry_run + run: npm publish --dry-run --access public + + - name: Publish to npm + if: ${{ !inputs.dry_run }} + run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/README.md b/README.md index 58b3de6..86e377a 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ from a cold start in ~90ms, in ~45 KB min. `svelte-uplot` is the ~3 KB layer that makes it feel like a Svelte component: declarative, reactive, autosizing, SSR-safe — with **zero** abstraction over uPlot's own API. +[![CI](https://github.com/twister55/svelte-uplot/actions/workflows/ci.yml/badge.svg)](https://github.com/twister55/svelte-uplot/actions/workflows/ci.yml) [![npm](https://img.shields.io/npm/v/svelte-uplot?logo=npm&color=cb3837)](https://www.npmjs.com/package/svelte-uplot) [![gzip](https://img.shields.io/badge/runtime-3.1_kB_gzip-brightgreen)](#why-this-one) [![Svelte 5](https://img.shields.io/badge/Svelte-5%20runes-ff3e00?logo=svelte&logoColor=white)](https://svelte.dev) diff --git a/src/lib/autosize.svelte.test.ts b/src/lib/autosize.svelte.test.ts index 2d65946..c925aef 100644 --- a/src/lib/autosize.svelte.test.ts +++ b/src/lib/autosize.svelte.test.ts @@ -13,6 +13,11 @@ function chartEl(container: HTMLElement) { return container.querySelector('[data-testid="chart"]')!; } +/** uPlot's own legend — the part of the overhead that rewraps with the width */ +function legendHeight(u: uPlot) { + return u.root.querySelector('.u-legend')!.offsetHeight; +} + function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -162,14 +167,23 @@ describe('autosize', () => { await laidOut(charts[0]); await expect.poll(() => charts[0].root.offsetHeight, { timeout: 2000 }).toBe(240); const oneRow = charts[0].height; + const oneRowLegend = legendHeight(charts[0]); chartEl(container).style.width = '200px'; await expect.poll(() => charts[0].width, { timeout: 2000 }).toBe(200); - // the legend now needs a row it did not need before, and the whole chart still - // has to fit the 240px it was given - await expect.poll(() => charts[0].root.offsetHeight, { timeout: 2000 }).toBe(240); - expect(charts[0].height).toBeLessThan(oneRow); + // The legend has rewrapped by now — the width is what it wraps to, and the + // convergence passes that follow only move the height. How many rows it takes + // at 200px is a font metric, and a platform whose glyphs are a little wider + // needs more of them, so what the plot owes the legend is measured, not assumed. + const grew = legendHeight(charts[0]) - oneRowLegend; + expect(grew).toBeGreaterThan(0); + + // The plot gives up exactly that much, instead of keeping the height it was + // sized to before the rewrap. Clamped at zero: a legend taller than the box + // leaves the plotting area nothing, and the root then outgrows the element — + // the degenerate case the next test starts from. + await expect.poll(() => charts[0].height, { timeout: 2000 }).toBe(Math.max(0, oneRow - grew)); }); it('takes the plot height back once the legend can unwrap again', async () => {