Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 12 additions & 10 deletions .env.local.example
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
# Sentry Configuration
# Get your DSN from: https://ad-capital.sentry.io/settings/projects/json-formatter/keys/
NEXT_PUBLIC_SENTRY_DSN=your_sentry_dsn_here
SENTRY_DSN=your_sentry_dsn_here
# Copy this file to .env.local and set only what you need.
# The app builds and runs with none of these set.
#
# All JSON processing happens in the browser; your content is never sent
# anywhere, and no analytics setting changes that.

# Sentry Auth Token for source map uploads (optional)
# Generate at: https://ad-capital.sentry.io/settings/auth-tokens/
SENTRY_AUTH_TOKEN=your_auth_token_here
# Google Analytics 4 measurement ID, e.g. G-XXXXXXXXXX.
# Optional. Leave empty and no Google script is loaded and no consent banner is
# shown. When set, GA runs behind Consent Mode: analytics_storage starts denied,
# so no cookies are written until the visitor accepts in the banner.
NEXT_PUBLIC_GA_MEASUREMENT_ID=

# Sentry Organization and Project
SENTRY_ORG=ad-capital
SENTRY_PROJECT=json-formatter
# Vercel Web Analytics needs no variable — it is enabled per-project from the
# Vercel dashboard, sets no cookies, and runs regardless of the consent choice.
93 changes: 93 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
name: CI

on:
push:
branches: [main]
pull_request:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
check:
name: Biome, ESLint, types & unit tests
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v7

# No `version:` input on purpose — action-setup then reads the
# `packageManager` pin from package.json, so the pin stays authoritative.
# Must run before setup-node, which needs pnpm on PATH for `cache: pnpm`.
- name: Set up pnpm
uses: pnpm/action-setup@v6

- name: Set up Node
uses: actions/setup-node@v7
with:
node-version: 22.x
cache: 'pnpm'

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Run checks
run: pnpm check

e2e:
name: Playwright smoke tests
runs-on: ubuntu-latest
# The Playwright webServer allows 300s just for the production build, on top
# of install and the browser download.
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v7

- name: Set up pnpm
uses: pnpm/action-setup@v6

- name: Set up Node
uses: actions/setup-node@v7
with:
node-version: 22.x
cache: 'pnpm'

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Cache Playwright browsers
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-playwright-

# Safe with or without a cache hit: an already-present browser is not
# re-downloaded, and the OS-level deps are installed either way.
- name: Install Chromium
run: pnpm exec playwright install --with-deps chromium

# No build or server step here on purpose: playwright.config.ts owns both
# via its `webServer`, which builds into .next-e2e (NEXT_DIST_DIR).
# Its CI reporter is `github`, which emits annotations but writes no report
# directory, so `html` is added on the CLI to leave something uploadable.
- name: Run Playwright tests
run: pnpm test:e2e --reporter=github,html

- name: Upload Playwright report
if: ${{ failure() }}
uses: actions/upload-artifact@v7
with:
name: playwright-report
path: |
playwright-report/
test-results/
if-no-files-found: ignore
retention-days: 7
2 changes: 0 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,6 @@ pip-delete-this-directory.txt
# Temporary files
tmp/
temp/
# Self-hosted Monaco runtime (copied from node_modules at build time)
/public/monaco/

# Playwright
/test-results/
Expand Down
85 changes: 0 additions & 85 deletions LANGUAGE_TESTING.md

This file was deleted.

6 changes: 2 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ JSON as an interactive node-link graph — click nodes to expand/collapse, scrol
```bash
pnpm install
```
(This also copies self-hosted editor assets and installs the Lefthook git hooks.)
(This also installs the Lefthook git hooks.)

