-
Notifications
You must be signed in to change notification settings - Fork 0
feat: store sets of runs as shareable reports #90
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 5 commits
99cdbc8
8ff9bce
f58420b
100eec6
db70c78
b38cbb7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| # Report Permalinks Feature Implementation | ||
|
|
||
| ## Overview | ||
| This implementation adds the ability to generate permalinks for sets of runs (reports), allowing users to share multiple runs together. | ||
|
|
||
| ## How it works | ||
|
|
||
| ### 1. First Run Submission | ||
| - User submits a URL for inspection on the main page | ||
| - API creates a new report with the run ID | ||
| - Returns the run data + new report ID | ||
| - UI shows the run and displays the report permalink | ||
|
|
||
| ### 2. Additional Run Submissions | ||
| - User submits another URL while on the same page | ||
| - API receives the current report ID in the request | ||
| - Creates a new immutable report containing all previous run IDs + new run ID | ||
| - Returns the run data + new report ID | ||
| - UI updates to show the new report permalink | ||
|
|
||
| ### 3. Viewing Report Permalinks | ||
| - Users can visit `/report/{reportId}` to view a specific report | ||
| - API fetches the report (containing run IDs) and loads all individual runs | ||
| - Page displays all runs in the report and sets up context for additional runs | ||
| - New runs submitted from this page will create new reports extending this one | ||
|
|
||
| ## Key Features | ||
|
|
||
| ### Immutable Reports | ||
| - Each new run creates a new report (no mutation of existing reports) | ||
| - Prevents shared permalinks from being modified by subsequent users | ||
|
|
||
| ### Data Storage | ||
| - Reports store only run IDs (not full run data) to minimize duplication | ||
| - Individual runs are still stored separately and loaded when needed | ||
| - Uses the same caching headers as individual runs (`max-age=31536000, immutable`) | ||
|
|
||
| ### UI Integration | ||
| - Report permalink displayed in a styled box with copy-to-clipboard functionality | ||
| - Permalink only shown when runs exist and a report has been created | ||
| - Clear button resets both runs and current report | ||
|
|
||
| ## API Endpoints | ||
|
|
||
| ### `POST /api/inspect-url` | ||
| **Request:** | ||
| ```json | ||
| { | ||
| "url": "https://example.com", | ||
| "currentReportId": "abc12345" // optional | ||
| } | ||
| ``` | ||
|
|
||
| **Response:** | ||
| ```json | ||
| { | ||
| "runId": "def67890", | ||
| "url": "https://example.com", | ||
| "status": 200, | ||
| "headers": { ... }, | ||
| "durationInMs": 150, | ||
| "reportId": "ghi13579" // new report ID | ||
| } | ||
| ``` | ||
|
|
||
| ### `GET /api/reports/{reportId}` | ||
| **Response:** | ||
| ```json | ||
| { | ||
| "reportId": "ghi13579", | ||
| "createdAt": 1703123456789, | ||
| "runs": [ | ||
| { | ||
| "runId": "def67890", | ||
| "url": "https://example.com", | ||
| "status": 200, | ||
| "headers": { ... }, | ||
| "durationInMs": 150 | ||
| } | ||
| ] | ||
| } | ||
| ``` | ||
|
|
||
| ## Routes | ||
|
|
||
| - `/` - Main page for starting new reports | ||
| - `/run/{runId}` - View individual run (clears report context) | ||
| - `/report/{reportId}` - View specific report and continue adding runs | ||
|
|
||
| ## Testing | ||
|
|
||
| - Added comprehensive tests for useRunManager report functionality | ||
| - Updated existing tests to handle new API parameters | ||
| - All tests pass and build succeeds | ||
|
|
||
| ## Implementation Files | ||
|
|
||
| ### New Files | ||
| - `app/types/report.ts` - Report type definitions | ||
| - `app/pages/report/[reportId].vue` - Report viewing page | ||
| - `server/api/reports/[reportId].ts` - Report API endpoint | ||
| - `app/composables/useRunManager.report.test.ts` - Report functionality tests | ||
|
|
||
| ### Modified Files | ||
| - `server/db.ts` - Added report storage functions | ||
| - `server/api/inspect-url.post.ts` - Added report creation/updating | ||
| - `app/composables/useRunManager.ts` - Added report tracking | ||
| - `app/components/RunDisplay.vue` - Added permalink display | ||
| - `app/types/run.ts` - Added optional reportId to ApiRun | ||
| - Various page components - Updated to pass currentReportId | ||
|
|
||
| ## Example Flow | ||
|
|
||
| 1. User visits `/` and submits `https://example.com` | ||
| 2. System creates run `abc12345` and report `report789` | ||
| 3. UI shows permalink: `https://site.netlify.app/report/report789` | ||
| 4. User submits `https://test.com` | ||
| 5. System creates run `def67890` and new report `report999` containing `[abc12345, def67890]` | ||
| 6. UI updates permalink to: `https://site.netlify.app/report/report999` | ||
| 7. User shares the permalink with colleague | ||
| 8. Colleague visits `/report/report999` and sees both runs | ||
| 9. Colleague can add more runs, creating new reports that extend the original |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,7 +6,24 @@ defineProps<{ | |
| error: string | null | ||
| loading: boolean | ||
| onClear: () => void | ||
| currentReportId?: string | null | ||
| }>() | ||
|
|
||
| const generateReportPermalink = (reportId: string) => { | ||
| const baseUrl = typeof window !== 'undefined' ? window.location.origin : '' | ||
| return `${baseUrl}/report/${reportId}` | ||
| } | ||
|
|
||
| const copyToClipboard = async (text: string) => { | ||
| if (typeof navigator !== 'undefined' && navigator.clipboard) { | ||
| try { | ||
| await navigator.clipboard.writeText(text) | ||
| } | ||
| catch (err) { | ||
| console.error('Failed to copy to clipboard:', err) | ||
| } | ||
| } | ||
| } | ||
| </script> | ||
|
|
||
| <template> | ||
|
|
@@ -35,6 +52,27 @@ defineProps<{ | |
| </div> | ||
|
|
||
| <div class="reset-container"> | ||
| <div | ||
| v-if="currentReportId && runs.length > 0" | ||
| class="report-permalink" | ||
| > | ||
| <label>Report Permalink:</label> | ||
serhalp marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| <div class="permalink-container"> | ||
| <input | ||
| :value="generateReportPermalink(currentReportId)" | ||
| readonly | ||
| class="permalink-input" | ||
| /> | ||
|
Comment on lines
+61
to
+65
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should be much wider as it often contains quite long URLs
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done in b38cbb7 - changed |
||
| <button | ||
| class="copy-button" | ||
| title="Copy to clipboard" | ||
| @click="copyToClipboard(generateReportPermalink(currentReportId))" | ||
| > | ||
| 📋 | ||
| </button> | ||
|
Comment on lines
+66
to
+72
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in b38cbb7:
|
||
| </div> | ||
| </div> | ||
|
|
||
| <button | ||
| v-if="runs.length > 0" | ||
| @click="onClear()" | ||
|
|
@@ -70,4 +108,51 @@ defineProps<{ | |
| text-align: center; | ||
| background-color: inherit; | ||
| } | ||
|
|
||
| .report-permalink { | ||
| margin-bottom: 1em; | ||
| padding: 1em; | ||
| background-color: var(--bg-200, #f8fafc); | ||
| border-radius: 0.5em; | ||
| border: 1px solid var(--border-200, #e2e8f0); | ||
| } | ||
|
|
||
| .report-permalink label { | ||
| display: block; | ||
| font-weight: 500; | ||
| margin-bottom: 0.5em; | ||
| color: var(--text-700, #374151); | ||
| } | ||
|
|
||
| .permalink-container { | ||
| display: flex; | ||
| gap: 0.5em; | ||
| align-items: center; | ||
| max-width: 600px; | ||
| margin: 0 auto; | ||
| } | ||
|
|
||
| .permalink-input { | ||
| flex: 1; | ||
| padding: 0.5em; | ||
| border: 1px solid var(--border-300, #d1d5db); | ||
| border-radius: 0.25em; | ||
| background-color: white; | ||
| font-family: monospace; | ||
| font-size: 0.875em; | ||
| } | ||
|
|
||
| .copy-button { | ||
| padding: 0.5em; | ||
| background-color: var(--blue-500, #3b82f6); | ||
| color: white; | ||
| border: none; | ||
| border-radius: 0.25em; | ||
| cursor: pointer; | ||
| font-size: 0.875em; | ||
| } | ||
|
|
||
| .copy-button:hover { | ||
| background-color: var(--blue-600, #2563eb); | ||
| } | ||
| </style> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| /** | ||
| * @vitest-environment jsdom | ||
| */ | ||
| import { describe, it, expect, vi, beforeEach } from 'vitest' | ||
| import { useRunManager } from './useRunManager' | ||
| import type { ApiRun } from '~/types/run' | ||
|
|
||
| // Mock the getCacheHeaders function | ||
| vi.mock('~/utils/getCacheHeaders', () => ({ | ||
| default: vi.fn((headers: Record<string, string>) => headers), | ||
| })) | ||
|
|
||
| // Mock fetch and $fetch | ||
| global.fetch = vi.fn() | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| global.$fetch = vi.fn() as any | ||
|
|
||
| describe('useRunManager - Report Functionality', () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks() | ||
| }) | ||
|
|
||
| it('tracks currentReportId state', () => { | ||
| const { currentReportId, setCurrentReportId } = useRunManager() | ||
|
|
||
| expect(currentReportId.value).toBe(null) | ||
|
|
||
| setCurrentReportId('test-report-123') | ||
| expect(currentReportId.value).toBe('test-report-123') | ||
|
|
||
| setCurrentReportId(null) | ||
| expect(currentReportId.value).toBe(null) | ||
| }) | ||
|
|
||
| it('sends currentReportId in API requests when set', async () => { | ||
| const mockApiRun: ApiRun = { | ||
| runId: 'test-run', | ||
| url: 'https://example.com', | ||
| status: 200, | ||
| durationInMs: 100, | ||
| headers: { 'cache-control': 'max-age=3600' }, | ||
| reportId: 'new-report-456', | ||
| } | ||
|
|
||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| const mockFetch = vi.mocked($fetch as any) | ||
| mockFetch.mockResolvedValueOnce(mockApiRun) | ||
|
|
||
| const { handleRequestFormSubmit, setCurrentReportId, currentReportId } = useRunManager() | ||
|
|
||
| setCurrentReportId('existing-report-123') | ||
|
|
||
| await handleRequestFormSubmit({ url: 'https://example.com' }) | ||
|
|
||
| expect(mockFetch).toHaveBeenCalledWith('/api/inspect-url', { | ||
| method: 'POST', | ||
| body: { | ||
| url: 'https://example.com', | ||
| currentReportId: 'existing-report-123', | ||
| }, | ||
| }) | ||
|
|
||
| // Should update to the new report ID returned from API | ||
| expect(currentReportId.value).toBe('new-report-456') | ||
| }) | ||
|
|
||
| it('updates currentReportId when API returns new reportId', async () => { | ||
| const mockApiRun: ApiRun = { | ||
| runId: 'test-run', | ||
| url: 'https://example.com', | ||
| status: 200, | ||
| durationInMs: 100, | ||
| headers: { 'cache-control': 'max-age=3600' }, | ||
| reportId: 'new-report-789', | ||
| } | ||
|
|
||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| const mockFetch = vi.mocked($fetch as any) | ||
| mockFetch.mockResolvedValueOnce(mockApiRun) | ||
|
|
||
| const { handleRequestFormSubmit, currentReportId } = useRunManager() | ||
|
|
||
| expect(currentReportId.value).toBe(null) | ||
|
|
||
| await handleRequestFormSubmit({ url: 'https://example.com' }) | ||
|
|
||
| expect(currentReportId.value).toBe('new-report-789') | ||
| }) | ||
|
|
||
| it('clears currentReportId when clearing runs', () => { | ||
| const { handleClickClear, setCurrentReportId, currentReportId } = useRunManager() | ||
|
|
||
| setCurrentReportId('test-report-123') | ||
| expect(currentReportId.value).toBe('test-report-123') | ||
|
|
||
| handleClickClear() | ||
|
|
||
| expect(currentReportId.value).toBe(null) | ||
| }) | ||
|
|
||
| it('handles API response without reportId', async () => { | ||
| const mockApiRun: ApiRun = { | ||
| runId: 'test-run', | ||
| url: 'https://example.com', | ||
| status: 200, | ||
| durationInMs: 100, | ||
| headers: { 'cache-control': 'max-age=3600' }, | ||
| // No reportId in response | ||
| } | ||
|
|
||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| const mockFetch = vi.mocked($fetch as any) | ||
serhalp marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| mockFetch.mockResolvedValueOnce(mockApiRun) | ||
|
|
||
| const { handleRequestFormSubmit, currentReportId, setCurrentReportId } = useRunManager() | ||
|
|
||
| setCurrentReportId('existing-report') | ||
|
|
||
| await handleRequestFormSubmit({ url: 'https://example.com' }) | ||
|
|
||
| // Should keep existing reportId if API doesn't return one | ||
| expect(currentReportId.value).toBe('existing-report') | ||
| }) | ||
| }) | ||

Uh oh!
There was an error while loading. Please reload this page.