@biffo/cli 0.232.1 → 0.234.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/_skeletons/sibling-template/.github/workflows/ci.yml +45 -0
- package/_skeletons/sibling-template/apps/frontend/e2e/fixtures.ts +44 -0
- package/_skeletons/sibling-template/apps/frontend/e2e/session.ts +90 -0
- package/_skeletons/sibling-template/apps/frontend/e2e/smoke.spec.ts +51 -0
- package/_skeletons/sibling-template/apps/frontend/e2e/static-server.mjs +122 -0
- package/_skeletons/sibling-template/apps/frontend/package.json +3 -1
- package/_skeletons/sibling-template/apps/frontend/playwright.config.ts +45 -0
- package/_skeletons/sibling-template/apps/frontend/pnpm-lock.yaml +41 -2
- package/dist/index.js +2 -1
- package/package.json +1 -1
|
@@ -103,6 +103,51 @@ jobs:
|
|
|
103
103
|
# so it cannot assume a layout. Do not edit it here; edit it upstream.
|
|
104
104
|
run: sh ../../scripts/js-dependency-audit.sh
|
|
105
105
|
|
|
106
|
+
e2e:
|
|
107
|
+
name: E2E (Playwright)
|
|
108
|
+
runs-on: ${{ vars.RUNNER_LABEL || 'ubuntu-latest' }}
|
|
109
|
+
timeout-minutes: 20
|
|
110
|
+
# `packages: read` so pnpm can install any private @tabsii-com/* package
|
|
111
|
+
# from GitHub Packages using the workflow token. Harmless if this sibling
|
|
112
|
+
# has none.
|
|
113
|
+
permissions:
|
|
114
|
+
contents: read
|
|
115
|
+
packages: read
|
|
116
|
+
# The official Playwright image, pinned to the @playwright/test version in
|
|
117
|
+
# apps/frontend/package.json. It ships Chromium and every browser system
|
|
118
|
+
# dependency, so this needs no `--with-deps`, no apt, and no root on the
|
|
119
|
+
# self-hosted runners.
|
|
120
|
+
container:
|
|
121
|
+
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
|
122
|
+
defaults:
|
|
123
|
+
run:
|
|
124
|
+
working-directory: apps/frontend
|
|
125
|
+
steps:
|
|
126
|
+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
|
127
|
+
- uses: pnpm/action-setup@v4
|
|
128
|
+
with:
|
|
129
|
+
version: 9.15.9
|
|
130
|
+
- uses: actions/setup-node@v4
|
|
131
|
+
with:
|
|
132
|
+
node-version: ${{ env.NODE_VERSION }}
|
|
133
|
+
cache: pnpm
|
|
134
|
+
cache-dependency-path: apps/frontend/pnpm-lock.yaml
|
|
135
|
+
- run: pnpm install --frozen-lockfile
|
|
136
|
+
env:
|
|
137
|
+
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
138
|
+
# Browser system deps come from the container; this only drops the
|
|
139
|
+
# Chromium binary into the resolved cache path (a no-op if present).
|
|
140
|
+
- name: Install Chromium
|
|
141
|
+
run: pnpm exec playwright install chromium
|
|
142
|
+
# `playwright test` boots its own webServer: it builds the static export
|
|
143
|
+
# and serves it base-path aware, INCLUDING
|
|
144
|
+
# `/.well-known/biffo-identity.json`. Serving that document is what makes
|
|
145
|
+
# the harness represent the deployed app -- without it the runtime pool
|
|
146
|
+
# lookup (#403) resolves null and every spec dies on a redirect to the
|
|
147
|
+
# portal, which is how tabsii-geo's CI broke for four hours (#1208).
|
|
148
|
+
- name: E2E tests
|
|
149
|
+
run: pnpm run e2e
|
|
150
|
+
|
|
106
151
|
python:
|
|
107
152
|
name: Python (lint, types, test, security)
|
|
108
153
|
runs-on: ${{ vars.RUNNER_LABEL || 'ubuntu-latest' }}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Shared between playwright.config.ts (which builds and serves the static
|
|
2
|
+
// export) and the specs.
|
|
3
|
+
//
|
|
4
|
+
// Every value here is read by BOTH sides, which is the point: the Cognito ids
|
|
5
|
+
// the harness advertises in `/.well-known/biffo-identity.json` must be the same
|
|
6
|
+
// ones `session.ts` seeds a localStorage session with, or the app resolves a
|
|
7
|
+
// pool it has no session for and silently redirects to the portal. That exact
|
|
8
|
+
// mismatch broke tabsii-geo's CI for four hours (#1208).
|
|
9
|
+
|
|
10
|
+
export const E2E_PORT = Number(process.env['E2E_PORT'] ?? 4330)
|
|
11
|
+
|
|
12
|
+
// The path CloudFront routes to this sibling, e.g. `/crm`. Read from the same
|
|
13
|
+
// env var `next.config.ts` uses, so the harness cannot disagree with the build.
|
|
14
|
+
export const BASE_PATH = process.env['NEXT_PUBLIC_BASE_PATH'] ?? ''
|
|
15
|
+
|
|
16
|
+
// Not real credentials, and never used against a real pool — they only have to
|
|
17
|
+
// be internally consistent. Shaped like the real thing so anything that parses
|
|
18
|
+
// a pool id does not choke.
|
|
19
|
+
export const COGNITO_USER_POOL_ID = 'us-east-1_E2ETESTPOOL'
|
|
20
|
+
export const COGNITO_CLIENT_ID = 'e2etestclientid0123456789'
|
|
21
|
+
|
|
22
|
+
// Injected into `next build` by the Playwright webServer.
|
|
23
|
+
//
|
|
24
|
+
// Deliberately NO `NEXT_PUBLIC_CORE_COGNITO_*`: a sibling resolves its pool at
|
|
25
|
+
// RUNTIME from the identity document (#403), exactly as the deployed app does.
|
|
26
|
+
// Baking a build-time fallback here would make the harness pass in a way the
|
|
27
|
+
// real app cannot, which is the failure this scaffold exists to prevent.
|
|
28
|
+
export const BUILD_ENV: Record<string, string> = {
|
|
29
|
+
NEXT_PUBLIC_BASE_PATH: BASE_PATH,
|
|
30
|
+
NEXT_PUBLIC_E2E: '1',
|
|
31
|
+
NEXT_PUBLIC_API_URL: '',
|
|
32
|
+
// LOAD-BEARING, and not obviously so. `src/app/page.tsx` only redirects a
|
|
33
|
+
// signed-out visitor `if (state.kind === 'signed_out' && CORE_PORTAL_URL)`.
|
|
34
|
+
// Leave this unset and the app has nowhere to send them, so it never
|
|
35
|
+
// redirects, so `smoke.spec.ts` passes whatever the auth wiring does —
|
|
36
|
+
// including with the identity document removed entirely.
|
|
37
|
+
//
|
|
38
|
+
// That is not hypothetical: the first draft of this scaffold omitted it, and
|
|
39
|
+
// the smoke test passed against a harness deliberately broken the same way
|
|
40
|
+
// tabsii-geo's was (#1208). A test that cannot fail is worse than no test,
|
|
41
|
+
// because it reports coverage. Verified after adding it: breaking the
|
|
42
|
+
// identity document makes the spec fail on the redirect.
|
|
43
|
+
NEXT_PUBLIC_CORE_PORTAL_URL: 'https://portal.example.test',
|
|
44
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Put the browser in a signed-in state without touching Cognito (crm#65 M1).
|
|
2
|
+
//
|
|
3
|
+
// `src/lib/auth.ts` reads its session through amazon-cognito-identity-js, whose
|
|
4
|
+
// storage keys are derived from the **Client ID only** — deliberately, so the
|
|
5
|
+
// portal's session carries over to this sibling on the same origin (ADR-0007;
|
|
6
|
+
// see the comment in auth.ts). `fixtures.ts`'s `COGNITO_CLIENT_ID` pins that
|
|
7
|
+
// Client ID to a fixed test value, so the key names are deterministic and can
|
|
8
|
+
// be written before first paint — and it is the SAME value
|
|
9
|
+
// `static-server.mjs` advertises in the `/.well-known/biffo-identity.json`
|
|
10
|
+
// document auth.ts now resolves the pool from at runtime (#403), so the pool
|
|
11
|
+
// this seeded session's keys are stored under is the one the app actually
|
|
12
|
+
// builds.
|
|
13
|
+
//
|
|
14
|
+
// Once the pool resolves, the library validates the cached session
|
|
15
|
+
// **locally**: `getCurrentUser()` reads `LastAuthUser`, `getSession()` reads
|
|
16
|
+
// the three token keys, and `CognitoUserSession.isValid()` compares
|
|
17
|
+
// `payload.exp` against now. Nothing is verified cryptographically, so an
|
|
18
|
+
// unsigned JWT with a future `exp` is sufficient.
|
|
19
|
+
//
|
|
20
|
+
// Deliberately NOT a second test-only route. The map harness
|
|
21
|
+
// (`src/app/e2e/map/page.tsx`) mounts a component in isolation, which is the
|
|
22
|
+
// gap #65 exists to close — these specs drive the real `/crm/` app shell.
|
|
23
|
+
|
|
24
|
+
import type { Page } from '@playwright/test'
|
|
25
|
+
import { COGNITO_CLIENT_ID } from './fixtures'
|
|
26
|
+
|
|
27
|
+
const USERNAME = 'user@e2e.test'
|
|
28
|
+
|
|
29
|
+
/** base64url, without padding — the JWT segment encoding. */
|
|
30
|
+
function b64url(value: string): string {
|
|
31
|
+
return Buffer.from(value)
|
|
32
|
+
.toString('base64')
|
|
33
|
+
.replace(/\+/g, '-')
|
|
34
|
+
.replace(/\//g, '_')
|
|
35
|
+
.replace(/=+$/, '')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A structurally valid, unsigned JWT.
|
|
40
|
+
*
|
|
41
|
+
* The signature segment is the literal `e2e` — it is never checked here, and
|
|
42
|
+
* making it obviously fake is the point: nothing in this file should look like
|
|
43
|
+
* a credential that might work somewhere real.
|
|
44
|
+
*/
|
|
45
|
+
function token(extraClaims: Record<string, unknown>): string {
|
|
46
|
+
const now = Math.floor(Date.now() / 1000)
|
|
47
|
+
const payload = {
|
|
48
|
+
sub: 'e2e-sub',
|
|
49
|
+
email: USERNAME,
|
|
50
|
+
'cognito:username': USERNAME,
|
|
51
|
+
aud: COGNITO_CLIENT_ID,
|
|
52
|
+
token_use: 'id',
|
|
53
|
+
auth_time: now,
|
|
54
|
+
iat: now,
|
|
55
|
+
exp: now + 60 * 60,
|
|
56
|
+
...extraClaims,
|
|
57
|
+
}
|
|
58
|
+
return `${b64url(JSON.stringify({ alg: 'none', typ: 'JWT' }))}.${b64url(JSON.stringify(payload))}.e2e`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Seed the Cognito session keys before any app code runs.
|
|
63
|
+
*
|
|
64
|
+
* `addInitScript` runs on every navigation in the context, ahead of the page's
|
|
65
|
+
* own scripts — which matters because `page.tsx` calls `getCurrentSession()` in
|
|
66
|
+
* its first effect, and that now resolves the pool asynchronously (a fetch to
|
|
67
|
+
* `/.well-known/biffo-identity.json`, #403) before it ever reads localStorage.
|
|
68
|
+
* Writing localStorage after `goto` would race that and land on the signed-out
|
|
69
|
+
* screen intermittently.
|
|
70
|
+
*/
|
|
71
|
+
export async function signIn(page: Page): Promise<void> {
|
|
72
|
+
const prefix = `CognitoIdentityServiceProvider.${COGNITO_CLIENT_ID}`
|
|
73
|
+
const entries: Record<string, string> = {
|
|
74
|
+
[`${prefix}.LastAuthUser`]: USERNAME,
|
|
75
|
+
[`${prefix}.${USERNAME}.idToken`]: token({ token_use: 'id' }),
|
|
76
|
+
[`${prefix}.${USERNAME}.accessToken`]: token({ token_use: 'access' }),
|
|
77
|
+
// Present but unused: `getSession` only reaches for it when the cached
|
|
78
|
+
// session has expired, which it has not.
|
|
79
|
+
[`${prefix}.${USERNAME}.refreshToken`]: 'e2e-refresh',
|
|
80
|
+
[`${prefix}.${USERNAME}.clockDrift`]: '0',
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
await page.addInitScript((items: Record<string, string>) => {
|
|
84
|
+
for (const [k, v] of Object.entries(items)) window.localStorage.setItem(k, v)
|
|
85
|
+
}, entries)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Every request the app has made to the fixture API, in order. */
|
|
89
|
+
|
|
90
|
+
/** Drop recorded requests and any units created by an earlier test. */
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { expect, test } from '@playwright/test'
|
|
2
|
+
import { BASE_PATH } from './fixtures'
|
|
3
|
+
import { signIn } from './session'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The one E2E every sibling starts with: **the app boots, and a signed-in
|
|
7
|
+
* session stays signed in.**
|
|
8
|
+
*
|
|
9
|
+
* ## Why this specific assertion
|
|
10
|
+
*
|
|
11
|
+
* It is the regression that actually happened. On 2026-08-03 `tabsii-geo`
|
|
12
|
+
* merged a change moving Cognito pool resolution to runtime (#403), its
|
|
13
|
+
* Playwright harness did not serve `/.well-known/biffo-identity.json`, so
|
|
14
|
+
* `getCurrentSession()` resolved null and the app redirected to the portal.
|
|
15
|
+
* Every spec died on *"Execution context was destroyed, most likely because of
|
|
16
|
+
* a navigation"*. `tabsii-crm` needed the identical fix for the identical
|
|
17
|
+
* reason. The branch stayed red for four hours (#1208).
|
|
18
|
+
*
|
|
19
|
+
* A sibling that renders its own base path while holding a session is a sibling
|
|
20
|
+
* whose build, base path, static export, identity document and auth wiring all
|
|
21
|
+
* agree. Almost every scaffolding mistake breaks one of those, and this catches
|
|
22
|
+
* it in about a second.
|
|
23
|
+
*
|
|
24
|
+
* ## Keep it, then add to it
|
|
25
|
+
*
|
|
26
|
+
* This is a floor, not a suite. Add specs for what your sibling actually does —
|
|
27
|
+
* `tabsii-crm` has brand, FDD and automation flows beside this. But do not
|
|
28
|
+
* delete it: it is the only test that fails loudly when the harness itself
|
|
29
|
+
* stops representing the deployed app, which is the failure mode that costs
|
|
30
|
+
* hours rather than minutes.
|
|
31
|
+
*
|
|
32
|
+
* **Make `E2E (Playwright)` a required status check on `dev`.** `tabsii-geo`
|
|
33
|
+
* had five E2E files and did not require them, so the regression above merged
|
|
34
|
+
* green — coverage that cannot block a merge is documentation.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
test('a signed-in session renders the app without redirecting to the portal', async ({ page }) => {
|
|
38
|
+
// Seed the session BEFORE the first navigation. `identity.ts` fetches the
|
|
39
|
+
// identity document and reads localStorage during hydration, so writing the
|
|
40
|
+
// session after `goto` races that and lands on the signed-out path.
|
|
41
|
+
await signIn(page)
|
|
42
|
+
|
|
43
|
+
await page.goto(`${BASE_PATH}/`)
|
|
44
|
+
|
|
45
|
+
// Still here — not bounced to the portal.
|
|
46
|
+
await expect(page).toHaveURL(new RegExp(`${BASE_PATH}/?$`))
|
|
47
|
+
|
|
48
|
+
// And something actually rendered, rather than an empty shell that merely
|
|
49
|
+
// failed to redirect.
|
|
50
|
+
await expect(page.locator('body')).not.toBeEmpty()
|
|
51
|
+
})
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Minimal static file server for the Next.js static export (output: 'export').
|
|
2
|
+
//
|
|
3
|
+
// The export is built with the sibling's basePath, so every emitted asset URL is
|
|
4
|
+
// prefixed with /crm — but the files themselves live at the `out/` ROOT (Next
|
|
5
|
+
// does not nest the export under the basePath directory). In production the
|
|
6
|
+
// parent CloudFront distribution syncs `out/` into the sibling's S3 prefix; here we
|
|
7
|
+
// reproduce the same effect by stripping the leading the base path from each request
|
|
8
|
+
// path before resolving it against `out/`.
|
|
9
|
+
import { createServer } from 'node:http'
|
|
10
|
+
|
|
11
|
+
import { readFile, stat } from 'node:fs/promises'
|
|
12
|
+
import { join, extname, normalize } from 'node:path'
|
|
13
|
+
import { fileURLToPath } from 'node:url'
|
|
14
|
+
|
|
15
|
+
const ROOT = fileURLToPath(new URL('../out/', import.meta.url))
|
|
16
|
+
const BASE_PATH = process.env.E2E_BASE_PATH || process.env.NEXT_PUBLIC_BASE_PATH || ''
|
|
17
|
+
const PORT = Number(process.env.E2E_PORT || 4321)
|
|
18
|
+
|
|
19
|
+
// The document the CORE portal publishes at runtime (#403/#400) — auth.ts's
|
|
20
|
+
// identity.ts resolves the Cognito pool from this instead of a baked
|
|
21
|
+
// NEXT_PUBLIC_CORE_COGNITO_* build var. Real deployments serve it same-origin
|
|
22
|
+
// from the portal's own bucket; here it stands in for that, sourced from the
|
|
23
|
+
// SAME E2E_COGNITO_* values playwright.config.ts derives from e2e/fixtures.ts,
|
|
24
|
+
// so the pool/client id this document advertises always matches the one
|
|
25
|
+
// e2e/session.ts used to seed the localStorage session.
|
|
26
|
+
const IDENTITY_DOCUMENT_PATH = '/.well-known/biffo-identity.json'
|
|
27
|
+
const IDENTITY_DOCUMENT = JSON.stringify({
|
|
28
|
+
userPoolId: process.env.E2E_COGNITO_USER_POOL_ID || '',
|
|
29
|
+
clientId: process.env.E2E_COGNITO_CLIENT_ID || '',
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const CONTENT_TYPES = {
|
|
33
|
+
'.html': 'text/html; charset=utf-8',
|
|
34
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
35
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
36
|
+
'.css': 'text/css; charset=utf-8',
|
|
37
|
+
'.json': 'application/json; charset=utf-8',
|
|
38
|
+
'.geojson': 'application/json; charset=utf-8',
|
|
39
|
+
'.svg': 'image/svg+xml',
|
|
40
|
+
'.png': 'image/png',
|
|
41
|
+
'.jpg': 'image/jpeg',
|
|
42
|
+
'.ico': 'image/x-icon',
|
|
43
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
44
|
+
'.woff2': 'font/woff2',
|
|
45
|
+
'.woff': 'font/woff',
|
|
46
|
+
'.map': 'application/json; charset=utf-8',
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function resolveFile(urlPath) {
|
|
50
|
+
// Strip the sibling base path: the files live at out/ root.
|
|
51
|
+
let p = urlPath
|
|
52
|
+
if (p === BASE_PATH) p = '/'
|
|
53
|
+
else if (p.startsWith(`${BASE_PATH}/`)) p = p.slice(BASE_PATH.length)
|
|
54
|
+
if (p.endsWith('/')) p += 'index.html'
|
|
55
|
+
|
|
56
|
+
// Block path traversal, then resolve against ROOT.
|
|
57
|
+
const rel = normalize(p).replace(/^(\.\.[/\\])+/, '')
|
|
58
|
+
let filePath = join(ROOT, rel)
|
|
59
|
+
|
|
60
|
+
let s = await stat(filePath).catch(() => null)
|
|
61
|
+
if (s?.isDirectory()) {
|
|
62
|
+
filePath = join(filePath, 'index.html')
|
|
63
|
+
s = await stat(filePath).catch(() => null)
|
|
64
|
+
}
|
|
65
|
+
if (!s) {
|
|
66
|
+
// Extensionless route (trailingSlash export also emits foo/index.html).
|
|
67
|
+
const alt = `${filePath}.html`
|
|
68
|
+
if (await stat(alt).catch(() => null)) return alt
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
return filePath
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Read a JSON request body, tolerating an empty one. */
|
|
75
|
+
|
|
76
|
+
const server = createServer((req, res) => {
|
|
77
|
+
void (async () => {
|
|
78
|
+
try {
|
|
79
|
+
const url = new URL(req.url, 'http://localhost')
|
|
80
|
+
const urlPath = decodeURIComponent(url.pathname)
|
|
81
|
+
|
|
82
|
+
// The core identity document, served ROOT-relative and same-origin —
|
|
83
|
+
// NOT under BASE_PATH, matching how the real portal publishes it and
|
|
84
|
+
// how identity.ts fetches it (an absolute `/.well-known/...` path).
|
|
85
|
+
if (urlPath === IDENTITY_DOCUMENT_PATH) {
|
|
86
|
+
res.writeHead(200, {
|
|
87
|
+
'content-type': 'application/json; charset=utf-8',
|
|
88
|
+
'cache-control': 'no-store',
|
|
89
|
+
})
|
|
90
|
+
res.end(IDENTITY_DOCUMENT)
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Where a sibling's Core-API fixtures go, if it needs them. Under E2E
|
|
95
|
+
// `NEXT_PUBLIC_API_URL` is '', so the app's Core-API calls are
|
|
96
|
+
// same-origin relative paths and arrive here. Add an `api-fixtures.mjs`
|
|
97
|
+
// beside this file and dispatch to it before the static fallback below —
|
|
98
|
+
// tabsii-crm's is the worked example. A sibling with no BFF calls needs
|
|
99
|
+
// nothing here.
|
|
100
|
+
|
|
101
|
+
const filePath = await resolveFile(urlPath)
|
|
102
|
+
if (!filePath) {
|
|
103
|
+
res.writeHead(404, { 'content-type': 'text/plain' })
|
|
104
|
+
res.end('Not found')
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
const body = await readFile(filePath)
|
|
108
|
+
res.writeHead(200, {
|
|
109
|
+
'content-type': CONTENT_TYPES[extname(filePath)] || 'application/octet-stream',
|
|
110
|
+
'cache-control': 'no-store',
|
|
111
|
+
})
|
|
112
|
+
res.end(body)
|
|
113
|
+
} catch (err) {
|
|
114
|
+
res.writeHead(500, { 'content-type': 'text/plain' })
|
|
115
|
+
res.end(String(err))
|
|
116
|
+
}
|
|
117
|
+
})()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
server.listen(PORT, '127.0.0.1', () => {
|
|
121
|
+
process.stdout.write(`static-server: http://127.0.0.1:${PORT}${BASE_PATH}/ (root: ${ROOT})\n`)
|
|
122
|
+
})
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
"lint": "eslint .",
|
|
10
10
|
"lint:fix": "eslint . --fix",
|
|
11
11
|
"typecheck": "tsc --noEmit",
|
|
12
|
-
"test": "vitest run"
|
|
12
|
+
"test": "vitest run",
|
|
13
|
+
"e2e": "playwright test"
|
|
13
14
|
},
|
|
14
15
|
"dependencies": {
|
|
15
16
|
"amazon-cognito-identity-js": "^6.3.12",
|
|
@@ -19,6 +20,7 @@
|
|
|
19
20
|
},
|
|
20
21
|
"devDependencies": {
|
|
21
22
|
"@eslint/eslintrc": "^3.2.0",
|
|
23
|
+
"@playwright/test": "^1.61.1",
|
|
22
24
|
"@testing-library/dom": "^10.4.1",
|
|
23
25
|
"@testing-library/jest-dom": "^6.9.1",
|
|
24
26
|
"@testing-library/react": "^16.3.2",
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { defineConfig, devices } from '@playwright/test'
|
|
2
|
+
import {
|
|
3
|
+
BASE_PATH,
|
|
4
|
+
BUILD_ENV,
|
|
5
|
+
COGNITO_CLIENT_ID,
|
|
6
|
+
COGNITO_USER_POOL_ID,
|
|
7
|
+
E2E_PORT,
|
|
8
|
+
} from './e2e/fixtures'
|
|
9
|
+
|
|
10
|
+
// Browser E2E harness for this sibling's static-export frontend.
|
|
11
|
+
//
|
|
12
|
+
// The webServer builds the export and serves it base-path aware on a local
|
|
13
|
+
// port, including `/.well-known/biffo-identity.json` — the document the app
|
|
14
|
+
// resolves its Cognito pool from at runtime (#403). Serving it is not optional:
|
|
15
|
+
// without it `getCurrentSession()` resolves null, the app redirects to the
|
|
16
|
+
// portal, and every spec dies on a navigation. That is precisely how
|
|
17
|
+
// tabsii-geo's CI broke for four hours (#1208).
|
|
18
|
+
const baseURL = `http://127.0.0.1:${E2E_PORT}`
|
|
19
|
+
const isCI = !!process.env['CI']
|
|
20
|
+
|
|
21
|
+
export default defineConfig({
|
|
22
|
+
testDir: './e2e',
|
|
23
|
+
// Only *.spec.ts are tests; fixtures.ts / session.ts / static-server.mjs are helpers.
|
|
24
|
+
testMatch: /.*\.spec\.ts/,
|
|
25
|
+
fullyParallel: true,
|
|
26
|
+
forbidOnly: isCI,
|
|
27
|
+
retries: isCI ? 1 : 0,
|
|
28
|
+
...(isCI ? { workers: 1 } : {}),
|
|
29
|
+
reporter: isCI ? [['github'], ['list']] : 'list',
|
|
30
|
+
use: { baseURL, trace: 'on-first-retry' },
|
|
31
|
+
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
|
32
|
+
webServer: {
|
|
33
|
+
command: 'pnpm run build && node e2e/static-server.mjs',
|
|
34
|
+
url: `${baseURL}${BASE_PATH}/`,
|
|
35
|
+
reuseExistingServer: !isCI,
|
|
36
|
+
timeout: 180_000,
|
|
37
|
+
env: {
|
|
38
|
+
...BUILD_ENV,
|
|
39
|
+
E2E_PORT: String(E2E_PORT),
|
|
40
|
+
E2E_BASE_PATH: BASE_PATH,
|
|
41
|
+
E2E_COGNITO_USER_POOL_ID: COGNITO_USER_POOL_ID,
|
|
42
|
+
E2E_COGNITO_CLIENT_ID: COGNITO_CLIENT_ID,
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
})
|
|
@@ -18,7 +18,7 @@ importers:
|
|
|
18
18
|
version: 6.3.18
|
|
19
19
|
next:
|
|
20
20
|
specifier: ^15.5.22
|
|
21
|
-
version: 15.5.22(@babel/core@7.29.7)(@types/node@22.20.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
|
21
|
+
version: 15.5.22(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@22.20.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
|
22
22
|
react:
|
|
23
23
|
specifier: ^19.0.0
|
|
24
24
|
version: 19.2.7
|
|
@@ -29,6 +29,9 @@ importers:
|
|
|
29
29
|
'@eslint/eslintrc':
|
|
30
30
|
specifier: ^3.2.0
|
|
31
31
|
version: 3.3.5
|
|
32
|
+
'@playwright/test':
|
|
33
|
+
specifier: ^1.61.1
|
|
34
|
+
version: 1.62.1
|
|
32
35
|
'@testing-library/dom':
|
|
33
36
|
specifier: ^10.4.1
|
|
34
37
|
version: 10.4.1
|
|
@@ -705,6 +708,11 @@ packages:
|
|
|
705
708
|
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
|
706
709
|
engines: {node: '>=12.4.0'}
|
|
707
710
|
|
|
711
|
+
'@playwright/test@1.62.1':
|
|
712
|
+
resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==}
|
|
713
|
+
engines: {node: '>=20'}
|
|
714
|
+
hasBin: true
|
|
715
|
+
|
|
708
716
|
'@rolldown/pluginutils@1.0.0-beta.27':
|
|
709
717
|
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
|
|
710
718
|
|
|
@@ -1610,6 +1618,11 @@ packages:
|
|
|
1610
1618
|
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
|
1611
1619
|
engines: {node: '>= 0.4'}
|
|
1612
1620
|
|
|
1621
|
+
fsevents@2.3.2:
|
|
1622
|
+
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
|
1623
|
+
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
|
1624
|
+
os: [darwin]
|
|
1625
|
+
|
|
1613
1626
|
fsevents@2.3.3:
|
|
1614
1627
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
|
1615
1628
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
|
@@ -2104,6 +2117,16 @@ packages:
|
|
|
2104
2117
|
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
|
|
2105
2118
|
engines: {node: '>=12'}
|
|
2106
2119
|
|
|
2120
|
+
playwright-core@1.62.1:
|
|
2121
|
+
resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==}
|
|
2122
|
+
engines: {node: '>=20'}
|
|
2123
|
+
hasBin: true
|
|
2124
|
+
|
|
2125
|
+
playwright@1.62.1:
|
|
2126
|
+
resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==}
|
|
2127
|
+
engines: {node: '>=20'}
|
|
2128
|
+
hasBin: true
|
|
2129
|
+
|
|
2107
2130
|
possible-typed-array-names@1.1.0:
|
|
2108
2131
|
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
|
2109
2132
|
engines: {node: '>= 0.4'}
|
|
@@ -3123,6 +3146,10 @@ snapshots:
|
|
|
3123
3146
|
|
|
3124
3147
|
'@nolyfill/is-core-module@1.0.39': {}
|
|
3125
3148
|
|
|
3149
|
+
'@playwright/test@1.62.1':
|
|
3150
|
+
dependencies:
|
|
3151
|
+
playwright: 1.62.1
|
|
3152
|
+
|
|
3126
3153
|
'@rolldown/pluginutils@1.0.0-beta.27': {}
|
|
3127
3154
|
|
|
3128
3155
|
'@rollup/rollup-android-arm-eabi@4.62.2':
|
|
@@ -4188,6 +4215,9 @@ snapshots:
|
|
|
4188
4215
|
dependencies:
|
|
4189
4216
|
is-callable: 1.2.7
|
|
4190
4217
|
|
|
4218
|
+
fsevents@2.3.2:
|
|
4219
|
+
optional: true
|
|
4220
|
+
|
|
4191
4221
|
fsevents@2.3.3:
|
|
4192
4222
|
optional: true
|
|
4193
4223
|
|
|
@@ -4568,7 +4598,7 @@ snapshots:
|
|
|
4568
4598
|
|
|
4569
4599
|
natural-compare@1.4.0: {}
|
|
4570
4600
|
|
|
4571
|
-
next@15.5.22(@babel/core@7.29.7)(@types/node@22.20.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
|
4601
|
+
next@15.5.22(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@22.20.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
|
4572
4602
|
dependencies:
|
|
4573
4603
|
'@next/env': 15.5.22
|
|
4574
4604
|
'@swc/helpers': 0.5.15
|
|
@@ -4586,6 +4616,7 @@ snapshots:
|
|
|
4586
4616
|
'@next/swc-linux-x64-musl': 15.5.22
|
|
4587
4617
|
'@next/swc-win32-arm64-msvc': 15.5.22
|
|
4588
4618
|
'@next/swc-win32-x64-msvc': 15.5.22
|
|
4619
|
+
'@playwright/test': 1.62.1
|
|
4589
4620
|
sharp: 0.35.3(@types/node@22.20.0)
|
|
4590
4621
|
transitivePeerDependencies:
|
|
4591
4622
|
- '@babel/core'
|
|
@@ -4694,6 +4725,14 @@ snapshots:
|
|
|
4694
4725
|
|
|
4695
4726
|
picomatch@4.0.5: {}
|
|
4696
4727
|
|
|
4728
|
+
playwright-core@1.62.1: {}
|
|
4729
|
+
|
|
4730
|
+
playwright@1.62.1:
|
|
4731
|
+
dependencies:
|
|
4732
|
+
playwright-core: 1.62.1
|
|
4733
|
+
optionalDependencies:
|
|
4734
|
+
fsevents: 2.3.2
|
|
4735
|
+
|
|
4697
4736
|
possible-typed-array-names@1.1.0: {}
|
|
4698
4737
|
|
|
4699
4738
|
postcss@8.5.23:
|
package/dist/index.js
CHANGED
|
@@ -1370,6 +1370,7 @@ var DEFAULT_STATUS_CHECKS = [
|
|
|
1370
1370
|
"Secret Scan",
|
|
1371
1371
|
"Terraform Validate & Security"
|
|
1372
1372
|
];
|
|
1373
|
+
var SIBLING_STATUS_CHECKS = [...DEFAULT_STATUS_CHECKS, "E2E (Playwright)"];
|
|
1373
1374
|
var REPO_MERGE_SETTINGS = {
|
|
1374
1375
|
allow_auto_merge: true,
|
|
1375
1376
|
delete_branch_on_merge: true
|
|
@@ -6602,7 +6603,7 @@ async function configureSiblingGithub(github, config, coreConfig, session, coreI
|
|
|
6602
6603
|
await github.createBranch(org, repo, "staging", "dev");
|
|
6603
6604
|
await github.createBranch(org, repo, "main", "dev");
|
|
6604
6605
|
await github.setDefaultBranch(org, repo, "dev");
|
|
6605
|
-
await github.configureBranchProtection(config);
|
|
6606
|
+
await github.configureBranchProtection(config, void 0, SIBLING_STATUS_CHECKS);
|
|
6606
6607
|
await github.createEnvironments(config);
|
|
6607
6608
|
await github.sealBranchProtection(org, repo);
|
|
6608
6609
|
await github.enableVulnerabilityAlerts(org, repo);
|