From 11bdc7b7d48332d17949dad42f94544e807f64e9 Mon Sep 17 00:00:00 2001 From: Mike K Date: Wed, 6 May 2026 13:17:10 -0400 Subject: [PATCH 1/5] Add name/description and fix tests --- packages/telescope/__tests__/cli.test.ts | 69 +++++++++++++++++++++--- packages/telescope/package.json | 2 +- packages/telescope/src/config.ts | 2 + packages/telescope/src/index.ts | 4 ++ packages/telescope/src/testRunner.ts | 2 + packages/telescope/src/types.ts | 6 +++ 6 files changed, 77 insertions(+), 8 deletions(-) diff --git a/packages/telescope/__tests__/cli.test.ts b/packages/telescope/__tests__/cli.test.ts index a595b5df..826e0afb 100644 --- a/packages/telescope/__tests__/cli.test.ts +++ b/packages/telescope/__tests__/cli.test.ts @@ -1,17 +1,26 @@ import { spawnSync } from 'child_process'; +import path from 'path'; +import { fileURLToPath } from 'url'; import { describe, it, expect, beforeAll } from 'vitest'; import { retrieveHAR, retrieveMetrics, + retrieveConfig, cleanupTestDirectory, } from './helpers.js'; import { BrowserConfig } from '../src/browsers.js'; -import type { HarData, Metrics, HTTPHeader } from '../src/types.js'; +import type { HarData, Metrics, HTTPHeader, SavedConfig } from '../src/types.js'; const browsers = BrowserConfig.getBrowsers(); +// Resolve project root + CLI path regardless of execution cwd +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const PROJECT_ROOT = path.resolve(__dirname, '..'); +const CLI_PATH = path.resolve(PROJECT_ROOT, 'dist/src/cli.js'); + describe.each(browsers)('Basic Test: %s', browser => { let harJSON: HarData | null; let metrics: Metrics | null; @@ -21,14 +30,14 @@ describe.each(browsers)('Basic Test: %s', browser => { try { const args = [ 'node', - 'dist/src/cli.js', + CLI_PATH, '--url', 'https://www.example.com', '-b', browser, ]; - const output = spawnSync(args[0], args.slice(1)); + const output = spawnSync(args[0], args.slice(1), { cwd: PROJECT_ROOT }); const outputLogs = output.stdout.toString(); const match = outputLogs.match(/Test ID:(.*)/); if (match && match.length > 1) { @@ -92,7 +101,7 @@ describe.each(browsers)('Changed User Agent: %s', browser => { try { const args = [ 'node', - 'dist/src/cli.js', + CLI_PATH, '--url', 'https://www.example.com', '--userAgent', @@ -101,7 +110,7 @@ describe.each(browsers)('Changed User Agent: %s', browser => { browser, ]; - const output = spawnSync(args[0], args.slice(1)); + const output = spawnSync(args[0], args.slice(1), { cwd: PROJECT_ROOT }); const outputLogs = output.stdout.toString(); const match = outputLogs.match(/Test ID:(.*)/); if (match && match.length > 1) { @@ -147,7 +156,7 @@ describe.each(browsers)('Add to User Agent: %s', browser => { try { const args = [ 'node', - 'dist/src/cli.js', + CLI_PATH, '--url', 'https://www.example.com', '--agentExtra', @@ -156,7 +165,7 @@ describe.each(browsers)('Add to User Agent: %s', browser => { browser, ]; - const output = spawnSync(args[0], args.slice(1)); + const output = spawnSync(args[0], args.slice(1), { cwd: PROJECT_ROOT }); const outputLogs = output.stdout.toString(); const match = outputLogs.match(/Test ID:(.*)/); if (match && match.length > 1) { @@ -192,3 +201,49 @@ describe.each(browsers)('Add to User Agent: %s', browser => { } }); }); + +describe('Config with name and description', () => { + const testName = 'Homepage Performance Test'; + const testDescription = 'Testing example.com homepage load performance'; + let config: SavedConfig | null; + let testId: string | undefined; + + beforeAll(() => { + try { + const args = [ + 'node', + 'dist/src/cli.js', + '--url', + 'https://www.example.com', + '-b', + 'chrome', + '--name', + testName, + '--description', + testDescription, + ]; + + const output = spawnSync(args[0], args.slice(1)); + const outputLogs = output.stdout.toString(); + const match = outputLogs.match(/Test ID:(.*)/); + if (match && match.length > 1) { + testId = match[1].trim(); + } + config = retrieveConfig(testId); + } finally { + cleanupTestDirectory(testId); + } + }); + + it('runs the test and creates a test ID', async () => { + expect(testId).toBeTruthy(); + }); + + it('writes name to config.json', async () => { + expect(config?.name).toBe(testName); + }); + + it('writes description to config.json', async () => { + expect(config?.description).toBe(testDescription); + }); +}); diff --git a/packages/telescope/package.json b/packages/telescope/package.json index 86fce5be..03aacab9 100644 --- a/packages/telescope/package.json +++ b/packages/telescope/package.json @@ -67,7 +67,7 @@ "@playwright/test": "^1.59.1", "@types/adm-zip": "^0.5.7", "@types/ejs": "^3.1.5", - "@types/node": "^25.2.1", + "@types/node": "^25.6.0", "@typescript-eslint/eslint-plugin": "^8.54.0", "@typescript-eslint/parser": "^8.54.0", "@vitest/coverage-v8": "^4.0.6", diff --git a/packages/telescope/src/config.ts b/packages/telescope/src/config.ts index e7e16b58..8982034c 100644 --- a/packages/telescope/src/config.ts +++ b/packages/telescope/src/config.ts @@ -47,6 +47,8 @@ export function normalizeCLIConfig(options: CLIOptions): LaunchOptions { delayUsing: DEFAULT_OPTIONS.delayUsing, userAgent: options.userAgent, agentExtra: options.agentExtra, + name: options.name, + description: options.description, }; // Already-parsed JSON options: pass through directly diff --git a/packages/telescope/src/index.ts b/packages/telescope/src/index.ts index 067a73be..81284bd0 100644 --- a/packages/telescope/src/index.ts +++ b/packages/telescope/src/index.ts @@ -371,6 +371,10 @@ export default function browserAgent(): void { 'Append to the browser User Agent. Takes precedence over --userAgent', ), ) + .addOption(new Option('--name ', 'Test name')) + .addOption( + new Option('--description ', 'Test description'), + ) .addOption( new Option( '--device ', diff --git a/packages/telescope/src/testRunner.ts b/packages/telescope/src/testRunner.ts index f008674c..1658f4bb 100644 --- a/packages/telescope/src/testRunner.ts +++ b/packages/telescope/src/testRunner.ts @@ -684,6 +684,8 @@ class TestRunner { date: new Date().toUTCString(), options: this.options, browserConfig: this.selectedBrowser, + ...(this.options.name && { name: this.options.name }), + ...(this.options.description && { description: this.options.description }), }; writeFileSync( this.paths['results'] + '/config.json', diff --git a/packages/telescope/src/types.ts b/packages/telescope/src/types.ts index 4873fcf4..5733b046 100644 --- a/packages/telescope/src/types.ts +++ b/packages/telescope/src/types.ts @@ -198,6 +198,8 @@ export interface LaunchOptions { delay?: Record; delayUsing?: DelayMethod; device?: CustomDeviceDescriptor; + name?: string; + description?: string; } /** @@ -529,6 +531,8 @@ export interface SavedConfig { date: string; options: LaunchOptions; browserConfig: BrowserConfigOptions; + name?: string; + description?: string; } // ============================================================================ @@ -663,6 +667,8 @@ export interface CLIOptions { userAgent?: string; agentExtra?: string; device?: string; + name?: string; + description?: string; } // ============================================================================ From 8844f94895576a0e9bc3570845ee27be4d7c40be Mon Sep 17 00:00:00 2001 From: Mike K Date: Wed, 6 May 2026 13:35:06 -0400 Subject: [PATCH 2/5] Add local storage and repository pattern --- package-lock.json | 2 +- .../__tests__/localStorage.test.ts | 106 ++++++++++ .../__tests__/localTestStore.test.ts | 199 ++++++++++++++++++ packages/telescope-web/__tests__/mode.test.ts | 71 +++++++ packages/telescope-web/src/lib/config/mode.ts | 50 +++++ .../src/lib/repositories/d1TestStore.ts | 67 ++++++ .../src/lib/repositories/localTestStore.ts | 117 ++++++++++ .../src/lib/repositories/testStore.ts | 61 ++++++ .../src/lib/storage/cloudflareStorage.ts | 71 +++++++ .../telescope-web/src/lib/storage/factory.ts | 63 ++++++ .../src/lib/storage/localStorage.ts | 95 +++++++++ .../telescope-web/src/lib/storage/storage.ts | 39 ++++ packages/telescope-web/src/lib/types/tests.ts | 3 + 13 files changed, 943 insertions(+), 1 deletion(-) create mode 100644 packages/telescope-web/__tests__/localStorage.test.ts create mode 100644 packages/telescope-web/__tests__/localTestStore.test.ts create mode 100644 packages/telescope-web/__tests__/mode.test.ts create mode 100644 packages/telescope-web/src/lib/config/mode.ts create mode 100644 packages/telescope-web/src/lib/repositories/d1TestStore.ts create mode 100644 packages/telescope-web/src/lib/repositories/localTestStore.ts create mode 100644 packages/telescope-web/src/lib/repositories/testStore.ts create mode 100644 packages/telescope-web/src/lib/storage/cloudflareStorage.ts create mode 100644 packages/telescope-web/src/lib/storage/factory.ts create mode 100644 packages/telescope-web/src/lib/storage/localStorage.ts create mode 100644 packages/telescope-web/src/lib/storage/storage.ts diff --git a/package-lock.json b/package-lock.json index 69de6a56..c4aec248 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11279,7 +11279,7 @@ "@playwright/test": "^1.59.1", "@types/adm-zip": "^0.5.7", "@types/ejs": "^3.1.5", - "@types/node": "^25.2.1", + "@types/node": "^25.6.0", "@typescript-eslint/eslint-plugin": "^8.54.0", "@typescript-eslint/parser": "^8.54.0", "@vitest/coverage-v8": "^4.0.6", diff --git a/packages/telescope-web/__tests__/localStorage.test.ts b/packages/telescope-web/__tests__/localStorage.test.ts new file mode 100644 index 00000000..1cd7a176 --- /dev/null +++ b/packages/telescope-web/__tests__/localStorage.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + existsSync, + mkdirSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { LocalStorage } from '@/lib/storage/localStorage'; + +describe('LocalStorage', () => { + let dir: string; + let storage: LocalStorage; + const testId = '2026_01_15_10_30_00_test-uuid-1234'; + + beforeEach(() => { + dir = join(tmpdir(), `telescope-localstorage-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(dir, { recursive: true }); + storage = new LocalStorage(dir); + }); + + afterEach(() => { + if (existsSync(dir)) rmSync(dir, { recursive: true, force: true }); + }); + + it('returns null for missing files', async () => { + expect(await storage.get(testId, 'config.json')).toBeNull(); + expect(await storage.exists(testId, 'config.json')).toBe(false); + expect(await storage.list(testId)).toEqual([]); + }); + + it('writes and reads bytes', async () => { + const data = new TextEncoder().encode('hello world'); + await storage.put(testId, 'data.bin', data); + const read = await storage.get(testId, 'data.bin'); + expect(read).not.toBeNull(); + expect(new TextDecoder().decode(read!)).toBe('hello world'); + expect(await storage.exists(testId, 'data.bin')).toBe(true); + }); + + it('writes and reads JSON', async () => { + const obj = { url: 'https://example.com', count: 42 }; + await storage.put( + testId, + 'config.json', + new TextEncoder().encode(JSON.stringify(obj)), + ); + const read = await storage.getJSON(testId, 'config.json'); + expect(read).toEqual(obj); + }); + + it('returns null when JSON is invalid', async () => { + await storage.put( + testId, + 'broken.json', + new TextEncoder().encode('{ not json'), + ); + expect(await storage.getJSON(testId, 'broken.json')).toBeNull(); + }); + + it('lists files including nested filmstrip frames', async () => { + await storage.put(testId, 'config.json', new TextEncoder().encode('{}')); + await storage.put(testId, 'metrics.json', new TextEncoder().encode('{}')); + await storage.put( + testId, + 'filmstrip/frame_001.jpg', + new TextEncoder().encode('jpg'), + ); + await storage.put( + testId, + 'filmstrip/frame_002.jpg', + new TextEncoder().encode('jpg'), + ); + const list = (await storage.list(testId)).sort(); + expect(list).toEqual([ + 'config.json', + 'filmstrip/frame_001.jpg', + 'filmstrip/frame_002.jpg', + 'metrics.json', + ]); + }); + + it('creates parent directories on put', async () => { + await storage.put( + testId, + 'deep/nested/file.txt', + new TextEncoder().encode('x'), + ); + expect(existsSync(join(dir, testId, 'deep', 'nested', 'file.txt'))).toBe( + true, + ); + }); + + it('reads pre-existing files written outside the storage layer', async () => { + const target = join(dir, testId); + mkdirSync(target, { recursive: true }); + writeFileSync(join(target, 'pageload.har'), '{"har":true}'); + const json = await storage.getJSON<{ har: boolean }>( + testId, + 'pageload.har', + ); + expect(json).toEqual({ har: true }); + }); +}); diff --git a/packages/telescope-web/__tests__/localTestStore.test.ts b/packages/telescope-web/__tests__/localTestStore.test.ts new file mode 100644 index 00000000..4cd93bc1 --- /dev/null +++ b/packages/telescope-web/__tests__/localTestStore.test.ts @@ -0,0 +1,199 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { LocalTestStore } from '@/lib/repositories/localTestStore'; +import { ContentRating } from '@/lib/types/tests'; + +interface MinimalConfig { + url: string; + date: string; + options: { url: string; browser?: string }; + browserConfig: { engine: string }; + name?: string; + description?: string; +} + +function writeTest( + baseDir: string, + testId: string, + config: MinimalConfig, +): void { + const dir = join(baseDir, testId); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'config.json'), JSON.stringify(config)); +} + +describe('LocalTestStore', () => { + let dir: string; + let store: LocalTestStore; + + beforeEach(() => { + dir = join( + tmpdir(), + `telescope-localteststore-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + mkdirSync(dir, { recursive: true }); + store = new LocalTestStore(dir); + }); + + afterEach(() => { + if (existsSync(dir)) rmSync(dir, { recursive: true, force: true }); + }); + + it('returns empty array when results directory has no tests', async () => { + expect(await store.getAll(false)).toEqual([]); + }); + + it('returns empty array when results directory does not exist', async () => { + rmSync(dir, { recursive: true, force: true }); + expect(await store.getAll(false)).toEqual([]); + }); + + it('reads tests from config.json with name and description', async () => { + writeTest(dir, '2026_01_15_10_30_00_test-uuid-1', { + url: 'https://example.com', + date: 'Wed, 15 Jan 2026 10:30:00 GMT', + options: { url: 'https://example.com', browser: 'chrome' }, + browserConfig: { engine: 'chromium' }, + name: 'My Test', + description: 'A description', + }); + + const tests = await store.getAll(false); + expect(tests).toHaveLength(1); + expect(tests[0]).toMatchObject({ + test_id: '2026_01_15_10_30_00_test-uuid-1', + url: 'https://example.com', + browser: 'chrome', + name: 'My Test', + description: 'A description', + content_rating: ContentRating.SAFE, + }); + expect(tests[0].test_date).toBeGreaterThan(0); + }); + + it('treats name and description as null when missing', async () => { + writeTest(dir, '2026_01_15_10_30_00_test-uuid-2', { + url: 'https://example.com', + date: 'Wed, 15 Jan 2026 10:30:00 GMT', + options: { url: 'https://example.com', browser: 'firefox' }, + browserConfig: { engine: 'firefox' }, + }); + + const test = await store.getById('2026_01_15_10_30_00_test-uuid-2'); + expect(test).not.toBeNull(); + expect(test!.name).toBeNull(); + expect(test!.description).toBeNull(); + expect(test!.browser).toBe('firefox'); + }); + + it('falls back to browserConfig.engine when options.browser is missing', async () => { + writeTest(dir, '2026_01_15_10_30_00_test-uuid-3', { + url: 'https://example.com', + date: 'Wed, 15 Jan 2026 10:30:00 GMT', + options: { url: 'https://example.com' }, + browserConfig: { engine: 'webkit' }, + }); + + const test = await store.getById('2026_01_15_10_30_00_test-uuid-3'); + expect(test!.browser).toBe('webkit'); + }); + + it('sorts tests newest-first by test_date', async () => { + writeTest(dir, 'a', { + url: 'https://a.com', + date: 'Wed, 01 Jan 2025 00:00:00 GMT', + options: { url: 'https://a.com' }, + browserConfig: { engine: 'chromium' }, + }); + writeTest(dir, 'b', { + url: 'https://b.com', + date: 'Wed, 01 Jan 2026 00:00:00 GMT', + options: { url: 'https://b.com' }, + browserConfig: { engine: 'chromium' }, + }); + writeTest(dir, 'c', { + url: 'https://c.com', + date: 'Wed, 01 Jul 2025 00:00:00 GMT', + options: { url: 'https://c.com' }, + browserConfig: { engine: 'chromium' }, + }); + + const tests = await store.getAll(false); + expect(tests.map(t => t.test_id)).toEqual(['b', 'c', 'a']); + }); + + it('skips folders without a config.json', async () => { + mkdirSync(join(dir, 'no-config-here'), { recursive: true }); + writeTest(dir, 'has-config', { + url: 'https://example.com', + date: 'Wed, 15 Jan 2026 10:30:00 GMT', + options: { url: 'https://example.com' }, + browserConfig: { engine: 'chromium' }, + }); + const tests = await store.getAll(false); + expect(tests).toHaveLength(1); + expect(tests[0].test_id).toBe('has-config'); + }); + + it('skips folders with malformed config.json', async () => { + mkdirSync(join(dir, 'bad'), { recursive: true }); + writeFileSync(join(dir, 'bad', 'config.json'), '{ not json'); + expect(await store.getAll(false)).toEqual([]); + expect(await store.getById('bad')).toBeNull(); + }); + + it('returns null from getById for non-existent test', async () => { + expect(await store.getById('does-not-exist')).toBeNull(); + }); + + it('findByTestId returns SAFE rating for existing folder', async () => { + writeTest(dir, 'present', { + url: 'https://example.com', + date: 'Wed, 15 Jan 2026 10:30:00 GMT', + options: { url: 'https://example.com' }, + browserConfig: { engine: 'chromium' }, + }); + expect(await store.findByTestId('present')).toEqual({ + testId: 'present', + contentRating: ContentRating.SAFE, + }); + expect(await store.findByTestId('absent')).toBeNull(); + }); + + it('findByZipKey always returns null in local mode', async () => { + expect(await store.findByZipKey('any-hash')).toBeNull(); + }); + + it('getRating returns SAFE for existing tests, null for missing', async () => { + writeTest(dir, 'present', { + url: 'https://example.com', + date: 'Wed, 15 Jan 2026 10:30:00 GMT', + options: { url: 'https://example.com' }, + browserConfig: { engine: 'chromium' }, + }); + expect(await store.getRating('present')).toEqual({ + rating: ContentRating.SAFE, + url: 'https://example.com', + }); + expect(await store.getRating('absent')).toBeNull(); + }); + + it('create() and updateContentRating() are no-ops', async () => { + await expect( + store.create({ + testId: 'x', + zipKey: 'k', + source: 'upload' as never, + url: 'https://x.com', + testDate: 0, + browser: 'chrome', + }), + ).resolves.toBeUndefined(); + await expect( + store.updateContentRating('x', ContentRating.SAFE), + ).resolves.toBeUndefined(); + }); +}); diff --git a/packages/telescope-web/__tests__/mode.test.ts b/packages/telescope-web/__tests__/mode.test.ts new file mode 100644 index 00000000..bbe2c12a --- /dev/null +++ b/packages/telescope-web/__tests__/mode.test.ts @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + getMode, + getResultsDir, + isCloudflareMode, + isLocalMode, +} from '@/lib/config/mode'; + +describe('getMode', () => { + let original: string | undefined; + + beforeEach(() => { + original = process.env.TELESCOPE_MODE; + }); + + afterEach(() => { + if (original === undefined) { + delete process.env.TELESCOPE_MODE; + } else { + process.env.TELESCOPE_MODE = original; + } + }); + + it('defaults to cloudflare when TELESCOPE_MODE is unset', () => { + delete process.env.TELESCOPE_MODE; + expect(getMode()).toBe('cloudflare'); + expect(isCloudflareMode()).toBe(true); + expect(isLocalMode()).toBe(false); + }); + + it('returns local when TELESCOPE_MODE=local', () => { + process.env.TELESCOPE_MODE = 'local'; + expect(getMode()).toBe('local'); + expect(isLocalMode()).toBe(true); + expect(isCloudflareMode()).toBe(false); + }); + + it('returns cloudflare for any non-local value', () => { + process.env.TELESCOPE_MODE = 'production'; + expect(getMode()).toBe('cloudflare'); + process.env.TELESCOPE_MODE = ''; + expect(getMode()).toBe('cloudflare'); + }); +}); + +describe('getResultsDir', () => { + let original: string | undefined; + + beforeEach(() => { + original = process.env.RESULTS_DIR; + }); + + afterEach(() => { + if (original === undefined) { + delete process.env.RESULTS_DIR; + } else { + process.env.RESULTS_DIR = original; + } + }); + + it('defaults to ./results when RESULTS_DIR is unset', () => { + delete process.env.RESULTS_DIR; + expect(getResultsDir()).toBe('./results'); + }); + + it('reads RESULTS_DIR when set', () => { + process.env.RESULTS_DIR = '/var/telescope/results'; + expect(getResultsDir()).toBe('/var/telescope/results'); + }); +}); diff --git a/packages/telescope-web/src/lib/config/mode.ts b/packages/telescope-web/src/lib/config/mode.ts new file mode 100644 index 00000000..7002bcff --- /dev/null +++ b/packages/telescope-web/src/lib/config/mode.ts @@ -0,0 +1,50 @@ +/** + * Runtime mode detection for telescope-web. + * + * Mode is selected via the `TELESCOPE_MODE` environment variable: + * - `cloudflare` (default): uses D1, R2, and Workers AI bindings + * - `local`: reads/writes from a local results directory on disk + */ + +export type TelescopeMode = 'cloudflare' | 'local'; + +/** + * Returns the current runtime mode. Defaults to `cloudflare` for + * backward compatibility with the existing deployment. + */ +export function getMode(): TelescopeMode { + // Astro exposes server env vars on import.meta.env in SSR. + // Fall back to process.env for plain Node contexts (tests, scripts). + const fromImport = + typeof import.meta !== 'undefined' && + (import.meta.env?.TELESCOPE_MODE as string | undefined); + const fromProcess = + typeof process !== 'undefined' + ? (process.env?.TELESCOPE_MODE as string | undefined) + : undefined; + const raw = fromImport || fromProcess; + return raw === 'local' ? 'local' : 'cloudflare'; +} + +export function isLocalMode(): boolean { + return getMode() === 'local'; +} + +export function isCloudflareMode(): boolean { + return getMode() === 'cloudflare'; +} + +/** + * Local results directory. Only used in local mode. + * Defaults to `./results` relative to the process cwd. + */ +export function getResultsDir(): string { + const fromImport = + typeof import.meta !== 'undefined' && + (import.meta.env?.RESULTS_DIR as string | undefined); + const fromProcess = + typeof process !== 'undefined' + ? (process.env?.RESULTS_DIR as string | undefined) + : undefined; + return fromImport || fromProcess || './results'; +} diff --git a/packages/telescope-web/src/lib/repositories/d1TestStore.ts b/packages/telescope-web/src/lib/repositories/d1TestStore.ts new file mode 100644 index 00000000..239975fe --- /dev/null +++ b/packages/telescope-web/src/lib/repositories/d1TestStore.ts @@ -0,0 +1,67 @@ +/** + * D1TestStore — Prisma/D1-backed implementation of ITestStore. + * + * Wraps the existing testRepository.ts functions. Used when + * TELESCOPE_MODE=cloudflare (default). + * + * Loaded via dynamic import from the storage factory so the Prisma + * runtime is not pulled into the local Node bundle. + */ + +import type { PrismaClient } from '@/generated/prisma/client'; +import type { TestConfig, Tests } from '@/lib/types/tests'; +import { ContentRating } from '@/lib/types/tests'; + +import { + createTest, + findTestIdByZipKey, + getAllTests, + getTestById, + getTestRating, + updateContentRating, +} from './testRepository.js'; + +import type { ITestStore } from './testStore.js'; + +export class D1TestStore implements ITestStore { + constructor(private prisma: PrismaClient) {} + + async getAll(aiEnabled: boolean): Promise { + return getAllTests(this.prisma, aiEnabled); + } + + async getById(testId: string): Promise { + return getTestById(this.prisma, testId); + } + + async getRating( + testId: string, + ): Promise<{ rating: string; url: string } | null> { + return getTestRating(this.prisma, testId); + } + + async create(testConfig: TestConfig): Promise { + await createTest(this.prisma, testConfig); + } + + async findByZipKey( + zipKey: string, + ): Promise<{ testId: string; contentRating: string } | null> { + return findTestIdByZipKey(this.prisma, zipKey); + } + + async findByTestId( + testId: string, + ): Promise<{ testId: string; contentRating: string } | null> { + const row = await getTestById(this.prisma, testId); + if (!row) return null; + return { testId: row.test_id, contentRating: row.content_rating }; + } + + async updateContentRating( + testId: string, + rating: ContentRating, + ): Promise { + await updateContentRating(this.prisma, testId, rating); + } +} diff --git a/packages/telescope-web/src/lib/repositories/localTestStore.ts b/packages/telescope-web/src/lib/repositories/localTestStore.ts new file mode 100644 index 00000000..a6c5a9e6 --- /dev/null +++ b/packages/telescope-web/src/lib/repositories/localTestStore.ts @@ -0,0 +1,117 @@ +/** + * LocalTestStore — filesystem-backed implementation of ITestStore. + * + * Test metadata is derived from `config.json` inside each test folder + * under RESULTS_DIR. Name and description are read from `config.json` + * (added by the telescope CLI via --name and --description). + * + * In local mode there is no persistent metadata DB: + * - `create()` is a no-op (the storage layer writes config.json directly) + * - `findByZipKey()` always returns null (dedup is by folder name) + * - `updateContentRating()` is a no-op (no AI rating in local mode) + */ + +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +import type { ConfigJson, TestConfig, Tests } from '@/lib/types/tests'; +import { ContentRating } from '@/lib/types/tests'; + +import type { ITestStore } from './testStore.js'; +import { getResultsDir } from '@/lib/config/mode'; + +export class LocalTestStore implements ITestStore { + private resultsDir: string; + + constructor(resultsDir?: string) { + this.resultsDir = resultsDir ?? getResultsDir(); + } + + async getAll(_aiEnabled: boolean): Promise { + if (!existsSync(this.resultsDir)) return []; + const tests: Tests[] = []; + for (const entry of readdirSync(this.resultsDir)) { + const full = join(this.resultsDir, entry); + try { + if (!statSync(full).isDirectory()) continue; + } catch { + continue; + } + const test = this.readTest(entry); + if (test) tests.push(test); + } + // Sort newest-first by test_date (unix seconds) + tests.sort((a, b) => b.test_date - a.test_date); + return tests; + } + + async getById(testId: string): Promise { + return this.readTest(testId); + } + + async getRating( + testId: string, + ): Promise<{ rating: string; url: string } | null> { + const test = this.readTest(testId); + if (!test) return null; + return { rating: ContentRating.SAFE, url: test.url }; + } + + async create(_testConfig: TestConfig): Promise { + // No-op. Test metadata is persisted by writing config.json via the + // storage layer during upload. + } + + async findByZipKey( + _zipKey: string, + ): Promise<{ testId: string; contentRating: string } | null> { + // Local mode dedups by folder name, not content hash. + return null; + } + + async findByTestId( + testId: string, + ): Promise<{ testId: string; contentRating: string } | null> { + const dir = join(this.resultsDir, testId); + if (!existsSync(dir)) return null; + return { testId, contentRating: ContentRating.SAFE }; + } + + async updateContentRating( + _testId: string, + _rating: ContentRating, + ): Promise { + // No-op. AI rating is disabled in local mode. + } + + /** + * Read and parse `config.json` for a single test folder. + * Returns `null` if the folder, file, or JSON is invalid. + */ + private readTest(testId: string): Tests | null { + const configPath = join(this.resultsDir, testId, 'config.json'); + if (!existsSync(configPath)) return null; + let parsed: ConfigJson; + try { + parsed = JSON.parse(readFileSync(configPath, 'utf-8')) as ConfigJson; + } catch (error) { + console.error(`[LocalTestStore] failed to parse ${configPath}`, error); + return null; + } + const dateMs = Date.parse(parsed.date); + const test_date = Number.isFinite(dateMs) ? Math.floor(dateMs / 1000) : 0; + const browser = + parsed.options?.browser || + parsed.browserConfig?.engine || + 'unknown'; + return { + test_id: testId, + url: parsed.url ?? '', + test_date, + browser, + name: parsed.name ?? null, + description: parsed.description ?? null, + content_rating: ContentRating.SAFE, + }; + } +} diff --git a/packages/telescope-web/src/lib/repositories/testStore.ts b/packages/telescope-web/src/lib/repositories/testStore.ts new file mode 100644 index 00000000..8c5cb33e --- /dev/null +++ b/packages/telescope-web/src/lib/repositories/testStore.ts @@ -0,0 +1,61 @@ +/** + * Test metadata store abstraction. + * + * Two implementations exist: + * - D1TestStore: backed by Cloudflare D1 via Prisma + * - LocalTestStore: scans the local results directory and reads + * `config.json` from each test folder + * + * Use `getTestStore()` from `@/lib/storage/factory` to obtain the + * right instance based on TELESCOPE_MODE. + */ + +import type { TestConfig, Tests } from '@/lib/types/tests'; +import type { ContentRating } from '@/lib/types/tests'; + +export interface ITestStore { + /** + * Return all visible tests, ordered newest first. + * Implementations may filter unsafe tests when AI rating is enabled. + */ + getAll(aiEnabled: boolean): Promise; + + /** + * Return a single test by its testId, or `null` if not found. + */ + getById(testId: string): Promise; + + /** + * Return content rating + url for a single test, or `null` if not found. + */ + getRating(testId: string): Promise<{ rating: string; url: string } | null>; + + /** + * Persist test metadata. In cloudflare mode this writes a D1 row. + * In local mode this is a no-op (metadata lives in `config.json`, + * which is written via the storage layer during upload). + */ + create(testConfig: TestConfig): Promise; + + /** + * Cloudflare-only duplicate check by SHA-256 hash of zip contents. + * Local mode never calls this; it dedups by folder name via + * `findByTestId`. + */ + findByZipKey( + zipKey: string, + ): Promise<{ testId: string; contentRating: string } | null>; + + /** + * Local-mode duplicate check — returns the testId if a test folder + * with that id already exists. Cloudflare mode also supports this. + */ + findByTestId( + testId: string, + ): Promise<{ testId: string; contentRating: string } | null>; + + /** + * Update the AI content rating for a test. No-op in local mode. + */ + updateContentRating(testId: string, rating: ContentRating): Promise; +} diff --git a/packages/telescope-web/src/lib/storage/cloudflareStorage.ts b/packages/telescope-web/src/lib/storage/cloudflareStorage.ts new file mode 100644 index 00000000..3f7a3e3b --- /dev/null +++ b/packages/telescope-web/src/lib/storage/cloudflareStorage.ts @@ -0,0 +1,71 @@ +/** + * CloudflareStorage — R2-backed implementation of IStorage. + * + * Wraps `env.RESULTS_BUCKET` from the Cloudflare Workers runtime. + * Used when TELESCOPE_MODE=cloudflare (default). + * + * IMPORTANT: This module imports from `cloudflare:workers`, which only + * resolves inside the Workers runtime. It must be loaded via dynamic + * import from the storage factory so it never executes in local mode. + */ + +import { env } from 'cloudflare:workers'; + +import type { IStorage } from './storage.js'; + +export class CloudflareStorage implements IStorage { + private get bucket() { + const bucket = env.RESULTS_BUCKET; + if (!bucket) { + throw new Error( + 'RESULTS_BUCKET binding is not configured for this environment.', + ); + } + return bucket; + } + + private key(testId: string, filename: string): string { + return `${testId}/${filename}`; + } + + async get(testId: string, filename: string): Promise { + const obj = await this.bucket.get(this.key(testId, filename)); + if (!obj) return null; + return new Uint8Array(await obj.arrayBuffer()); + } + + async getJSON(testId: string, filename: string): Promise { + const obj = await this.bucket.get(this.key(testId, filename)); + if (!obj) return null; + try { + return await obj.json(); + } catch (error) { + console.error( + `[CloudflareStorage] JSON parse error: ${this.key(testId, filename)}`, + error, + ); + return null; + } + } + + async put( + testId: string, + filename: string, + data: Uint8Array, + ): Promise { + await this.bucket.put(this.key(testId, filename), data); + } + + async list(testId: string): Promise { + const prefix = `${testId}/`; + const listed = await this.bucket.list({ prefix }); + return listed.objects + .map(obj => obj.key.slice(prefix.length)) + .filter(Boolean); + } + + async exists(testId: string, filename: string): Promise { + const obj = await this.bucket.head(this.key(testId, filename)); + return obj !== null; + } +} diff --git a/packages/telescope-web/src/lib/storage/factory.ts b/packages/telescope-web/src/lib/storage/factory.ts new file mode 100644 index 00000000..34cdc86f --- /dev/null +++ b/packages/telescope-web/src/lib/storage/factory.ts @@ -0,0 +1,63 @@ +/** + * Factories for storage and test-store providers. + * + * Uses dynamic imports so that Cloudflare-only modules + * (`cloudflare:workers`, Prisma D1 adapter) are never evaluated when + * running in local mode, and `node:fs` is never bundled into the + * Workers runtime when running in cloudflare mode. + * + * Each provider is cached per-process; Cloudflare Workers get a fresh + * isolate per request anyway, so caching here is safe in both modes. + */ + +import type { PrismaClient } from '@/generated/prisma/client'; +import { getMode } from '@/lib/config/mode'; + +import type { ITestStore } from '@/lib/repositories/testStore'; +import type { IStorage } from './storage.js'; + +let cachedStorage: IStorage | null = null; + +export async function getStorage(): Promise { + if (cachedStorage) return cachedStorage; + if (getMode() === 'local') { + const { LocalStorage } = await import('./localStorage.js'); + cachedStorage = new LocalStorage(); + } else { + const { CloudflareStorage } = await import('./cloudflareStorage.js'); + cachedStorage = new CloudflareStorage(); + } + return cachedStorage; +} + +/** + * Build the test-store for the current mode. + * + * In cloudflare mode, the caller must provide an active PrismaClient + * (created in middleware). In local mode the prisma argument is ignored. + */ +export async function getTestStore( + prisma?: PrismaClient | null, +): Promise { + if (getMode() === 'local') { + const { LocalTestStore } = await import( + '@/lib/repositories/localTestStore' + ); + return new LocalTestStore(); + } + if (!prisma) { + throw new Error( + 'D1TestStore requires a PrismaClient. Did middleware run?', + ); + } + const { D1TestStore } = await import('@/lib/repositories/d1TestStore'); + return new D1TestStore(prisma); +} + +/** + * For tests / scripts: clear the cached storage instance so the next + * call to `getStorage()` re-resolves the implementation. + */ +export function resetStorageCache(): void { + cachedStorage = null; +} diff --git a/packages/telescope-web/src/lib/storage/localStorage.ts b/packages/telescope-web/src/lib/storage/localStorage.ts new file mode 100644 index 00000000..5ba40018 --- /dev/null +++ b/packages/telescope-web/src/lib/storage/localStorage.ts @@ -0,0 +1,95 @@ +/** + * LocalStorage — filesystem-backed implementation of IStorage. + * + * Files live under `${RESULTS_DIR}/${testId}/${filename}`. + * Used when TELESCOPE_MODE=local. + */ + +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { dirname, join, relative, sep } from 'node:path'; + +import type { IStorage } from './storage.js'; +import { getResultsDir } from '@/lib/config/mode'; + +export class LocalStorage implements IStorage { + private resultsDir: string; + + constructor(resultsDir?: string) { + this.resultsDir = resultsDir ?? getResultsDir(); + } + + private filePath(testId: string, filename: string): string { + return join(this.resultsDir, testId, filename); + } + + async get(testId: string, filename: string): Promise { + const path = this.filePath(testId, filename); + if (!existsSync(path)) return null; + try { + return new Uint8Array(readFileSync(path)); + } catch (error) { + console.error(`[LocalStorage] read error: ${path}`, error); + return null; + } + } + + async getJSON(testId: string, filename: string): Promise { + const bytes = await this.get(testId, filename); + if (!bytes) return null; + try { + return JSON.parse(new TextDecoder('utf-8').decode(bytes)) as T; + } catch (error) { + console.error( + `[LocalStorage] JSON parse error: ${this.filePath(testId, filename)}`, + error, + ); + return null; + } + } + + async put( + testId: string, + filename: string, + data: Uint8Array, + ): Promise { + const path = this.filePath(testId, filename); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, data); + } + + async list(testId: string): Promise { + const dir = join(this.resultsDir, testId); + if (!existsSync(dir)) return []; + return walkDir(dir, dir); + } + + async exists(testId: string, filename: string): Promise { + return existsSync(this.filePath(testId, filename)); + } +} + +/** + * Recursively list all files under `dir`, returning paths relative to `base`. + * Uses POSIX separators in the returned paths to match the R2 key format. + */ +function walkDir(dir: string, base: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + const stats = statSync(full); + if (stats.isDirectory()) { + out.push(...walkDir(full, base)); + } else if (stats.isFile()) { + const rel = relative(base, full); + out.push(rel.split(sep).join('/')); + } + } + return out; +} diff --git a/packages/telescope-web/src/lib/storage/storage.ts b/packages/telescope-web/src/lib/storage/storage.ts new file mode 100644 index 00000000..edb65c28 --- /dev/null +++ b/packages/telescope-web/src/lib/storage/storage.ts @@ -0,0 +1,39 @@ +/** + * Storage abstraction for test result files. + * + * Two implementations exist: + * - CloudflareStorage: backed by an R2 bucket (env.RESULTS_BUCKET) + * - LocalStorage: backed by the local filesystem (RESULTS_DIR) + * + * Use `getStorage()` from `./factory.js` to obtain the right instance + * based on TELESCOPE_MODE. + */ + +export interface IStorage { + /** + * Read a file as raw bytes. Returns `null` if the file does not exist. + */ + get(testId: string, filename: string): Promise; + + /** + * Read a file and parse as JSON. Returns `null` if missing or invalid. + */ + getJSON(testId: string, filename: string): Promise; + + /** + * Write bytes to a file. Creates parent directories as needed. + */ + put(testId: string, filename: string, data: Uint8Array): Promise; + + /** + * List all files belonging to a test. Returns relative paths + * (without the testId prefix). Returns an empty array if the + * test does not exist. + */ + list(testId: string): Promise; + + /** + * Returns true if the file exists in storage. + */ + exists(testId: string, filename: string): Promise; +} diff --git a/packages/telescope-web/src/lib/types/tests.ts b/packages/telescope-web/src/lib/types/tests.ts index 6f10c059..3b1df0cb 100644 --- a/packages/telescope-web/src/lib/types/tests.ts +++ b/packages/telescope-web/src/lib/types/tests.ts @@ -23,6 +23,9 @@ export enum ContentRating { export interface ConfigJson { url: string; date: string; + // Optional metadata written by the telescope CLI via --name / --description + name?: string; + description?: string; options: { url: string; browser?: string; From cdc138c770f88c238c3e7db8f889b68caf2dfc1e Mon Sep 17 00:00:00 2001 From: Mike K Date: Wed, 6 May 2026 16:24:49 -0400 Subject: [PATCH 3/5] Update telescope-web adapter & config for dual-mode --- package-lock.json | 183 ++++++++++++++++++++++- packages/telescope-web/astro.config.mjs | 69 ++++++--- packages/telescope-web/package.json | 14 +- packages/telescope-web/src/env.d.ts | 14 +- packages/telescope-web/src/middleware.ts | 36 ++++- 5 files changed, 281 insertions(+), 35 deletions(-) diff --git a/package-lock.json b/package-lock.json index c4aec248..4d7ec872 100644 --- a/package-lock.json +++ b/package-lock.json @@ -84,6 +84,20 @@ "vfile": "^6.0.3" } }, + "node_modules/@astrojs/node": { + "version": "10.0.6", + "resolved": "https://registry.npmjs.org/@astrojs/node/-/node-10.0.6.tgz", + "integrity": "sha512-e8JmaP4sGxqvdei14kmBzhAqgd5/L5MTExW3Hks5DOt9LDvGzlsFZwnXVXzWPVjW/PErl7t9uLg7xWhCqfkSrA==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.9.0", + "send": "^1.2.1", + "server-destroy": "^1.0.1" + }, + "peerDependencies": { + "astro": "^6.0.0" + } + }, "node_modules/@astrojs/prism": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.0.1.tgz", @@ -5151,6 +5165,15 @@ "node": ">=0.10" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -5321,6 +5344,12 @@ "node": ">=4" } }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/effect": { "version": "3.20.0", "resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz", @@ -5370,6 +5399,15 @@ "node": ">=14" } }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/enquirer": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", @@ -5474,6 +5512,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -5731,6 +5775,15 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -6069,6 +6122,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fs-extra": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", @@ -6484,6 +6546,26 @@ "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", "license": "BSD-2-Clause" }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/http-status-codes": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", @@ -6555,6 +6637,12 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/iron-webcrypto": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", @@ -7880,6 +7968,31 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/miniflare": { "version": "4.20260424.0", "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260424.0.tgz", @@ -8170,6 +8283,18 @@ "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", "license": "MIT" }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/oniguruma-parser": { "version": "0.12.2", "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", @@ -8751,6 +8876,15 @@ "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", "license": "MIT" }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/rc9": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz", @@ -9242,12 +9376,44 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/seq-queue": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==", "devOptional": true }, + "node_modules/server-destroy": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/server-destroy/-/server-destroy-1.0.1.tgz", + "integrity": "sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==", + "license": "ISC" + }, "node_modules/set-cookie-parser": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", @@ -9255,6 +9421,12 @@ "devOptional": true, "license": "MIT" }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -9445,7 +9617,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -9689,6 +9860,15 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tough-cookie": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", @@ -11300,6 +11480,7 @@ "version": "0.0.1", "dependencies": { "@astrojs/cloudflare": "^13.1.10", + "@astrojs/node": "^10.0.6", "@astrojs/react": "^5.0.1", "@phosphor-icons/react": "^2.1.10", "@prisma/adapter-d1": "^7.5.0", diff --git a/packages/telescope-web/astro.config.mjs b/packages/telescope-web/astro.config.mjs index 870f9139..2ac60d7d 100644 --- a/packages/telescope-web/astro.config.mjs +++ b/packages/telescope-web/astro.config.mjs @@ -2,32 +2,63 @@ import { defineConfig } from 'astro/config'; import cloudflare from '@astrojs/cloudflare'; +import node from '@astrojs/node'; import react from '@astrojs/react'; +// TELESCOPE_MODE selects the runtime target: +// - 'cloudflare' (default): builds for Cloudflare Workers (D1/R2/AI) +// - 'local': builds for standalone Node.js (filesystem) +const mode = process.env.TELESCOPE_MODE === 'local' ? 'local' : 'cloudflare'; + +const adapter = + mode === 'local' + ? node({ mode: 'standalone' }) + : cloudflare({ + imageService: 'cloudflare', + }); + // https://astro.build/config export default defineConfig({ output: 'server', - adapter: cloudflare({ - imageService: 'cloudflare', - }), + adapter, vite: { - plugins: [ - { - name: 'pre-compile-deps', - configEnvironment(name) { - if (name !== 'client') { - return { - optimizeDeps: { - // wasm-compiler-edge must never be bundled by esbuild — it contains a ?module import that only workerd can handle natively - // https://docs.astro.build/en/guides/integrations-guide/cloudflare/#cloudflare-module-imports - // https://developers.cloudflare.com/workers/wrangler/bundling/#including-non-javascript-modules - exclude: ['@prisma/client/runtime/wasm-compiler-edge'], - }, - }; + // In local mode, exclude Cloudflare-only modules from the bundle. + // They are reachable only via dynamic import in cloudflare-mode code paths. + build: + mode === 'local' + ? { + rollupOptions: { + external: [ + 'cloudflare:workers', + '@prisma/adapter-d1', + '@/generated/prisma/client', + /^@\/generated\/prisma\b/, + /^.*\/cloudflareStorage(?:\.js)?$/, + /^.*\/d1TestStore(?:\.js)?$/, + ], + }, } - }, - }, - ], + : undefined, + plugins: + mode === 'cloudflare' + ? [ + { + name: 'pre-compile-deps', + configEnvironment(name) { + if (name !== 'client') { + return { + optimizeDeps: { + // wasm-compiler-edge must never be bundled by esbuild — it contains a ?module import that only workerd can handle natively + // https://docs.astro.build/en/guides/integrations-guide/cloudflare/#cloudflare-module-imports + // https://developers.cloudflare.com/workers/wrangler/bundling/#including-non-javascript-modules + exclude: ['@prisma/client/runtime/wasm-compiler-edge'], + }, + }; + } + }, + }, + ] + : [], }, integrations: [react()], }); diff --git a/packages/telescope-web/package.json b/packages/telescope-web/package.json index b344b955..84ebc037 100644 --- a/packages/telescope-web/package.json +++ b/packages/telescope-web/package.json @@ -5,12 +5,15 @@ "version": "0.0.1", "description": "Telescope web UI — Astro + Cloudflare Workers app for viewing test results. Not published to npm.", "scripts": { - "dev": "cross-env CLOUDFLARE_ENV=development astro dev", + "dev": "cross-env CLOUDFLARE_ENV=development TELESCOPE_MODE=cloudflare astro dev", + "dev:local": "cross-env TELESCOPE_MODE=local astro dev", "dev:setup": "./scripts/dev-setup.sh", "dev:clean": "rm -rf .wrangler .env dist node_modules", "cf-typegen": "wrangler types", - "build:development": "cross-env CLOUDFLARE_ENV=development astro build", - "build:staging": "cross-env CLOUDFLARE_ENV=staging astro build", + "build:development": "cross-env CLOUDFLARE_ENV=development TELESCOPE_MODE=cloudflare astro build", + "build:staging": "cross-env CLOUDFLARE_ENV=staging TELESCOPE_MODE=cloudflare astro build", + "build:local": "cross-env TELESCOPE_MODE=local astro build", + "start:local": "cross-env TELESCOPE_MODE=local node ./dist/server/entry.mjs", "preview": "astro preview", "migrate:development": "npx wrangler d1 migrations apply telescope-db-development --local --env development", "migrate:staging": "npx wrangler d1 migrations apply telescope-db-staging --remote --env staging", @@ -22,6 +25,7 @@ }, "dependencies": { "@astrojs/cloudflare": "^13.1.10", + "@astrojs/node": "^10.0.6", "@astrojs/react": "^5.0.1", "@phosphor-icons/react": "^2.1.10", "@prisma/adapter-d1": "^7.5.0", @@ -31,8 +35,8 @@ "fflate": "^0.8.2", "react": "^19.2.4", "react-dom": "^19.2.4", - "zod": "^4.3.6", - "vitest": "^4.0.6" + "vitest": "^4.0.6", + "zod": "^4.3.6" }, "devDependencies": { "@types/node": "^24.12.0", diff --git a/packages/telescope-web/src/env.d.ts b/packages/telescope-web/src/env.d.ts index 8e4d45e4..9c5abb56 100644 --- a/packages/telescope-web/src/env.d.ts +++ b/packages/telescope-web/src/env.d.ts @@ -1,9 +1,19 @@ /// -type Runtime = import('@astrojs/cloudflare').Runtime; +// In cloudflare mode, App.Locals extends the Workers Runtime (env, cf, ctx). +// In local mode, those bindings are absent and `prisma` is null. +// We declare the union here so both targets type-check the same source. +type CloudflareRuntime = import('@astrojs/cloudflare').Runtime; declare namespace App { - interface Locals extends Runtime { + interface Locals extends Partial { + /** Cloudflare D1 client. `null` in local mode. */ prisma: import('@/generated/prisma/client').PrismaClient | null; + /** Active storage provider (filesystem or R2), set by middleware. */ + storage: import('@/lib/storage/storage').IStorage; + /** Active test metadata store (filesystem or D1), set by middleware. */ + testStore: import('@/lib/repositories/testStore').ITestStore; + /** Current runtime mode. */ + mode: import('@/lib/config/mode').TelescopeMode; } } diff --git a/packages/telescope-web/src/middleware.ts b/packages/telescope-web/src/middleware.ts index af00e26d..27b65b3a 100644 --- a/packages/telescope-web/src/middleware.ts +++ b/packages/telescope-web/src/middleware.ts @@ -1,16 +1,36 @@ import { defineMiddleware } from 'astro:middleware'; -import { PrismaD1 } from '@prisma/adapter-d1'; -import { PrismaClient } from '@/generated/prisma/client'; -import { env } from 'cloudflare:workers'; +import { getMode } from '@/lib/config/mode'; +import { getStorage, getTestStore } from '@/lib/storage/factory'; +import type { PrismaClient } from '@/generated/prisma/client'; + +// Cached Prisma client — only initialised in cloudflare mode. let prisma: PrismaClient | null = null; export const onRequest = defineMiddleware(async (context, next) => { - const d1_database = env.TELESCOPE_DB; - if (!prisma) { - const adapter = new PrismaD1(d1_database); - prisma = new PrismaClient({ adapter }); + const mode = getMode(); + context.locals.mode = mode; + + if (mode === 'cloudflare') { + if (!prisma) { + // Dynamic import keeps Cloudflare-specific modules out of the + // local Node bundle. + const [{ env }, { PrismaD1 }, { PrismaClient: PrismaCtor }] = + await Promise.all([ + import('cloudflare:workers'), + import('@prisma/adapter-d1'), + import('@/generated/prisma/client'), + ]); + const adapter = new PrismaD1(env.TELESCOPE_DB!); + prisma = new PrismaCtor({ adapter }); + } + context.locals.prisma = prisma; + } else { + context.locals.prisma = null; } - context.locals.prisma = prisma; + + context.locals.storage = await getStorage(); + context.locals.testStore = await getTestStore(context.locals.prisma); + return next(); }); From dea3392d69353476399426d7d5d530d8d716cd1f Mon Sep 17 00:00:00 2001 From: Mike K Date: Wed, 6 May 2026 17:24:35 -0400 Subject: [PATCH 4/5] Refactor telescope-web pages to use abstractions --- .../src/components/FilmstripVideo.astro | 44 +++-- .../src/lib/utils/contentRatingCache.ts | 13 +- .../pages/api/tests/[testId]/[...filename].ts | 44 ++--- .../pages/api/tests/[testId]/downloadZIP.ts | 41 +++-- .../src/pages/api/tests/[testId]/rating.ts | 26 ++- .../telescope-web/src/pages/api/upload.ts | 158 +++++++++++------- .../telescope-web/src/pages/results.astro | 42 ++--- .../src/pages/results/[testId].astro | 81 +++------ 8 files changed, 240 insertions(+), 209 deletions(-) diff --git a/packages/telescope-web/src/components/FilmstripVideo.astro b/packages/telescope-web/src/components/FilmstripVideo.astro index 71ea7ffb..5bff2b90 100644 --- a/packages/telescope-web/src/components/FilmstripVideo.astro +++ b/packages/telescope-web/src/components/FilmstripVideo.astro @@ -1,22 +1,21 @@ --- -import { env } from 'cloudflare:workers'; - import type { MetricsJson } from '@/lib/types/metrics'; interface Props { testId: string; metrics: MetricsJson | null; } const { testId, metrics } = Astro.props; +const storage = Astro.locals.storage; + let frameRate = 1; -try { - const configObj = await env.RESULTS_BUCKET.get(`${testId}/config.json`); - if (configObj) { - const config = await configObj.json<{ options?: { frameRate?: number } }>(); - frameRate = config?.options?.frameRate || 1; - } -} catch (error) { - console.error('Error reading config.json:', error); +const config = await storage.getJSON<{ options?: { frameRate?: number } }>( + testId, + 'config.json', +); +if (config) { + frameRate = config?.options?.frameRate || 1; } + const fcpMs = metrics?.paintTiming?.find( p => p.name === 'first-contentful-paint', )?.startTime; @@ -28,6 +27,7 @@ const lcpMs = : lcpEntry?.startTime; const layoutShifts = metrics?.layoutShifts?.filter(s => !s.hadRecentInput) || []; + interface FilmstripFrame { filename: string; frameNum: number; @@ -38,18 +38,19 @@ interface FilmstripFrame { isLcp: boolean; hasShift: boolean; } + let filmstripFrames: FilmstripFrame[] = []; let videoUrl: string | null = null; try { - const listed = await env.RESULTS_BUCKET.list({ prefix: `${testId}/` }); - const frames = listed.objects + const allFiles = await storage.list(testId); + const frames = allFiles .filter( - obj => - obj.key.includes('/filmstrip/') && - (obj.key.endsWith('.jpg') || obj.key.endsWith('.png')), + key => + key.includes('filmstrip/') && + (key.endsWith('.jpg') || key.endsWith('.png')), ) - .map(obj => { - const filename = obj.key.split('/').pop() || ''; + .map(key => { + const filename = key.split('/').pop() || ''; const match = filename.match(/frame_\d+x\d+_(\d+)\.(jpg|png)/); const frameNum = match ? parseInt(match[1], 10) : 0; return { @@ -75,14 +76,11 @@ try { ); return { ...frame, ms, timeLabel, isFcp, isLcp, hasShift }; }); - const videoFile = listed.objects.find( - obj => - obj.key.startsWith(`${testId}/`) && - !obj.key.includes('/filmstrip/') && - obj.key.endsWith('.webm'), + const videoFile = allFiles.find( + key => !key.includes('filmstrip/') && key.endsWith('.webm'), ); if (videoFile) { - videoUrl = `/api/tests/${testId}/${videoFile.key.replace(`${testId}/`, '')}`; + videoUrl = `/api/tests/${testId}/${videoFile}`; } } catch (error) { console.error('Error loading filmstrip/video:', error); diff --git a/packages/telescope-web/src/lib/utils/contentRatingCache.ts b/packages/telescope-web/src/lib/utils/contentRatingCache.ts index c53438c4..96b98b46 100644 --- a/packages/telescope-web/src/lib/utils/contentRatingCache.ts +++ b/packages/telescope-web/src/lib/utils/contentRatingCache.ts @@ -1,13 +1,15 @@ import type { APIContext } from 'astro'; -import { getPrismaClient } from '@/lib/prisma/client'; -import { getTestRating } from '@/lib/repositories/testRepository'; import { ContentRating } from '@/lib/types/tests'; /** - * Check test rating with cache + * Check test rating with cache. * Cache key format: https://rating/{testId} * TTL: immutable (ratings never change once final) - * Only caches final ratings (SAFE or UNSAFE), not UNKNOWN or IN_PROGRESS + * Only caches final ratings (SAFE or UNSAFE), not UNKNOWN or IN_PROGRESS. + * + * Cloudflare-mode only: backed by the Workers Cache API. In local mode + * this is unused — `[testId].ts`/`downloadZIP.ts` skip the rating gate + * entirely. */ export async function checkTestRating( context: APIContext, @@ -23,8 +25,7 @@ export async function checkTestRating( } catch (error) { console.warn(`[Cache] Cache read error (ignoring):`, error); } - const prisma = getPrismaClient(context); - const test = await getTestRating(prisma, testId); + const test = await context.locals.testStore.getRating(testId); if (!test) { return ContentRating.UNKNOWN; } diff --git a/packages/telescope-web/src/pages/api/tests/[testId]/[...filename].ts b/packages/telescope-web/src/pages/api/tests/[testId]/[...filename].ts index fe77617e..e62efeed 100644 --- a/packages/telescope-web/src/pages/api/tests/[testId]/[...filename].ts +++ b/packages/telescope-web/src/pages/api/tests/[testId]/[...filename].ts @@ -1,5 +1,3 @@ -import { env } from 'cloudflare:workers'; - import type { APIContext, APIRoute } from 'astro'; import { ContentRating } from '@/lib/types/tests'; import { @@ -10,10 +8,9 @@ import { import { checkTestRating } from '@/lib/utils/contentRatingCache'; /** - * Serve files from R2 bucket + * Serve files from the active storage provider. * Route: /api/tests/{testId}/{filename} - * Supports nested paths like filmstrip/frame_1.jpg or video files - * Used for serving screenshots and other test artifacts + * Supports nested paths like filmstrip/frame_1.jpg or video files. */ export const GET: APIRoute = async (context: APIContext) => { const { testId, filename } = context.params; @@ -29,21 +26,30 @@ export const GET: APIRoute = async (context: APIContext) => { if (!isExpectedTelescopeFile(normalizedFilename)) { return new Response('Invalid file', { status: 400 }); } - const aiEnabled = env.ENABLE_AI_RATING === 'true'; - if (aiEnabled) { - const rating = await checkTestRating(context, testId); - if (rating !== ContentRating.SAFE) { - return new Response('Test file not available', { status: 404 }); + + // AI rating gating only applies in cloudflare mode. + if (context.locals.mode === 'cloudflare') { + const { env } = await import('cloudflare:workers'); + const aiEnabled = env.ENABLE_AI_RATING === 'true'; + if (aiEnabled) { + const rating = await checkTestRating(context, testId); + if (rating !== ContentRating.SAFE) { + return new Response('Test file not available', { status: 404 }); + } } } - const key = `${testId}/${normalizedFilename}`; + try { - const r2Start = Date.now(); - const object = await env.RESULTS_BUCKET.get(key); - const r2Duration = Date.now() - r2Start; - console.log(`[R2] Fetch took ${r2Duration}ms - key: ${key}`); - if (!object) { - console.warn(`[R2] File not found in bucket - key: ${key}`); + const start = Date.now(); + const bytes = await context.locals.storage.get(testId, normalizedFilename); + const duration = Date.now() - start; + console.log( + `[Storage] Fetch took ${duration}ms - testId: ${testId}, file: ${normalizedFilename}`, + ); + if (!bytes) { + console.warn( + `[Storage] File not found - testId: ${testId}, file: ${normalizedFilename}`, + ); return new Response('File not found', { status: 404 }); } // Determine content type based on file extension @@ -74,10 +80,10 @@ export const GET: APIRoute = async (context: APIContext) => { headers['Content-Disposition'] = `attachment; filename="${normalizedFilename}"`; } - return new Response(object.body, { headers }); + return new Response(bytes as unknown as BodyInit, { headers }); } catch (error) { console.error( - `[R2] Fetch error - key: ${key}, testId: ${testId}, file: ${normalizedFilename}`, + `[Storage] Fetch error - testId: ${testId}, file: ${normalizedFilename}`, error, ); return new Response('Internal server error', { status: 500 }); diff --git a/packages/telescope-web/src/pages/api/tests/[testId]/downloadZIP.ts b/packages/telescope-web/src/pages/api/tests/[testId]/downloadZIP.ts index bfca397a..34d11504 100644 --- a/packages/telescope-web/src/pages/api/tests/[testId]/downloadZIP.ts +++ b/packages/telescope-web/src/pages/api/tests/[testId]/downloadZIP.ts @@ -1,4 +1,3 @@ -import { env } from 'cloudflare:workers'; import { zipSync } from 'fflate'; import type { APIContext, APIRoute } from 'astro'; @@ -15,36 +14,36 @@ export const GET: APIRoute = async (context: APIContext) => { if (!isValidTestId(testId)) { return new Response('Invalid testId format', { status: 400 }); } - const aiEnabled = env.ENABLE_AI_RATING === 'true'; - if (aiEnabled) { - const rating = await checkTestRating(context, testId); - if (rating !== ContentRating.SAFE) { - return new Response('Test file not available', { status: 404 }); + + // AI rating gating only applies in cloudflare mode. + if (context.locals.mode === 'cloudflare') { + const { env } = await import('cloudflare:workers'); + const aiEnabled = env.ENABLE_AI_RATING === 'true'; + if (aiEnabled) { + const rating = await checkTestRating(context, testId); + if (rating !== ContentRating.SAFE) { + return new Response('Test file not available', { status: 404 }); + } } } - const bucket = env.RESULTS_BUCKET; - const prefix = `${testId}/`; + + const storage = context.locals.storage; + try { - // Use R2 list() function that matches the prefix: https://developers.cloudflare.com/r2/api/workers/workers-api-reference/#r2listoptions - const listed = await bucket.list({ prefix }); - if (!listed.objects || listed.objects.length === 0) { + const keys = await storage.list(testId); + if (keys.length === 0) { return new Response('No files found for this test', { status: 404 }); } - const keys = listed.objects - .map(obj => obj.key) - .filter(key => key.slice(prefix.length)); // filter out empty paths upfront const files: Record = {}; - // need for-loop for sequential downloads, doing parallel downloads could overwhelm Worker + // Sequential reads to avoid overwhelming the runtime (esp. Workers). for (const key of keys) { - const relativePath = key.slice(prefix.length); - const r2obj = await bucket.get(key); - if (r2obj) { - const arrayBuffer = await r2obj.arrayBuffer(); - files[relativePath] = new Uint8Array(arrayBuffer); + const bytes = await storage.get(testId, key); + if (bytes) { + files[key] = bytes; } } const zipped = zipSync(files, { - level: 6, // default compression size/quality tradeoff: https://github.com/101arrowz/fflate#usage + level: 6, // default compression size/quality tradeoff }); const zipBuffer = zipped.buffer.slice( zipped.byteOffset, diff --git a/packages/telescope-web/src/pages/api/tests/[testId]/rating.ts b/packages/telescope-web/src/pages/api/tests/[testId]/rating.ts index fd349627..26445667 100644 --- a/packages/telescope-web/src/pages/api/tests/[testId]/rating.ts +++ b/packages/telescope-web/src/pages/api/tests/[testId]/rating.ts @@ -1,10 +1,13 @@ import type { APIContext, APIRoute } from 'astro'; -import { getPrismaClient } from '@/lib/prisma/client'; -import { getTestRating } from '@/lib/repositories/testRepository'; +import { ContentRating } from '@/lib/types/tests'; /** * GET /api/tests/:testId/rating + * * Returns the current content_rating for a test. + * + * In local mode AI rating is disabled and all tests are reported safe. + * In cloudflare mode this proxies the testStore for the live rating. */ export const GET: APIRoute = async (context: APIContext) => { const { testId } = context.params; @@ -14,8 +17,23 @@ export const GET: APIRoute = async (context: APIContext) => { headers: { 'Content-Type': 'application/json' }, }); } - const prisma = getPrismaClient(context); - const test = await getTestRating(prisma, testId); + + // Local mode: rating is always SAFE if the test exists. + if (context.locals.mode === 'local') { + const test = await context.locals.testStore.findByTestId(testId); + if (!test) { + return new Response(JSON.stringify({ error: 'Test not found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }); + } + return new Response(JSON.stringify({ rating: ContentRating.SAFE }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const test = await context.locals.testStore.getRating(testId); if (test === null) { return new Response(JSON.stringify({ error: 'Test not found' }), { status: 404, diff --git a/packages/telescope-web/src/pages/api/upload.ts b/packages/telescope-web/src/pages/api/upload.ts index 9d0f0676..6b1a7c07 100644 --- a/packages/telescope-web/src/pages/api/upload.ts +++ b/packages/telescope-web/src/pages/api/upload.ts @@ -1,5 +1,3 @@ -import { env } from 'cloudflare:workers'; - import type { APIContext, APIRoute } from 'astro'; import type { Unzipped } from 'fflate'; import type { TestConfig } from '@/lib/types/tests'; @@ -11,13 +9,6 @@ import { normalizeAndFilterZipFiles, toPosixPath } from '@/lib/utils/security'; import { generateTestId } from '@/lib/utils/testId'; import { TestSource, ContentRating } from '@/lib/types/tests'; -import { getPrismaClient } from '@/lib/prisma/client'; -import { - createTest, - findTestIdByZipKey, - updateContentRating, -} from '@/lib/repositories/testRepository'; -import { rateUrlContent } from '@/lib/ai/ai-content-rater'; // route is server-rendered by default b/c `astro.config.mjs` has `output: server` @@ -33,7 +24,7 @@ async function getUnzipped(buffer: ArrayBuffer): Promise { return unzipped; } -// Generate a SHA-256 hash of the buffer contents to use as unique identifier +// Generate a SHA-256 hash of the buffer contents to use as unique identifier (cloudflare mode only) async function generateContentHash(buffer: ArrayBuffer): Promise { const hashBuffer = await crypto.subtle.digest('SHA-256', buffer); const hashArray = Array.from(new Uint8Array(hashBuffer)); @@ -52,43 +43,50 @@ export const POST: APIRoute = async (context: APIContext) => { }); const formData = await context.request.formData(); const result = uploadSchema.safeParse({ - // safeParse() is explicit runtime type check: https://zod.dev/basics?id=handling-errors file: formData.get('file'), - name: formData.get('name'), - description: formData.get('description'), + name: formData.get('name') || undefined, + description: formData.get('description') || undefined, source: formData.get('source'), }); if (!result.success) { - return new Response(JSON.stringify({ error: result.error.errors }), { - // TODO: add custom error messaging + return new Response(JSON.stringify({ error: result.error.issues }), { status: 400, + headers: { 'Content-Type': 'application/json' }, }); } const { file, name, description, source } = result.data; + const mode = context.locals.mode; + const storage = context.locals.storage; + const testStore = context.locals.testStore; + // Read file buffer const buffer = await file.arrayBuffer(); const unzipped = await getUnzipped(buffer); const files = Object.keys(unzipped); - // Generate hash for unique R2 storage key - const zipKey = await generateContentHash(buffer); - // Check if this exact content already exists in D1 - const prisma = getPrismaClient(context); - const existing = await findTestIdByZipKey(prisma, zipKey); - if (existing) { - return new Response( - JSON.stringify({ - success: false, - error: `Duplicate uploads are not allowed.`, - testId: existing.testId, - contentRating: existing.contentRating, - }), - { - status: 409, - headers: { 'Content-Type': 'application/json' }, - }, - ); + + // Cloudflare mode: dedup by SHA-256 of full ZIP contents. + // Local mode: defer dedup until we know the testId (folder name). + let zipKey = ''; + if (mode === 'cloudflare') { + zipKey = await generateContentHash(buffer); + const existing = await testStore.findByZipKey(zipKey); + if (existing) { + return new Response( + JSON.stringify({ + success: false, + error: `Duplicate uploads are not allowed.`, + testId: existing.testId, + contentRating: existing.contentRating, + }), + { + status: 409, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } } - // Find if some ' .../config.json' exists + + // Locate config.json inside the archive const configPath = files.find( file => path.posix.basename(toPosixPath(file)) === 'config.json', ); @@ -138,7 +136,7 @@ export const POST: APIRoute = async (context: APIContext) => { let configText; try { configText = configDecoder.decode(configBytes); - } catch (error) { + } catch (_error) { return new Response( JSON.stringify({ success: false, @@ -173,8 +171,8 @@ export const POST: APIRoute = async (context: APIContext) => { }) .passthrough(), }); - type ConfigJson = z.infer; - let config: ConfigJson; + type UploadConfigJson = z.infer; + let config: UploadConfigJson; try { const parsed = JSON.parse(configText); const configResult = configSchema.safeParse(parsed); @@ -188,7 +186,7 @@ export const POST: APIRoute = async (context: APIContext) => { ); } config = configResult.data; - } catch (error) { + } catch (_error) { return new Response( JSON.stringify({ success: false, @@ -200,7 +198,9 @@ export const POST: APIRoute = async (context: APIContext) => { // Build test configuration object const testId = generateTestId(config.date); const browser = - config.options.browser || config.browserConfig.engine || 'unknown'; + (config.options.browser as string | undefined) || + config.browserConfig.engine || + 'unknown'; const testConfig: TestConfig = { testId, zipKey, @@ -211,9 +211,41 @@ export const POST: APIRoute = async (context: APIContext) => { testDate: Math.floor(new Date(config.date).getTime() / 1000), browser, }; - // Store test metadata in database + + // Local mode: dedup by folder name + if (mode === 'local') { + const existing = await testStore.findByTestId(testId); + if (existing) { + return new Response( + JSON.stringify({ + success: false, + error: `Duplicate uploads are not allowed.`, + testId: existing.testId, + contentRating: existing.contentRating, + }), + { + status: 409, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + } + + // Inject name/description into config.json before persisting it. + if (name || description) { + const enriched = { + ...(config as unknown as Record), + ...(name ? { name } : {}), + ...(description ? { description } : {}), + }; + normalizedUnzipped['config.json'] = new TextEncoder().encode( + JSON.stringify(enriched), + ); + } + + // Persist test metadata (cloudflare: D1 row; local: no-op) try { - await createTest(prisma, testConfig); + await testStore.create(testConfig); } catch (error) { return new Response( JSON.stringify({ @@ -223,18 +255,15 @@ export const POST: APIRoute = async (context: APIContext) => { { status: 500, headers: { 'Content-Type': 'application/json' } }, ); } - // store all valid files in R2 with {testId}/{filename} format - for (const filename of validFiles) { - await env.RESULTS_BUCKET!.put( - `${testId}/${filename}`, - normalizedUnzipped[filename], - ); + // Persist all valid files via the storage layer + for (const filename of Object.keys(normalizedUnzipped)) { + await storage.put(testId, filename, normalizedUnzipped[filename]); } // Build success response first const response = new Response( JSON.stringify({ success: true, - testId: testId, + testId, message: 'Upload processed successfully', }), { @@ -243,20 +272,37 @@ export const POST: APIRoute = async (context: APIContext) => { }, ); - // Rate the URL content via Workers AI — fire-and-forget after response is built - if (env.ENABLE_AI_RATING === 'true' && env.AI) { - context.locals.cfContext.waitUntil( - (async () => { - await updateContentRating(prisma, testId, ContentRating.IN_PROGRESS); + // AI rating: cloudflare mode only. + if (mode === 'cloudflare') { + const { env } = await import('cloudflare:workers'); + if (env.ENABLE_AI_RATING === 'true' && env.AI) { + const cfRuntime = ( + context.locals as unknown as { + runtime?: { ctx?: { waitUntil?: (p: Promise) => void } }; + } + ).runtime; + const waitUntil = cfRuntime?.ctx?.waitUntil?.bind(cfRuntime.ctx); + const job = (async () => { + const { rateUrlContent } = await import('@/lib/ai/ai-content-rater'); + await testStore.updateContentRating( + testId, + ContentRating.IN_PROGRESS, + ); const rating = await rateUrlContent( env.AI!, testConfig.url, normalizedUnzipped['metrics.json'], normalizedUnzipped['screenshot.png'], ); - await updateContentRating(prisma, testId, rating); - })(), - ); + await testStore.updateContentRating(testId, rating); + })(); + if (waitUntil) { + waitUntil(job); + } else { + // Fallback: fire and forget without backgrounding. + job.catch(err => console.error('[upload] rating job failed:', err)); + } + } } return response; diff --git a/packages/telescope-web/src/pages/results.astro b/packages/telescope-web/src/pages/results.astro index ba5e6425..10778ac7 100644 --- a/packages/telescope-web/src/pages/results.astro +++ b/packages/telescope-web/src/pages/results.astro @@ -1,14 +1,9 @@ --- -import { env } from 'cloudflare:workers'; - import type { Tests } from '@/lib/types/tests'; import Page from '@/layouts/Page.astro'; import TestCard from '@/components/TestCard.astro'; -import { getPrismaClient } from '@/lib/prisma/client'; -import { getAllTests } from '@/lib/repositories/testRepository'; - import { ListIcon, SquaresFourIcon } from '@phosphor-icons/react/ssr'; // Get saved view preference from cookie @@ -16,12 +11,15 @@ const savedView = Astro.cookies.get('resultsView')?.value; const viewLayout = savedView === 'grid' || savedView === 'vertical' ? savedView : 'vertical'; -// Fetch all tests from D1 -const aiEnabled = env.ENABLE_AI_RATING === 'true'; -const prisma = getPrismaClient(Astro); -const tests = await getAllTests(prisma, aiEnabled); +// AI content rating only applies in cloudflare mode. +let aiEnabled = false; +if (Astro.locals.mode === 'cloudflare') { + const { env } = await import('cloudflare:workers'); + aiEnabled = env.ENABLE_AI_RATING === 'true'; +} + +const tests = await Astro.locals.testStore.getAll(aiEnabled); -// get proper interface with screenshot type TestWithScreenshot = Tests & { screenshot_url: string | null; }; @@ -29,15 +27,15 @@ type TestWithScreenshot = Tests & { const filename = 'screenshot.png'; const testsWithScreenshots: TestWithScreenshot[] = await Promise.all( tests.map(async (test: Tests): Promise => { - let screenshotUrl: string | null = null; - const key = `${test.test_id}/${filename}`; - const obj = await env.RESULTS_BUCKET!.head(key); - if (obj) { - screenshotUrl = `/api/tests/${test.test_id}/${filename}`; - } + const hasScreenshot = await Astro.locals.storage.exists( + test.test_id, + filename, + ); return { - ...test, // now with content_rating field - screenshot_url: screenshotUrl, + ...test, + screenshot_url: hasScreenshot + ? `/api/tests/${test.test_id}/${filename}` + : null, }; }), ); @@ -75,9 +73,11 @@ export const prerender = false; Grid - - AI content filtering {aiEnabled ? 'on' : 'off'} - + {Astro.locals.mode === 'cloudflare' && ( + + AI content filtering {aiEnabled ? 'on' : 'off'} + + )}
{testsWithScreenshots.map(test => ( diff --git a/packages/telescope-web/src/pages/results/[testId].astro b/packages/telescope-web/src/pages/results/[testId].astro index 0ff53af7..a36ec36c 100644 --- a/packages/telescope-web/src/pages/results/[testId].astro +++ b/packages/telescope-web/src/pages/results/[testId].astro @@ -1,6 +1,4 @@ --- -import { env } from 'cloudflare:workers'; - import Page from '@/layouts/Page.astro'; import MetricCard from '@/components/MetricCard.astro'; import ScreenshotDisplay from '@/components/ScreenshotDisplay.astro'; @@ -14,8 +12,6 @@ import Console from '@/components/Console.astro'; import Bottlenecks from '@/components/Bottlenecks.astro'; import ConfigTab from '@/components/ConfigTab.astro'; -import { getPrismaClient } from '@/lib/prisma/client'; -import { getTestById } from '@/lib/repositories/testRepository'; import { ContentRating } from '@/lib/types/tests'; import type { MetricsJson } from '@/lib/types/metrics'; @@ -39,10 +35,14 @@ if (!testId) { return Astro.redirect('/results'); } -const aiEnabled = env.ENABLE_AI_RATING === 'true'; +// AI gating only applies in cloudflare mode. +let aiEnabled = false; +if (Astro.locals.mode === 'cloudflare') { + const { env } = await import('cloudflare:workers'); + aiEnabled = env.ENABLE_AI_RATING === 'true'; +} -const prisma = getPrismaClient(Astro); -const test = await getTestById(prisma, testId); +const test = await Astro.locals.testStore.getById(testId); if (!test) { return Astro.redirect('/results'); } @@ -53,7 +53,7 @@ const isUnknown = (test.content_rating === ContentRating.UNKNOWN || test.content_rating === ContentRating.IN_PROGRESS); -// Only read R2 files if content is safe to display +// Only read result files if content is safe to display let screenshotUrl: string | null = null; let harBrowser: { name: string; version: string } | null = null; let hasHar = false; @@ -70,69 +70,32 @@ let transferBytes = extractTransferSize(null); let durationMs = extractDuration(null); if (!isUnsafe && !isUnknown) { + const storage = Astro.locals.storage; + // --- Screenshot --- - const screenshotKey = `${testId}/screenshot.png`; - const screenshotObj = await env.RESULTS_BUCKET.head(screenshotKey); - screenshotUrl = screenshotObj ? `/api/tests/${testId}/screenshot.png` : null; + const hasScreenshot = await storage.exists(testId, 'screenshot.png'); + screenshotUrl = hasScreenshot ? `/api/tests/${testId}/screenshot.png` : null; // --- HAR (browser version + existence check for Waterfall) --- - try { - const harObj = await env.RESULTS_BUCKET.get(`${testId}/pageload.har`); - if (harObj) { - hasHar = true; - har = await harObj.json(); - harBrowser = har?.log?.browser ?? null; - } - } catch (err) { - console.error('Error reading HAR browser info:', err); - // HAR unavailable — render without browser version + har = await storage.getJSON(testId, 'pageload.har'); + if (har) { + hasHar = true; + harBrowser = har?.log?.browser ?? null; } // --- Metrics --- - try { - const metricsObj = await env.RESULTS_BUCKET.get(`${testId}/metrics.json`); - if (metricsObj) { - metrics = await metricsObj.json(); - } - } catch (err) { - console.error('Error reading metrics.json:', err); - // metrics unavailable — render without them - } + metrics = await storage.getJSON(testId, 'metrics.json'); // --- Resources --- - try { - const resourcesObj = await env.RESULTS_BUCKET.get( - `${testId}/resources.json`, - ); - if (resourcesObj) { - resources = await resourcesObj.json(); - } - } catch (err) { - console.error('Error reading resources.json:', err); - // resources unavailable — render without them - } + resources = + (await storage.getJSON(testId, 'resources.json')) ?? []; // --- Console Messages --- - try { - const consoleObj = await env.RESULTS_BUCKET.get(`${testId}/console.json`); - if (consoleObj) { - consoleMessages = await consoleObj.json(); - } - } catch (err) { - console.error('Error reading console.json:', err); - // console messages unavailable — render without them - } + consoleMessages = + (await storage.getJSON(testId, 'console.json')) ?? []; // --- Config --- - try { - const configObj = await env.RESULTS_BUCKET.get(`${testId}/config.json`); - if (configObj) { - config = await configObj.json(); - } - } catch (err) { - console.error('Error reading config.json:', err); - // config unavailable — render without it - } + config = await storage.getJSON(testId, 'config.json'); // Extract metric values lcp = getLcp(metrics); From 05c7bc3ba696523e7f197d379c1ba3ceb68be92e Mon Sep 17 00:00:00 2001 From: Mike K Date: Wed, 6 May 2026 18:07:27 -0400 Subject: [PATCH 5/5] tests and verify 1 --- packages/telescope-web/AGENTS.md | 95 +++++-- .../__tests__/npm-scripts.test.ts | 8 +- .../__tests__/uploadLocal.test.ts | 260 ++++++++++++++++++ 3 files changed, 340 insertions(+), 23 deletions(-) create mode 100644 packages/telescope-web/__tests__/uploadLocal.test.ts diff --git a/packages/telescope-web/AGENTS.md b/packages/telescope-web/AGENTS.md index af1f5913..c72507e9 100644 --- a/packages/telescope-web/AGENTS.md +++ b/packages/telescope-web/AGENTS.md @@ -2,13 +2,44 @@ ## Package Overview -`telescope-web` is an Astro + Cloudflare Workers web application that serves as the Telescope results UI. It is a fully independent project from the core `packages/telescope/` library — do not mix concerns between the two packages. +`telescope-web` is an Astro web application that serves as the Telescope results UI. It runs in **two modes**, selected at build/run time via the `TELESCOPE_MODE` environment variable: + +- `cloudflare` (default) — Astro + Cloudflare Workers, backed by D1 (Prisma), R2, and Workers AI +- `local` — Astro + Node.js standalone, backed by the local filesystem (no DB, no AI) + +It is a fully independent project from the core `packages/telescope/` library — do not mix concerns between the two packages. Key subdirectories: -- `src/` — Astro pages, React components, and Workers API routes +- `src/` — Astro pages, React components, API routes, and runtime abstractions + - `src/lib/storage/` — `IStorage` interface + `LocalStorage` / `CloudflareStorage` implementations + factory + - `src/lib/repositories/` — `ITestStore` interface + `LocalTestStore` / `D1TestStore` implementations + - `src/lib/config/mode.ts` — `getMode()`, `getResultsDir()` runtime helpers - `scripts/` — Dev setup helpers -- `migrations/` — D1 database migration files +- `migrations/` — D1 database migration files (cloudflare mode only) + +--- + +## Modes + +### Local mode (`TELESCOPE_MODE=local`) + +- Uses `@astrojs/node` adapter (standalone) +- Reads test results directly from `RESULTS_DIR` (default `./results`) +- Each subdirectory is a test; metadata comes from the `config.json` inside (including `name` and `description` when written by the CLI) +- Upload extracts the ZIP into `RESULTS_DIR/{testId}/` and merges form-supplied `name`/`description` into `config.json` +- Dedup is by folder name — re-uploading produces 409 +- AI content rating is disabled; all tests are reported safe +- No Prisma, no Wrangler + +### Cloudflare mode (`TELESCOPE_MODE=cloudflare`, default) + +- Uses `@astrojs/cloudflare` adapter +- Test metadata in D1 via Prisma; binary results in R2 (`RESULTS_BUCKET`) +- Upload writes to D1 + R2; dedup is by SHA-256 hash of the ZIP contents +- AI content rating runs via Workers AI when `ENABLE_AI_RATING=true` + +The page/component/API code is mode-agnostic and reaches storage and metadata through `Astro.locals.storage` and `Astro.locals.testStore`, which middleware resolves on each request. --- @@ -16,48 +47,59 @@ Key subdirectories: Commands below are run from `packages/telescope-web/`. To run from the repo root, use `npm run