3. Run the development server (Turbopack):
```bash
Expand Down Expand Up @@ -138,9 +138,7 @@ Bypass once with `git commit --no-verify` if needed.
json-viewer/
├── 📁 docs/
│ └── 📁 screenshots/ # README screenshots
├── 📁 public/ # Static assets (manifest, robots.txt, images, self-hosted editor assets)
├── 📁 scripts/
│ └── 📄 copy-monaco.mjs # Copies editor assets into public/ (no CDN egress)
├── 📁 public/ # Static assets (manifest, robots.txt, images)
├── 📁 src/
│ ├── 📁 app/ # Next.js App Router
│ │ ├── 📁 (app)/ # Workbench views — one URL per view
Expand Down
4 changes: 2 additions & 2 deletions biome.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.7/schema.json",
"$schema": "https://biomejs.dev/schemas/2.5.10/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": true,
"includes": ["**", "!public", "!.next", "!out", "!next-env.d.ts", "!scripts"]
"includes": ["**", "!public", "!.next", "!out", "!next-env.d.ts"]
},
"formatter": {
"enabled": true,
Expand Down
93 changes: 93 additions & 0 deletions e2e/consent.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { expect, test } from '@playwright/test';

/**
* The consent gate is only meaningful if GA genuinely writes nothing before the
* visitor accepts. That is invisible from the UI, so assert on the cookie jar
* and on the Consent Mode state the tag actually sees.
*
* These run without NEXT_PUBLIC_GA_MEASUREMENT_ID set, which is the state of the
* e2e build — so they pin the "no ID configured" contract: no Google request, no
* cookie, no banner. The consent-state assertions are written to hold either
* way, so they keep their value if the suite ever runs with an ID.
*/

const GA_HOSTS = /googletagmanager\.com|google-analytics\.com|analytics\.google\.com/;

test('sets no Google cookie before any choice is made', async ({ page }) => {
await page.goto('/', { waitUntil: 'networkidle' });
const cookies = await page.context().cookies();
expect(cookies.filter((c) => c.name.startsWith('_ga'))).toEqual([]);
});

test('makes no Google request when no measurement ID is configured', async ({ page }) => {
const googleRequests: string[] = [];
page.on('request', (r) => {
if (GA_HOSTS.test(r.url())) googleRequests.push(r.url());
});
await page.goto('/', { waitUntil: 'networkidle' });
expect(googleRequests, googleRequests.join('\n')).toEqual([]);
});

test('the JSON never leaves the browser', async ({ page }) => {
const outbound: string[] = [];
page.on('request', (r) => {
const url = new URL(r.url());
if (url.host !== new URL(page.url()).host) outbound.push(r.url());
});

await page.goto('/', { waitUntil: 'networkidle' });
const secret = 'CANARY_b7f3e1a9_do_not_transmit';
await page.locator('.cm-content').first().fill(`{"secret": "${secret}"}`);
await page.waitForTimeout(1500);

// Nothing carrying the document may have gone anywhere, to any host.
for (const url of outbound) {
expect(url, `request leaked document content: ${url}`).not.toContain(secret);
}
const bodies = outbound.filter((u) => GA_HOSTS.test(u));
expect(bodies, 'no analytics request should fire while typing').toEqual([]);
});

/**
* The gate itself, exercised only when a measurement ID is baked into the build.
* NEXT_PUBLIC_* is inlined at build time, so this cannot be toggled per-test —
* run `NEXT_PUBLIC_GA_MEASUREMENT_ID=G-TEST123 pnpm test:e2e` to cover it.
*
* Verified manually against a G-VERIFY123 build: consent defaults to denied, no
* _ga cookie exists before the choice, and both _ga and _ga_<id> appear only
* after Accept.
*/
const gaConfigured = Boolean(process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID);

test.describe('with a measurement ID configured', () => {
test.skip(!gaConfigured, 'needs NEXT_PUBLIC_GA_MEASUREMENT_ID at build time');

test('defaults consent to denied and writes no cookie until accepted', async ({ page }) => {
await page.goto('/', { waitUntil: 'networkidle' });

const defaults = await page.evaluate(() =>
((window as { dataLayer?: unknown[] }).dataLayer ?? [])
.map((a) => Array.from(a as ArrayLike<unknown>))
.filter((a) => a[0] === 'consent' && a[1] === 'default')
);
expect(defaults).toHaveLength(1);
expect(defaults[0][2]).toMatchObject({ analytics_storage: 'denied' });

const before = (await page.context().cookies()).filter((c) => c.name.startsWith('_ga'));
expect(before, 'no analytics cookie may exist before consent').toEqual([]);

await page.getByRole('button', { name: /accept/i }).click();
await page.waitForTimeout(1000);

const after = (await page.context().cookies()).filter((c) => c.name.startsWith('_ga'));
expect(after.length, 'accepting should allow the analytics cookie').toBeGreaterThan(0);
});

test('declining keeps it cookieless', async ({ page }) => {
await page.goto('/', { waitUntil: 'networkidle' });
await page.getByRole('button', { name: /decline/i }).click();
await page.waitForTimeout(1000);
const cookies = (await page.context().cookies()).filter((c) => c.name.startsWith('_ga'));
expect(cookies, 'declining must not write an analytics cookie').toEqual([]);
});
});
Loading
Loading