Skip to content

Testing your logging ​

Frogger ships first-party test helpers so you can assert "my app logged X" without standing up a real file or HTTP sink. Everything is built on the memoryTransport(), which captures every log into an in-memory array a test can read back.

Two subpath entry points package the helpers:

  • nuxt-frogger/testing — Vitest helpers (capture, filter, expect matcher).
  • nuxt-frogger/playwright — Playwright fixtures (intercept client→server batches, tripwire on Frogger's own errors).

The tooling packages (vitest, @nuxt/test-utils, @playwright/test) are optional peer dependencies — you only need the ones you actually use.

The memory transport ​

Add a memoryTransport({ name }) to your transports. The name is the key you'll read the captures back with:

ts
// frogger.config.ts (or your test-only Nuxt config)
import { defineFroggerOptions, memoryTransport } from '#frogger/config'

export default defineFroggerOptions({
    transports: [memoryTransport({ name: 'test' })],
})

Under the hood a named memory transport shares its array through a process-global registry, so the transport Frogger builds from config and the getCapturedLogs({ name }) helper you call in a test see the exact same captures.

Server-only, for now

Like fileTransport(), memoryTransport() is server-side only in this release — it captures logs that reach the Nitro server queue (including client logs beamed back to /api/_frogger/logs). client: true is ignored with a warning. To assert on client→server batches directly in an e2e test, use the Playwright capture fixture below.

Vitest: unit-level capture ​

For a plain unit test you can drive a MemoryTransport directly — no Nuxt runtime required:

ts
import { describe, it, expect } from 'vitest'
import { MemoryTransport, filterLogs } from 'nuxt-frogger/testing'

it('captures what was logged', () => {
    const memory = new MemoryTransport()

    memory.log({
        time: Date.now(), lvl: 1, type: 'warn', msg: 'disk almost full',
        ctx: {}, env: 'server', trace: { traceId: 't', spanId: 's' },
    })

    expect(memory.getLogs()).toHaveLength(1)
    expect(filterLogs(memory.getLogs(), { level: 'warn' })).toHaveLength(1)
})

filterLogs(logs, matcher) is the shared predicate behind every helper. Every field is optional and all present fields must match:

FieldMatches
levelThe log level, as a name ('warn') or the numeric lvl
typeThe exact consola type ('info', 'success', …)
msgA substring (string) or pattern (RegExp) of msg
ctxA subset of ctx — every listed key must deep-equal the log's
traceIdThe log's trace.traceId

Vitest: Nuxt-level capture ​

To assert on logs that flow through the real server queue, run a Nuxt test (// @vitest-environment nuxt) with a memoryTransport({ name }) configured, then read the captures with getCapturedLogs:

ts
// @vitest-environment nuxt
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mockNuxtImport } from '@nuxt/test-utils/runtime'
import {
    memoryTransport,
    getCapturedLogs,
    clearCapturedLogs,
    flushFrogger,
    froggerTestRuntimeConfig,
    stubFroggerFetch,
} from 'nuxt-frogger/testing'

const { runtimeConfig } = vi.hoisted(() => ({ runtimeConfig: vi.fn() }))
mockNuxtImport('useRuntimeConfig', () => runtimeConfig)

beforeEach(() => {
    clearCapturedLogs('test')
    vi.stubGlobal('$fetch', stubFroggerFetch())
    runtimeConfig.mockReturnValue(froggerTestRuntimeConfig({
        frogger: { transports: [memoryTransport({ name: 'test' })] },
    }))
})

it('logs a warning when the disk is full', async () => {
    // ...drive the code that logs...

    await flushFrogger()

    expect(getCapturedLogs({ name: 'test', level: 'warn', msg: /disk/ })).toHaveLength(1)
})

mockNuxtImport must stay at the top level

mockNuxtImport('useRuntimeConfig', …) is a compile-time macro — it has to sit at the top level of the test file. froggerTestRuntimeConfig() only supplies the value the mock returns (with test defaults: batching off, scrub and websocket disabled); it does not — and can't — call the macro for you.

Determinism: batch: false + flushFrogger() ​

By default Frogger batches on the server, sorting logs in a time window before flushing them downstream. In a test that fights fake timers. Two knobs keep captures deterministic:

  • batch: false (the default in froggerTestRuntimeConfig()) — logs reach the memory transport synchronously on enqueue, so there's no time window to wait out.
  • await flushFrogger() — force-drains the server queue before you assert. Harmless with batch: false; necessary if you deliberately test with batching on.

The toHaveLogged matcher ​

For readable assertions, opt into the toHaveLogged Vitest matcher. It takes the same matcher shape as filterLogs and passes when at least one captured log matches:

ts
import { beforeAll, expect, it } from 'vitest'
import { registerFroggerMatchers, getCapturedLogs } from 'nuxt-frogger/testing'

beforeAll(async () => {
    await registerFroggerMatchers()
})

it('warns the operator to redeploy', () => {
    expect(getCapturedLogs({ name: 'test' })).toHaveLogged({ level: 'warn', msg: /redeploy/ })

    // and its negation
    expect(getCapturedLogs({ name: 'test' })).not.toHaveLogged({ level: 'error' })
})

Registration is opt-in (a function call, not a side-effecting import) so importing the testing helpers never silently mutates the global expect. Calling registerFroggerMatchers() also augments the vitest types, so toHaveLogged is fully typed.

Playwright: end-to-end ​

The Playwright entry point captures the client→server log batches your app POSTs during a real browser flow. Because client and server logs share a traceId, you can assert trace continuity across the boundary.

ts
import { test, expect } from 'nuxt-frogger/playwright'

test('logs on save', async ({ page, froggerCapture }) => {
    await page.goto('/')
    await page.getByRole('button', { name: 'Save' }).click()

    const log = await froggerCapture.expectLog({ msg: /saved/, level: 'info' })
    expect(log.trace.traceId).toBeTruthy()
})

froggerCapture intercepts POSTs to /api/_frogger/logs, collects each batch, and lets the request continue so the app still works. It exposes:

MethodReturns
getLogs(matcher?)Every captured log (flattened across batches), optionally filtered
getBatches()The raw LoggerObjectBatch bodies as received
waitForLog(matcher, opts?)Resolves once a matching log is captured, rejects on timeout
expectLog(matcher, opts?)Same as waitForLog — reads as an assertion
clear()Drops all captured batches

You can also build a capture without the fixture via useFroggerCapture(page, { endpoint? }) — handy if your ingest route is customized.

Tripwire: fail on Frogger's own errors ​

"Frogger must never be the reason my e2e fails." The opt-in failOnFroggerInternalErrors fixture fails a test if Frogger logs any internal-diagnostics line (prefixed 🐸 Frogger) to the console. Reference it in a test to arm it:

ts
import { test } from 'nuxt-frogger/playwright'

test('flow is clean of Frogger internal errors', async ({ page, failOnFroggerInternalErrors }) => {
    await page.goto('/')
    // ...drive the flow; the test fails if Frogger logs anything internally...
})

The stable prefix is exported as FROGGER_INTERNAL_PREFIX if you'd rather match it yourself.

Fixtures, not a running suite

This release ships the fixtures, docs, and example above — not a standing Playwright config or CI suite. Wire nuxt-frogger/playwright into your own playwright.config.ts as you would any fixture module.