ReviewOS

also looking at this

loghqorg/loghq

chore(release): promote main to production

#19
Merged glennmichael123 wants to merge main into production
8 files +325 -31
.env.productionmodified+3-1
Changes to .env.production
@@ -100,4 +100,6 @@ BUGHQ_KEY="encrypted:v2:eyJ2IjoyLCJlcGsiOiJNQ293QlFZREsyVnVBeUVBQ212TlNiUnNvcHNJ
100100ANALYTICSHQ_APP_ID="encrypted:v2:eyJ2IjoyLCJlcGsiOiJNQ293QlFZREsyVnVBeUVBZC1VY3h6RnZGYlJFamlPR3pzdXNoV0w2aUZJTV9HZWZSaG5HaXBnUDJDTSIsInNhbHQiOiJQa3cwYU5uRnptaks4YlpTYnRBMFJRIiwibm9uY2UiOiJmcXpzLU1jSzB6YTFFZzVTIiwiY2lwaGVydGV4dCI6IkZ0UXhoYUhzVlRzU1p3cExIX1JuZmpvWk85XzdDeG82IiwidGFnIjoiSzhjVHk2eHE1OEtwc0c4UDgyYjBqUSJ9"
101101GITHUB_REDIRECT_URL="encrypted:v2:eyJ2IjoyLCJlcGsiOiJNQ293QlFZREsyVnVBeUVBdFpORy1WcEtFU2pyUXI3dU9NamRMbW82enVlSVRpb0ZfZGxleWZGQlhIdyIsInNhbHQiOiJyZTljX3I3eXhIOWlUY0o0RGJwem5nIiwibm9uY2UiOiJOVk9CUmlnRldHd1BGVG15IiwiY2lwaGVydGV4dCI6InRNOE4zalluMktGVkFPSXd4WV9aSzdZTmhKSGt0YUcxQzVrVmJRRDBRcFRRSnkxVjhFd0kycHpMIiwidGFnIjoiX2V3Z1o1SHB5YWN2OFRFQUhMMEtxdyJ9"
102102GITHUB_CLIENT_SECRET="encrypted:v2:eyJ2IjoyLCJlcGsiOiJNQ293QlFZREsyVnVBeUVBMTE3aHZGVmlyX1dQNXFmOVRaMzVQNUh6Q2tzbnk4MFhPNG8tODQyRm5GcyIsInNhbHQiOiJHWGU2ZG96T2hTLWIzWjYxOUVrNnNRIiwibm9uY2UiOiJ3UkFrUTBXNFU1ODdGSUxoIiwiY2lwaGVydGV4dCI6IkN6MmJJdk5EU25TcHNpUDh6WWtTUzFqWUpFUXd5b1p3dlhTU3dmeW5LY2lhNDJkQV91RVBQdyIsInRhZyI6InR0bFd0aG01Zi1hcGwzUEZwbkt3cXcifQ"
103GITHUB_CLIENT_ID="encrypted:v2:eyJ2IjoyLCJlcGsiOiJNQ293QlFZREsyVnVBeUVBUHFrbDF5dVdvTktIWHAwWmd6VkwyOWthQlc1djM4RkJITUV2RmF4Y1loSSIsInNhbHQiOiJlM0xzTHR5Sm01WlVVZ3VzZGc1YkFRIiwibm9uY2UiOiJ5d2d5TWNld2lkdG9HdVN1IiwiY2lwaGVydGV4dCI6Ijk3cEF6N3ZlMFNOMDNoY05UdzhmTXpWbWNBOCIsInRhZyI6InpKSTdJdHp3aEREM0ZEekVsUFdFZHcifQ"⧵
103GITHUB_CLIENT_ID="encrypted:v2:eyJ2IjoyLCJlcGsiOiJNQ293QlFZREsyVnVBeUVBUHFrbDF5dVdvTktIWHAwWmd6VkwyOWthQlc1djM4RkJITUV2RmF4Y1loSSIsInNhbHQiOiJlM0xzTHR5Sm01WlVVZ3VzZGc1YkFRIiwibm9uY2UiOiJ5d2d5TWNld2lkdG9HdVN1IiwiY2lwaGVydGV4dCI6Ijk3cEF6N3ZlMFNOMDNoY05UdzhmTXpWbWNBOCIsInRhZyI6InpKSTdJdHp3aEREM0ZEekVsUFdFZHcifQ"
104MAIL_PASSWORD_NOREPLY="encrypted:v2:eyJ2IjoyLCJlcGsiOiJNQ293QlFZREsyVnVBeUVBdlNUcEtKZHFVQ0pzMW5hdXhTcFlTTV9lblVzNWFaR0JBX0ZNWDJyRzhqMCIsInNhbHQiOiJBX2o4TzgtZjNTOVhYZlJ1VTN5OGVnIiwibm9uY2UiOiJKWTI2V3J6dU43YXVKRGhpIiwiY2lwaGVydGV4dCI6IkhUUUJkM3BUTzlzVHMwUWFHZ0V1MVg1bm5na29CU0NIIiwidGFnIjoiM1JBUm05UndZVVI5VHdUd0taaW1qZyJ9"
105MAIL_PASSWORD="encrypted:v2:eyJ2IjoyLCJlcGsiOiJNQ293QlFZREsyVnVBeUVBSkFlS21LaWlmc0NyZDBkZUk3UHhjRUljN2F3XzRPZVJZZ3czQ0RiMU1TcyIsInNhbHQiOiJPOC1kSTFreUFhcUQ5cUc2Ym8td1FBIiwibm9uY2UiOiJsd0x1WThFenhyVm1wT2dFIiwiY2lwaGVydGV4dCI6IkVmdl9qX1FfNGxWc2tDd2FoTXpqSXlyVmhmb3gtTHU1IiwidGFnIjoiZEpRMW9jWmoyelJkbEV0RV9NWjd5ZyJ9"
config/email.tsmodified+9-2
Changes to config/email.ts
@@ -31,9 +31,16 @@ export default {
3131 * - Objects: [{ email: 'chris', password: '...' }]
3232 */
3333 mailboxes: [
34 // The mailbox this app authenticates as. Until now loghq.org had no mailbox
35 // of any kind on the mail server, so MAIL_USERNAME named an account that did
36 // not exist and SMTP auth answered 535 on every send. DNS and config were
37 // both already correct; only the account was missing.
3438 'noreply',
35 'chris',
36 'hello',
39 // 'chris' and 'hello' were declared here and never existed either. They are
40 // left out rather than created: provisioning sets each declared mailbox's
41 // password from MAIL_PASSWORD_<LOCALPART>, so declaring an address nobody
42 // asked for mints a real, credentialed account for it. Add them back when
43 // somebody actually wants to read mail at them.
3744 ],
3845
3946 url: env.APP_URL || 'https://stacksjs.com',
config/ui.tsmodified+16-1
Changes to config/ui.ts
@@ -127,7 +127,22 @@ export default {
127127 // allowPatterns therefore stays EMPTY.
128128 strict: {
129129 enabled: true,
130 failOnViolation: false,
130 // Hard-fail on a prohibited DOM call in a developer's own environment,
131 // warn everywhere else. An ALLOWLIST deliberately, not `!== 'prod'`:
132 // APP_ENV is typed 'local'|'dev'|'stage'|'prod' (config/env.ts) with no
133 // 'production' member, and .env ships 'development', which is outside
134 // that enum -- so any predicate written against 'production' is TRUE in
135 // production, the exact inversion this guards against. An unset APP_ENV
136 // falls through to warn-only for the same reason: a missing variable must
137 // not be what turns a page into a 500. That covers the real production
138 // path twice over: APP_ENV ships dotenvx-encrypted in .env.production, so
139 // it arrives either decrypted as 'prod' or, if the key is missing, as the
140 // literal 'encrypted:v2:...' string — and neither is in the list.
141 //
142 // A violation under this flag is a real HTTP 500 with the custom /500 page
143 // (bun-plugin-stx serve.js:11111,11133), not a blank 200 -- verified
144 // against the installed copy.
145 failOnViolation: ['local', 'dev', 'development'].includes(process.env.APP_ENV ?? ''),
131146 allowPatterns: [],
132147 },
133148
tests/e2e/README.mdadded+29-0
Changes to tests/e2e/README.md
@@ -0,0 +1,29 @@
1# Local E2E safety
2
3These mutating HTTP suites are disabled by default. A reachable development server
4does not opt a normal unit-test run into account creation or database cleanup.
5
6An intentional run requires all three variables:
7
8- `E2E_RUN=1`
9- `E2E_BASE_URL`: an HTTP numeric-loopback origin with an explicit port, such as
10 `http://127.0.0.1:3100`. Remote hosts, redirects and URL credentials are not allowed.
11- `E2E_SQLITE_PATH`: an absolute path to an existing `database.sqlite` inside a
12 freshly created `loghq-e2e-*` directory directly under the system temp directory.
13
14Start a separate local application instance using that same disposable database.
15Do not point the test instance at real data. Disable outbound mail, notifications,
16webhooks and paid integrations in that instance before running these suites.
17The URL guard cannot prove which database or integrations a local server uses;
18the operator must configure the isolated server correctly.
19
20Cleanup opens only the explicitly named disposable SQLite file and only removes
21accounts whose exact email addresses were generated in this process. It never
22uses the application's default database connection or a wildcard email match.
23
24## Known coverage gap
25
26The auth and routing suites still contain expectations for the previous page-action
27login and readable `loghq_token` cookie. They need migration to the current JSON
28auth endpoints and HttpOnly `auth-token` contract before they can serve as a current
29auth release gate. This safety guard does not claim those legacy expectations pass.
tests/e2e/helpers.tsmodified+22-27
Changes to tests/e2e/helpers.ts
@@ -1,11 +1,9 @@
11/**
22 * Shared plumbing for the end-to-end suite.
33 *
4 * These tests drive the app over HTTP against a running dev server. That is
5 * possible at all because every form in the app handles its own POST now
6 * (stx#1847, see resources/views/login.stx) — sign-in, sign-up, password reset
7 * and project creation are ordinary form submissions, so a browser is not
8 * needed to exercise them and this suite carries no browser dependency.
4 * These tests drive an explicitly opted-in, isolated local server over HTTP.
5 * See README.md for the disposable database requirement and the legacy auth
6 * expectations that still need migrating to the current JSON API.
97 *
108 * What that does NOT cover is anything whose behaviour only exists after
119 * hydration: SPA navigation, the theme toggle, clipboard buttons, the delete
@@ -13,7 +11,10 @@
1311 * deliberately out of scope here rather than faked.
1412 */
1513
16const BASE = Bun.env.E2E_BASE_URL || 'http://localhost:3000'
14import { cleanupE2EAccounts, e2eUrl, validateE2ETarget } from './safety'
15
16const target = validateE2ETarget(Bun.env)
17const createdEmails = new Set<string>()
1718
1819/** A password that satisfies the app's 8-character minimum. */
1920export const TEST_PASSWORD = 'e2e-test-password'
@@ -21,14 +22,16 @@ export const TEST_PASSWORD = 'e2e-test-password'
2122/**
2223 * Whether a server is reachable.
2324 *
24 * The suite SKIPS rather than fails when nothing is listening, so `bun test`
25 * The suite SKIPS unless explicitly enabled, and when nothing is listening, so `bun test`
2526 * stays green on a machine that has not started the app. A failing E2E suite
2627 * should mean the app is broken, not that the developer did not run `bun run
2728 * dev` — otherwise the signal gets ignored, which is worse than not having it.
2829 */
2930export async function serverIsUp(): Promise<boolean> {
31 if (!target)
32 return false
3033 try {
31 const res = await fetch(BASE, { signal: AbortSignal.timeout(3000) })
34 const res = await fetch(target.baseUrl, { redirect: 'manual', signal: AbortSignal.timeout(3000) })
3235 return res.ok
3336 }
3437 catch {
@@ -46,7 +49,7 @@ export async function serverIsUp(): Promise<boolean> {
4649export const SERVER_UP: boolean = await serverIsUp()
4750
4851export function url(path: string): string {
49 return BASE + path
52 return e2eUrl(target, path)
5053}
5154
5255/**
@@ -88,12 +91,13 @@ export function sessionCookie(res: Response): string {
8891/**
8992 * An email nobody else is using.
9093 *
91 * Unique per call so a rerun cannot collide with its own leftovers, and
92 * prefixed so the cleanup below can find every account this suite ever made —
93 * including from a run that crashed before its own teardown.
94 * Unique per call so a rerun cannot collide with its own leftovers. Track the
95 * exact address for this run's cleanup; the prefix is only a readable QA label.
9496 */
9597export function freshEmail(label: string): string {
96 return `e2e-${label}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}@loghq.test`
98 const email = `e2e-${label}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}@loghq.test`
99 createdEmails.add(email)
100 return email
97101}
98102
99103/** Register through the real form and return the session cookie it mints. */
@@ -109,20 +113,11 @@ export async function registerAndSignIn(label: string): Promise<{ email: string,
109113}
110114
111115/**
112 * Remove every account this suite has ever created, and anything hanging off
113 * one.
114 *
115 * Matches on the `e2e-%@loghq.test` prefix rather than tracking ids, so a run
116 * that died before teardown is cleaned up by the next one. Deletes children
117 * first — projects and tokens reference users.
116 * Remove only this process's generated accounts from the explicit disposable
117 * SQLite file. Never import the app's default database connection for cleanup.
118 * Failed-run leftovers remain confined to the disposable test database.
118119 */
119120export async function cleanupTestAccounts(): Promise<void> {
120 const { db } = await import('@stacksjs/database')
121 await db.unsafe(
122 `DELETE FROM projects WHERE owner_id IN (SELECT id FROM users WHERE email LIKE 'e2e-%@loghq.test')`,
123 ).execute()
124 await db.unsafe(
125 `DELETE FROM oauth_access_tokens WHERE user_id IN (SELECT id FROM users WHERE email LIKE 'e2e-%@loghq.test')`,
126 ).execute()
127 await db.unsafe(`DELETE FROM users WHERE email LIKE 'e2e-%@loghq.test'`).execute()
121 cleanupE2EAccounts(target, createdEmails)
122 createdEmails.clear()
128123}
tests/e2e/safety.tsadded+76-0
Changes to tests/e2e/safety.ts
@@ -0,0 +1,76 @@
1import { Database } from 'bun:sqlite'
2import { realpathSync, statSync } from 'node:fs'
3import { tmpdir } from 'node:os'
4import { basename, dirname, isAbsolute } from 'node:path'
5
6interface E2EEnvironment {
7 E2E_RUN?: string
8 E2E_BASE_URL?: string
9 E2E_SQLITE_PATH?: string
10}
11
12export interface E2ETarget {
13 baseUrl: string
14 sqlitePath: string
15}
16
17export function e2eUrl(target: E2ETarget | null, path: string): string {
18 if (!target)
19 throw new Error('Mutating E2E tests are disabled. See tests/e2e/README.md.')
20 const resolved = new URL(path, target.baseUrl)
21 if (resolved.origin !== target.baseUrl)
22 throw new Error('E2E requests cannot leave the configured loopback origin.')
23 return resolved.href
24}
25
26export function cleanupE2EAccounts(target: E2ETarget | null, emails: ReadonlySet<string>): void {
27 if (!target || emails.size === 0)
28 return
29 const checked = validateE2ETarget({ E2E_RUN: '1', E2E_BASE_URL: target.baseUrl, E2E_SQLITE_PATH: target.sqlitePath })!
30 const db = new Database(checked.sqlitePath, { readwrite: true, create: false })
31 try {
32 db.transaction(() => {
33 for (const email of emails) {
34 db.query('DELETE FROM projects WHERE owner_id IN (SELECT id FROM users WHERE email = ?)').run(email)
35 db.query('DELETE FROM oauth_access_tokens WHERE user_id IN (SELECT id FROM users WHERE email = ?)').run(email)
36 db.query('DELETE FROM users WHERE email = ?').run(email)
37 }
38 })()
39 }
40 finally {
41 db.close()
42 }
43}
44
45/** Never infer permission to mutate from an unrelated server being reachable. */
46export function validateE2ETarget(env: E2EEnvironment): E2ETarget | null {
47 if (env.E2E_RUN !== '1')
48 return null
49
50 let base: URL
51 try {
52 base = new URL(env.E2E_BASE_URL || '')
53 }
54 catch {
55 throw new Error('E2E_RUN requires an explicit numeric-loopback HTTP origin.')
56 }
57 if (base.protocol !== 'http:' || !['127.0.0.1', '[::1]'].includes(base.hostname)
58 || !base.port || base.username || base.password || base.pathname !== '/' || base.search || base.hash) {
59 throw new Error('Mutating E2E tests only accept a numeric-loopback HTTP origin with an explicit port.')
60 }
61
62 const path = env.E2E_SQLITE_PATH || ''
63 if (!isAbsolute(path) || basename(path) !== 'database.sqlite')
64 throw new Error('E2E_SQLITE_PATH must name an existing disposable database.sqlite file.')
65
66 // Resolve symlinks before checking containment. /tmp is /private/tmp on macOS.
67 const sqlitePath = realpathSync(path)
68 const directory = realpathSync(dirname(path))
69 const roots = new Set([realpathSync(tmpdir()), realpathSync('/tmp')])
70 if (dirname(sqlitePath) !== directory || basename(sqlitePath) !== 'database.sqlite'
71 || !basename(directory).startsWith('loghq-e2e-')
72 || !roots.has(dirname(directory)) || !statSync(sqlitePath).isFile()) {
73 throw new Error('E2E database must be a regular file inside a loghq-e2e-* directory directly under the system temp directory.')
74 }
75 return { baseUrl: base.origin, sqlitePath }
76}
tests/unit/e2e-safety.test.tsadded+82-0
Changes to tests/unit/e2e-safety.test.ts
@@ -0,0 +1,82 @@
1import { Database } from 'bun:sqlite'
2import { afterAll, describe, expect, test } from 'bun:test'
3import { mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs'
4import { tmpdir } from 'node:os'
5import { join } from 'node:path'
6import { cleanupE2EAccounts, e2eUrl, validateE2ETarget } from '../e2e/safety'
7
8const directory = mkdtempSync(join(tmpdir(), 'loghq-e2e-safety-'))
9const sqlitePath = join(directory, 'database.sqlite')
10const fixture = new Database(sqlitePath)
11fixture.exec('CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT); CREATE TABLE projects (owner_id INTEGER); CREATE TABLE oauth_access_tokens (user_id INTEGER)')
12fixture.close()
13afterAll(() => rmSync(directory, { recursive: true, force: true }))
14
15describe('mutating E2E target safety', () => {
16 test('is disabled by default, even when a server URL is present', () => {
17 expect(validateE2ETarget({})).toBeNull()
18 expect(validateE2ETarget({ E2E_BASE_URL: 'https://loghq.org' })).toBeNull()
19 })
20
21 test('requires an explicit local target and disposable database', () => {
22 expect(() => validateE2ETarget({ E2E_RUN: '1' })).toThrow()
23 expect(() => validateE2ETarget({ E2E_RUN: '1', E2E_BASE_URL: 'http://127.0.0.1:3100' })).toThrow()
24 })
25
26 test('rejects remote hosts, credentials, paths and non-HTTP targets', () => {
27 for (const target of [
28 'https://loghq.org',
29 'http://localhost.example.com:3100',
30 'http://localhost:3100',
31 'http://user:password@127.0.0.1:3100',
32 'http://127.0.0.1:3100/api',
33 'http://127.0.0.1:3100/?redirect=production',
34 'http://127.0.0.1:3100/#fragment',
35 'ftp://127.0.0.1:3100',
36 ]) {
37 expect(() => validateE2ETarget({ E2E_RUN: '1', E2E_BASE_URL: target, E2E_SQLITE_PATH: '/tmp/loghq-e2e-example/test.sqlite' })).toThrow()
38 }
39 })
40
41 test('accepts an explicit loopback origin with a real disposable file', () => {
42 for (const baseUrl of ['http://127.0.0.1:3100', 'http://[::1]:3100']) {
43 expect(validateE2ETarget({ E2E_RUN: '1', E2E_BASE_URL: baseUrl, E2E_SQLITE_PATH: sqlitePath }))
44 .toEqual({ baseUrl, sqlitePath: realpathSync(sqlitePath) })
45 }
46 })
47
48 test('rejects a symlink to a differently named file', () => {
49 const linkDirectory = mkdtempSync(join(tmpdir(), 'loghq-e2e-link-'))
50 const otherFile = join(directory, 'not-disposable.sqlite')
51 new Database(otherFile).close()
52 symlinkSync(otherFile, join(linkDirectory, 'database.sqlite'))
53 try {
54 expect(() => validateE2ETarget({ E2E_RUN: '1', E2E_BASE_URL: 'http://127.0.0.1:3100', E2E_SQLITE_PATH: join(linkDirectory, 'database.sqlite') })).toThrow()
55 }
56 finally {
57 rmSync(linkDirectory, { recursive: true, force: true })
58 }
59 })
60
61 test('cleans only this run in the disposable file and rejects cross-origin requests', () => {
62 const target = validateE2ETarget({ E2E_RUN: '1', E2E_BASE_URL: 'http://127.0.0.1:3100', E2E_SQLITE_PATH: sqlitePath })!
63 expect(() => e2eUrl(null, '/login')).toThrow()
64 expect(() => e2eUrl(target, 'https://loghq.org/logout')).toThrow()
65 expect(() => e2eUrl(target, '//example.com/logout')).toThrow()
66 expect(e2eUrl(target, '/login')).toBe('http://127.0.0.1:3100/login')
67 const email = 'e2e-current-run@loghq.test'
68 const db = new Database(sqlitePath)
69 try {
70 db.query('INSERT INTO users (id, email) VALUES (?, ?)').run(1, email)
71 db.query('INSERT INTO users (id, email) VALUES (?, ?)').run(2, 'e2e-previous-run@loghq.test')
72 db.exec('INSERT INTO projects VALUES (1), (2); INSERT INTO oauth_access_tokens VALUES (1), (2)')
73 cleanupE2EAccounts(target, new Set([email]))
74 expect(db.query('SELECT email FROM users').all()).toEqual([{ email: 'e2e-previous-run@loghq.test' }])
75 expect(db.query('SELECT owner_id FROM projects').all()).toEqual([{ owner_id: 2 }])
76 expect(db.query('SELECT user_id FROM oauth_access_tokens').all()).toEqual([{ user_id: 2 }])
77 }
78 finally {
79 db.close()
80 }
81 })
82})
tests/unit/no-vanilla-js.test.tsadded+88-0
Changes to tests/unit/no-vanilla-js.test.ts
@@ -0,0 +1,88 @@
1/**
2 * Rule 8 (stx-standards 08-no-vanilla-js): no vanilla DOM in a client script.
3 *
4 * config/ui.ts turns stx's strict mode on, but that is only a partial gate.
5 * The renderer validates whatever reaches validateClientScript, and a
6 * signal-using <script client> block is re-tagged data-stx-scoped by
7 * processScriptSetup and then skipped by process.js's own skipAttrs -- so the
8 * blocks most likely to touch the DOM are the ones it never sees. It also
9 * only ever checks pages something actually rendered, which makes a
10 * dev-server boot a sampling exercise rather than a check.
11 *
12 * This reads the source instead, where no block has been re-tagged yet, so it
13 * sees every one of them. It calls stx's own exported validator rather than
14 * re-listing the prohibited patterns, so the rules cannot drift from the
15 * engine's, and honours `stx-strict-ignore` markers because that validator
16 * parses them itself.
17 */
18import { describe, expect, it } from 'bun:test'
19import { readdirSync, readFileSync, statSync } from 'node:fs'
20import { join, relative } from 'node:path'
21import { validateClientScript } from '@stacksjs/stx'
22
23const root = process.cwd()
24
25/** Vendored or generated; their violations are not this repo's to fix. */
26const SKIP = new Set(['node_modules', 'storage', 'pantry', '.git', 'dist', 'public', 'coverage'])
27
28/**
29 * Not a client script: server blocks, external sources, and any explicit
30 * non-JS type. Mirrors the skipAttrs process.js passes its scanner, minus the
31 * two render-time markers (data-stx-scoped, data-stx-router) that by
32 * definition cannot be in a source file -- excluding them here is what makes
33 * this broader than the renderer.
34 */
35const NOT_CLIENT = /\bserver\b|\bsrc\s*=|\btype\s*=\s*["'](?!(?:text\/javascript|application\/javascript|module)["'])[^"']*["']/
36const SCRIPT = /<script\b([^>]*)>([\s\S]*?)<\/script>/g
37
38function stxFiles(dir: string): string[] {
39 let found: string[] = []
40 for (const name of readdirSync(dir)) {
41 if (SKIP.has(name) || name.startsWith('.') && name !== '.stx') continue
42 const path = join(dir, name)
43 let entry
44 try { entry = statSync(path) }
45 catch { continue }
46 if (entry.isDirectory()) found = found.concat(stxFiles(path))
47 else if (path.endsWith('.stx')) found.push(path)
48 }
49 return found
50}
51
52function scan(): { blocks: number, files: number, violations: string[] } {
53 const violations: string[] = []
54 const files = stxFiles(root)
55 let blocks = 0
56 for (const file of files) {
57 const name = relative(root, file)
58 for (const [, attrs, body] of readFileSync(file, 'utf8').matchAll(SCRIPT)) {
59 if (NOT_CLIENT.test(attrs ?? '')) continue
60 blocks++
61 // failOnViolation rather than reading console.warn: the validator
62 // memoises per (content, file, config) and a remembered verdict
63 // re-throws but does not warn again, so two identical blocks would
64 // otherwise report once.
65 try { validateClientScript(body ?? '', name, { enabled: true, failOnViolation: true, allowPatterns: [] }, name) }
66 catch (error) { violations.push(error instanceof Error ? error.message : String(error)) }
67 }
68 }
69 return { blocks, files: files.length, violations }
70}
71
72const result = scan()
73
74describe('rule 8: no vanilla JS in client scripts', () => {
75 it('reaches the client scripts it is meant to check', () => {
76 // Without this the suite would pass just as happily on a scan that
77 // matched nothing at all, which is the one failure mode a green test
78 // cannot tell you about.
79 expect(result.files).toBeGreaterThan(0)
80 expect(result.blocks).toBeGreaterThan(0)
81 })
82
83 it('finds no prohibited DOM calls', () => {
84 // Asserting on the joined report rather than a count: the diff on failure
85 // is the list of offending files, lines and suggested composables.
86 expect(result.violations.join('\n\n')).toBe('')
87 })
88})