import { describe, it, expect } from 'vitest' import { readFileSync, existsSync, readdirSync } from 'node:fs' import { join } from 'node:path' const SKILL = join(__dirname, '../../skills/data-portal') const TEMPLATE = join(SKILL, 'template') const wrangler = () => readFileSync(join(TEMPLATE, 'wrangler.toml'), 'utf8') const schema = () => readFileSync(join(SKILL, 'schema.sql'), 'utf8') describe('wrangler.toml', () => { it('binds D1 as DB', () => { expect(wrangler()).toMatch(/\[\[d1_databases\]\]/) expect(wrangler()).toMatch(/binding\s*=\s*"DB"/) }) it('binds R2 as BUCKET — the resource class the plugin has never bound', () => { expect(wrangler()).toMatch(/\[\[r2_buckets\]\]/) expect(wrangler()).toMatch(/binding\s*=\s*"BUCKET"/) expect(wrangler()).toMatch(/bucket_name\s*=\s*"__R2_BUCKET_NAME__"/) }) it('declares every placeholder it uses in its header comment', () => { const text = wrangler() const used = [...text.matchAll(/__[A-Z0-9_]+__/g)].map((m) => m[0]) for (const p of new Set(used)) { // Each placeholder appears at least twice: once documented, once used. expect(text.split(p).length - 1).toBeGreaterThanOrEqual(2) } }) }) describe('schema.sql', () => { it('is re-appliable — every create is IF NOT EXISTS', () => { const creates = [...schema().matchAll(/CREATE (TABLE|INDEX|UNIQUE INDEX)/gi)] expect(creates.length).toBeGreaterThan(0) expect(schema()).not.toMatch(/CREATE TABLE (?!IF NOT EXISTS)/i) }) it('has the four tables the portal needs', () => { for (const t of ['people', 'sessions', 'manifest', 'auth_attempts']) { expect(schema()).toMatch(new RegExp(`CREATE TABLE IF NOT EXISTS ${t}`, 'i')) } }) it('defaults manifest.ingested to 0', () => { expect(schema()).toMatch(/ingested\s+INTEGER\s+NOT NULL\s+DEFAULT 0/i) }) it('stores no plaintext passcode column', () => { expect(schema()).not.toMatch(/passcode\s+TEXT/i) }) it('enforces one attempt row per (scope, window)', () => { expect(schema()).toMatch(/CREATE UNIQUE INDEX IF NOT EXISTS auth_attempts_scope_window/i) }) }) // The template's flat shape and its `pages_build_output_dir = "."` have to agree, // because the two together are what puts index.html at the served root. Until // Task 1917 the broker handed wrangler the site root as the positional asset // directory, which overrode the declared output dir, so a nested public/ served // the site one level too deep and `/` returned 404 — the live failure Task 1774 // closed by flattening. Since 1917 the broker publishes the declared output dir, // so a mismatch here no longer 404s; these assertions keep the shipped template // on the one shape that is deployed and measured. describe('template layout matches what the deploy publishes', () => { it('has no public/ level', () => { expect(existsSync(join(TEMPLATE, 'public'))).toBe(false) }) it('serves index.html, portal.css and portal.js from the tree root', () => { for (const f of ['index.html', 'portal.css', 'portal.js']) { expect(existsSync(join(TEMPLATE, f))).toBe(true) } }) it('keeps functions/ at the tree root', () => { expect(existsSync(join(TEMPLATE, 'functions', 'api', 'auth.ts'))).toBe(true) }) it('sets pages_build_output_dir to the tree root', () => { expect(wrangler()).toMatch(/pages_build_output_dir\s*=\s*"\."/) }) // Wrangler's Pages upload ignore list is fixed (_worker.js, _redirects, // _headers, _routes.json, functions, .DS_Store, node_modules, .git, // .wrangler) and Pages honours no .assetsignore, so every other file in the // tree is served publicly. schema.sql has no reason to be, so it lives beside // template/ rather than inside it. wrangler.toml has to stay and its exposure // is tracked separately. it('keeps schema.sql out of the publishable tree', () => { expect(existsSync(join(TEMPLATE, 'schema.sql'))).toBe(false) expect(existsSync(join(SKILL, 'schema.sql'))).toBe(true) }) // Dotfiles are excluded because the ignore list already covers the ones that // matter (.DS_Store, .git, .wrangler) and a stray Finder .DS_Store would // otherwise fail this test for a reason it does not name. it('publishes nothing beyond the site files, functions/ and wrangler.toml', () => { const published = readdirSync(TEMPLATE) .filter((e) => !e.startsWith('.')) .sort() expect(published).toEqual([ 'functions', 'index.html', 'portal.css', 'portal.js', 'wrangler.toml', ]) }) }) describe('device binding', () => { it('declares the install origin as a var placeholder', () => { const text = wrangler() expect(text).toMatch(/\[vars\]/) expect(text).toMatch(/PORTAL_INSTALL_ORIGIN\s*=\s*"__PORTAL_INSTALL_ORIGIN__"/) }) // The secret must never be ASSIGNED in wrangler.toml: that file is committed // to the account's assembled tree, and the deploy publishes the directory it // is handed, so a secret there is a secret in a repo and one deploy from // being served. Naming it in a comment is the opposite — that comment is what // stops someone adding the assignment later — so the rule is on assignment, // not on the word. it('never assigns the fetch secret in wrangler.toml', () => { const assignments = wrangler() .split('\n') .filter((l) => !l.trim().startsWith('#')) .join('\n') expect(assignments).not.toMatch(/PORTAL_FETCH_SECRET\s*=/) }) it('SKILL.md tells the assembler how to set both, and to publish the index', () => { const skill = readFileSync(join(SKILL, 'SKILL.md'), 'utf8') expect(skill).toContain('PORTAL_FETCH_SECRET') expect(skill).toContain('PORTAL_INSTALL_ORIGIN') expect(skill).toContain('portal-index-push.mjs') // Enrolment gained a required flag; the documented command must carry it or // every enrolment the skill prescribes fails. expect(skill).toContain('--account') }) it('schema.sql declares the directory table the portal reads', () => { const text = schema() expect(text).toMatch(/CREATE TABLE IF NOT EXISTS directory/) expect(text).toMatch(/UNIQUE \(accountId, relPath\)/) expect(text).toMatch(/accountId\s+TEXT NOT NULL DEFAULT ''/) }) // IF NOT EXISTS is a no-op on an existing table, so a portal deployed before // this column will NOT gain it from a re-apply. The push names isDir in its // INSERT, so such a table fails loudly rather than silently publishing // nothing — the ALTER has to be findable at the point that happens. it('declares isDir and carries the ALTER for a table that predates it', () => { const text = schema() expect(text).toMatch(/isDir\s+INTEGER NOT NULL DEFAULT 0/) expect(text).toContain('ALTER TABLE directory ADD COLUMN isDir INTEGER NOT NULL DEFAULT 0') }) }) const portalJs = () => readFileSync(join(TEMPLATE, 'portal.js'), 'utf8') const indexHtml = () => readFileSync(join(TEMPLATE, 'index.html'), 'utf8') describe('login error surfacing and passcode reveal (Task 1872)', () => { it('maps each auth failure to a distinct message, not one opaque string', () => { const js = portalJs() expect(js).not.toMatch(/That did not work\./) expect(js).toContain('Too many attempts. Try again later.') expect(js).toContain('Enter both your name and your passcode.') expect(js).toContain('That name and passcode did not match.') expect(js).toContain('Something went wrong. Please try again in a moment.') expect(js).toContain('Could not reach the server. Check your connection and try again.') }) it('logs the status and error on the auth failure path', () => { expect(portalJs()).toMatch(/console\.error\(\s*'\[data-portal\] auth failed'/) }) it('ships a reveal control bound to the passcode field', () => { const html = indexHtml() expect(html).toMatch(/id="dp-reveal"/) expect(html).toMatch(/aria-controls="dp-passcode"/) expect(portalJs()).toMatch(/dp-reveal/) }) }) // Task 1900. The device pushes a new tree hourly, so a client who stays signed // in reads an index frozen at sign-in. These assertions pin the control that // re-reads it: the button in the pinned header, and the click that drives it. describe('refresh control', () => { it('puts a refresh button inside the pinned header', () => { const header = indexHtml().match(/
[\s\S]*?<\/header>/) expect(header).not.toBeNull() expect(header![0]).toMatch(/id="dp-refresh"/) expect(header![0]).toMatch(/class="dp-iconbtn"/) expect(header![0]).toMatch(/aria-label="Refresh"/) }) it('sits between the crumbs and the upload control', () => { const text = indexHtml() expect(text.indexOf('id="dp-crumbs"')).toBeLessThan(text.indexOf('id="dp-refresh"')) expect(text.indexOf('id="dp-refresh"')).toBeLessThan(text.indexOf('id="dp-upload-open"')) }) it('binds a click on it', () => { expect(portalJs()).toMatch(/refreshEl\.addEventListener\('click'/) }) it('drives refresh() rather than reloading the page', () => { expect(portalJs()).not.toMatch(/location\.reload/) }) // The status line has to survive a failed re-read. refresh() catches its own // failure, so the promise resolves either way; only a reported outcome tells // the handler whether clearing the status would erase an error. it('has refresh() report its outcome on all three exits', () => { const body = portalJs().match(/function refresh\(\)[\s\S]*?\n }\n/) expect(body).not.toBeNull() expect(body![0]).toMatch(/return true/) expect((body![0].match(/return false/g) || []).length).toBe(2) }) // Task 1900 asserted "never polls", on the reasoning that a poll against an // hourly tree is waste. Task 1910 moved the push to a 60s loop, so that // premise no longer holds: the poll is now the thing that makes a device-side // change appear without the client clicking anything. // // What still must hold is that the poll cannot run away. It is gated on tab // visibility and on a re-entry guard, so a background tab costs nothing and a // slow response cannot stack reads on a portal running on a free plan. it('polls once a minute, gated on visibility and not re-entrant', () => { const text = portalJs() const timer = text.match(/setInterval\(function \(\)[\s\S]*?\}, 60000\)/) expect(timer).not.toBeNull() expect(timer![0]).toMatch(/document\.hidden/) expect(timer![0]).toMatch(/polling/) }) }) // refresh() now writes its own failure text, which would bury the outcome of // the delete or upload the client just asked for. Both callers compose instead, // so a stale list is reported without losing what happened to the file. describe('an action that outlives its re-list', () => { it('keeps the delete and upload outcome when the re-list fails', () => { const text = portalJs() expect((text.match(/The list could not be reloaded\./g) || []).length).toBe(2) }) it('has both callers read the boolean refresh() returns', () => { const text = portalJs() const removeBody = text.match(/function remove\(key\)[\s\S]*?\n }\n/) const uploadBody = text.match(/function uploadFile\(file, target\)[\s\S]*?\n }\n/) expect(removeBody).not.toBeNull() expect(uploadBody).not.toBeNull() expect(removeBody![0]).toMatch(/refresh\(\)\.then\(function \(ok\)/) expect(uploadBody![0]).toMatch(/refresh\(\)\.then\(function \(ok\)/) }) }) // The two-way client (Task 1910). These are text assertions over the shipped // template, which is the only harness this dependency-free page has — there is // no bundler and no DOM test runner for it. They pin the behaviours that make // the exchange two-way; the live drag-and-drop itself is an on-device check. describe('the client can send a file to a folder', () => { it('makes every folder row a drop target', () => { // The interaction the whole task exists for: drop onto the folder, not // into a modal that decides for you. const text = portalJs() expect(text).toMatch(/li\.addEventListener\('drop'/) expect(text).toMatch(/dp-drop-target/) }) it('sends the row own path as the target, not the folder merely open', () => { const text = portalJs() expect(text).toMatch(/uploadMany\(dt\.files, e\.relPath\)/) }) it('does not upload a row drop twice', () => { // A drop on a folder row bubbles to the listing, whose handler would // otherwise upload the same file again into whichever folder is open. const text = portalJs() expect(text).toMatch(/ev\.dpHandled = true/) expect(text).toMatch(/if \(e\.dpHandled\) return/) }) it('takes every dropped file, not the first', () => { const text = portalJs() expect(text).not.toMatch(/dt\.files\[0\]/) expect(text).toMatch(/function uploadMany\(files, target\)/) }) it('accepts a multiple selection in the picker', () => { expect(indexHtml()).toMatch(/id="dp-file"[^>]*multiple/) }) it('names the destination folder in the modal', () => { // An upload whose folder the client has to infer is the defect this task // removes; stating it is part of the fix, not decoration. expect(portalJs()).toMatch(/Uploading into /) expect(indexHtml()).toMatch(/id="dp-upload-target"/) }) }) describe('the client can take a file out', () => { it('offers drag-out on the client own uploads, not only device files', () => { // Before this there were two stores and only one of them could be dragged // out of. DownloadURL is what makes the OS write real bytes. const text = portalJs() expect((text.match(/setData\(\s*'DownloadURL'/g) || []).length).toBe(2) }) }) describe('the client can send a large file', () => { it('slices at the same 25 MiB bound the server gates on', () => { const text = portalJs() expect(text).toMatch(/PART_BYTES = 25 \* 1024 \* 1024/) expect(text).toMatch(/MAX_TOTAL_BYTES = 1024 \* 1024 \* 1024/) }) it('checks the size before sending rather than after', () => { // The browser should not spend minutes sending a gigabyte only to be told // at the end that it was too large. expect(portalJs()).toMatch(/file\.size > MAX_TOTAL_BYTES/) }) it('runs the three multipart stages', () => { const text = portalJs() for (const action of ['create', 'part', 'complete']) { expect(text).toContain(`'action', '${action}'`) } }) it('shows progress across the parts', () => { expect(portalJs()).toMatch(/showProgress\(i \/ total\)/) expect(indexHtml()).toMatch(/id="dp-progress-bar"/) }) }) describe('a failed upload says which failure it was', () => { it('maps each status to its own message', () => { // Every failure used to read 'Upload failed.', which tells a client nothing // about whether to retry, sign in again, or pick a smaller file. const text = portalJs() const fn = text.match(/function uploadFailureMessage\(status\)[\s\S]*?\n }\n/) expect(fn).not.toBeNull() for (const status of ['401', '403', '413', '400']) { expect(fn![0]).toContain(status) } }) it('distinguishes a network failure from a server refusal', () => { expect(portalJs()).toMatch(/Could not reach the server/) }) })