Skip to content

Implement report permalinks for sharing sets of runs #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

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions REPORT_FEATURE.md
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
85 changes: 85 additions & 0 deletions app/components/RunDisplay.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -35,6 +52,27 @@ defineProps<{
</div>

<div class="reset-container">
<div
v-if="currentReportId && runs.length > 0"
class="report-permalink"
>
<label>Report Permalink:</label>
<div class="permalink-container">
<input
:value="generateReportPermalink(currentReportId)"
readonly
class="permalink-input"
/>
<button
class="copy-button"
title="Copy to clipboard"
@click="copyToClipboard(generateReportPermalink(currentReportId))"
>
📋
</button>
</div>
</div>

<button
v-if="runs.length > 0"
@click="onClear()"
Expand Down Expand Up @@ -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>
124 changes: 124 additions & 0 deletions app/composables/useRunManager.report.test.ts
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)
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')
})
})
7 changes: 6 additions & 1 deletion app/composables/useRunManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ describe('useRunManager', () => {
status: 200,
durationInMs: 100,
headers: { 'cache-control': 'max-age=3600' },
reportId: 'test-report',
}

const run = getRunFromApiRun(apiRun)
Expand All @@ -57,6 +58,7 @@ describe('useRunManager', () => {
status: 200,
durationInMs: 100,
headers: { 'cache-control': 'max-age=3600' },
reportId: 'test-report',
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand All @@ -73,7 +75,10 @@ describe('useRunManager', () => {
expect(runs.value[0]?.url).toBe('https://example.com')
expect(mockFetch).toHaveBeenCalledWith('/api/inspect-url', {
method: 'POST',
body: { url: 'https://example.com' },
body: {
url: 'https://example.com',
currentReportId: null,
},
})
})

Expand Down
Loading