Skip to content
Merged
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ Native Playwright HTML reports are great for local debugging — but they're eph

```bash
# Linux / macOS
mkdir -p .data && chown -R 1001:1001 .data # the container runs as non-root UID 1001
docker run -p 3000:3000 -v $(pwd)/.data:/app/.data phenx/piwitests-server:latest
```

Expand All @@ -76,6 +77,8 @@ docker run -p 3000:3000 -v ${PWD}/.data:/app/.data phenx/piwitests-server:latest

Visit `http://localhost:3000`. A [`docker-compose.yml`](./docker-compose.yml) is also included.

> **Linux hosts:** the container runs as non-root UID 1001, so without the `chown` above, Docker auto-creates `.data` owned by `root` and the container can't write to it. Windows and macOS (Docker Desktop) don't need this step. See [Troubleshooting](./DOCKER.md#troubleshooting) if you hit a permission error.

> **Production tip:** set `PIWI_SECRET_KEY` (any long random string) so credentials you store in the dashboard — AI API keys, SCM tokens — are encrypted at rest. Generate one with `node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"`. See the [deployment guide](https://piwitests.github.io/deployment).

**2. Add the reporter to your test project**
Expand Down
5 changes: 4 additions & 1 deletion application/app/components/cluster/ClusterTestEvidence.vue
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,10 @@ const evidenceChips = computed(() => {
>
<div class="overflow-x-auto max-h-72">
<TestSourceStack v-if="caseDetail.testSourceFrames?.length" :frames="caseDetail.testSourceFrames" />
<MarkdownPreview v-else-if="caseDetail.testSource" :text="'```typescript\n' + caseDetail.testSource + '\n```'" />
<MarkdownPreview
v-else-if="caseDetail.testSource"
:text="'```typescript\n' + caseDetail.testSource + '\n```'"
/>
</div>
</TestEvidenceSection>

Expand Down
110 changes: 72 additions & 38 deletions application/app/components/layout/GetStartedWizard.vue
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
<script setup lang="ts">
import { docsUrl } from '#shared/docs';

const config = useRuntimeConfig();
const authEnabled = computed(() => !!config.public.authEnabled);

// Reflect the actual dashboard URL so the generated config snippet is correct
const serverUrl = ref('http://localhost:3000');
onMounted(() => {
serverUrl.value = window.location.origin;
});

const apiKeyLine = computed(() => (authEnabled.value ? `\n apiKey: process.env.PIWI_API_KEY,` : ''));

const configCode = computed(
() => `import { defineConfig } from '@playwright/test'

Expand All @@ -15,7 +20,7 @@ export default defineConfig({
['list'],
['@piwitests/reporter', {
serverUrl: '${serverUrl.value}',
projectName: 'my-project',
projectName: 'my-project',${apiKeyLine.value}
}],
],
use: {
Expand All @@ -37,7 +42,7 @@ export default PiwiDashboard.wrapConfig(
}),
{
serverUrl: '${serverUrl.value}',
projectName: 'my-project',
projectName: 'my-project',${apiKeyLine.value}
},
)`,
);
Expand Down Expand Up @@ -65,40 +70,60 @@ import { extendPiwiFixtures } from '@piwitests/reporter'
export const test = extendPiwiFixtures(base)
export { expect } from '@playwright/test'`;

const steps = computed(() => [
{
id: 1,
title: 'Start the dashboard',
description: "You're already here — the dashboard is running.",
done: true,
code: null as string | null,
lang: undefined as string | undefined,
},
{
id: 2,
title: 'Install the reporter',
description: 'Add the Piwi reporter to your Playwright project.',
done: false,
code: 'npm install --save-dev @piwitests/reporter',
lang: 'bash',
},
{
id: 3,
title: 'Configure Playwright',
description: 'Add the reporter to your playwright.config.ts.',
done: false,
code: configCode.value,
lang: 'typescript',
},
{
id: 4,
title: 'Run your tests',
description: 'Results appear in the dashboard automatically. The project is created on first submit.',
done: false,
code: 'npx playwright test',
lang: 'bash',
},
]);
interface WizardStep {
title: string;
description: string;
done?: boolean;
code?: string | null;
lang?: string;
/** Renders an inline call-to-action instead of (or alongside) a code block. */
action?: 'create-api-key';
}

const steps = computed<Array<WizardStep & { id: number }>>(() => {
const list: WizardStep[] = [
{
title: 'Start the dashboard',
description: "You're already here — the dashboard is running.",
done: true,
},
{
title: 'Install the reporter',
description: 'Add the Piwi reporter to your Playwright project.',
code: 'npm install --save-dev @piwitests/reporter',
lang: 'bash',
},
];

if (authEnabled.value) {
list.push({
title: 'Create an API key',
description:
'Authentication is enabled on this instance, so the reporter needs a key to submit results. Create one, then set it as PIWI_API_KEY in your CI secrets (used by the snippet below).',
action: 'create-api-key',
});
}

list.push(
{
title: 'Configure Playwright',
description: 'Add the reporter to your playwright.config.ts.',
code: configCode.value,
lang: 'typescript',
},
{
title: 'Run your tests',
description: 'Results appear in the dashboard automatically. The project is created on first submit.',
code: 'npx playwright test',
lang: 'bash',
},
);

return list.map((step, index) => ({ id: index + 1, ...step }));
});

const STEP_COUNT_WORDS = ['zero', 'one', 'two', 'three', 'four', 'five', 'six'];
const stepCountWord = computed(() => STEP_COUNT_WORDS[steps.value.length] ?? String(steps.value.length));

const goFurtherOpen = ref(false);
</script>
Expand All @@ -114,7 +139,7 @@ const goFurtherOpen = ref(false);
<h2 class="text-xl font-semibold inline-flex items-center gap-1">
Get started in 60 seconds <HelpHint topic="home.get-started" />
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">Send your first test run in four steps</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Send your first test run in {{ stepCountWord }} steps</p>
</div>
</div>
</template>
Expand Down Expand Up @@ -144,6 +169,15 @@ const goFurtherOpen = ref(false);
<UBadge v-if="step.done" color="success" variant="subtle" size="xs">Done</UBadge>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">{{ step.description }}</p>
<UButton
v-if="step.action === 'create-api-key'"
to="/settings/users"
icon="i-lucide-key-round"
size="sm"
variant="soft"
>
Create an API key
</UButton>
<CodeBlock v-if="step.code" :code="step.code" :lang="step.lang" />
</div>
</div>
Expand Down Expand Up @@ -174,7 +208,7 @@ const goFurtherOpen = ref(false);
auto-injects the reporter and chains the global setup in one call. It also registers the run on the
dashboard <em>before</em> your
<code class="text-xs bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded">globalSetup</code> runs, so the
dashboard shows an initialising state during setup.
dashboard shows an initializing state during setup.
</p>
<CodeBlock :code="wrapConfigCode" lang="typescript" />
</div>
Expand Down
9 changes: 7 additions & 2 deletions application/app/components/test-case/TestCaseAiCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -139,10 +139,15 @@ const showResult = computed(
() => diagnosis.value && (diagnosis.value.status === 'completed' || diagnosis.value.status === 'failed'),
);
const showDiagnoseButton = computed(
() => !diagnosis.value || diagnosis.value.status === 'failed' || (diagnosis.value.status === 'running' && isStale(diagnosis.value)),
() =>
!diagnosis.value ||
diagnosis.value.status === 'failed' ||
(diagnosis.value.status === 'running' && isStale(diagnosis.value)),
);
/** A fresh 'running' row: a diagnosis is genuinely in flight (this or another session). */
const showRunning = computed(() => diagnosis.value?.status === 'running' && !isStale(diagnosis.value) && !posting.value);
const showRunning = computed(
() => diagnosis.value?.status === 'running' && !isStale(diagnosis.value) && !posting.value,
);
</script>

<template>
Expand Down
10 changes: 4 additions & 6 deletions application/app/components/test-case/TestCaseVerdictCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,9 @@ const squareClass = (status: string) => ({
</NuxtLink>
({{ formatRelativeTime(verdict.lastPass.startTime) }}).
</template>
<template v-else-if="verdict.total > 1"> No passing run in the last {{ verdict.total }} recorded. </template>
<template v-else-if="verdict.total > 1">
No passing run in the last {{ verdict.total }} recorded.
</template>
<template v-else> First recorded run of this test. </template>
</template>
<template v-else-if="testCase?.status === 'passed'"> This execution passed. </template>
Expand All @@ -151,11 +153,7 @@ const squareClass = (status: string) => ({
<div v-if="strip.length > 1">
<p class="text-xs text-gray-400 mb-1">Recent runs (oldest → newest)</p>
<div class="flex items-center gap-1 flex-wrap">
<UTooltip
v-for="point in strip"
:key="point.id"
:text="`Run #${point.runId}: ${point.status}`"
>
<UTooltip v-for="point in strip" :key="point.id" :text="`Run #${point.runId}: ${point.status}`">
<NuxtLink
:to="`/test-run-cases/${point.id}`"
class="size-3.5 rounded-sm inline-block transition-colors"
Expand Down
16 changes: 13 additions & 3 deletions application/app/components/test-case/TestSourceStack.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ function rows(snippet: string) {

<template>
<div class="space-y-2">
<div v-for="(frame, i) in frames" :key="`${frame.file}:${frame.line}:${i}`" class="rounded-lg border border-default overflow-hidden">
<div
v-for="(frame, i) in frames"
:key="`${frame.file}:${frame.line}:${i}`"
class="rounded-lg border border-default overflow-hidden"
>
<div class="flex items-center gap-2 px-3 py-1.5 bg-elevated/40 border-b border-default text-xs">
<UIcon
:name="i === 0 ? 'i-lucide-circle-x' : 'i-lucide-corner-left-up'"
Expand All @@ -38,8 +42,14 @@ function rows(snippet: string) {
v-for="(row, j) in rows(frame.snippet)"
:key="j"
class="px-3 whitespace-pre"
:class="row.failing ? 'bg-red-50 dark:bg-red-950/30 text-red-700 dark:text-red-300 font-medium' : 'text-gray-600 dark:text-gray-400'"
>{{ row.text }}</div>
:class="
row.failing
? 'bg-red-50 dark:bg-red-950/30 text-red-700 dark:text-red-300 font-medium'
: 'text-gray-600 dark:text-gray-400'
"
>
{{ row.text }}
</div>
</div>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion application/app/demo/db.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ async function initialize(): Promise<void> {
}

/**
* Returns the singleton in-browser Drizzle instance, initialising it on
* Returns the singleton in-browser Drizzle instance, initializing it on
* first call (fetching the seed SQL and opening IndexedDB).
*/
export async function getDemoDb(): Promise<DemoDB> {
Expand Down
30 changes: 28 additions & 2 deletions application/app/pages/docs.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,25 @@ const isDemo = config.public.demoMode;
const specUrl = isDemo ? '/demo/_openapi.json' : '/_openapi.json';
const container = ref<HTMLDivElement>();

// The interactive reference is loaded from a CDN; a restricted/offline network
// must fall back to a plain link to the raw spec instead of a blank page.
const status = ref<'loading' | 'ready' | 'error'>('loading');
const SCRIPT_TIMEOUT_MS = 8000;

useHead({
title: 'API Reference — Piwi Dashboard',
});

onMounted(async () => {
onMounted(() => {
const timeout = setTimeout(() => {
if (status.value === 'loading') status.value = 'error';
}, SCRIPT_TIMEOUT_MS);

const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/@scalar/api-reference';
script.async = true;
script.onload = () => {
clearTimeout(timeout);
const S = (window as unknown as Record<string, unknown>).Scalar as
| { createApiReference: (element: HTMLElement, config: Record<string, unknown>) => void }
| undefined;
Expand All @@ -27,15 +37,31 @@ onMounted(async () => {
'REST API for storing and querying Playwright test results, traces, failure diagnoses, and project statistics.',
},
});
status.value = 'ready';
} else {
status.value = 'error';
}
};
script.onerror = () => {
clearTimeout(timeout);
status.value = 'error';
};
document.head.appendChild(script);
});
</script>

<template>
<ClientOnly>
<div ref="container" class="scalar-container" />
<div v-if="status === 'error'" class="flex flex-col items-center justify-center h-screen gap-3 text-center px-4">
<UIcon name="i-lucide-circle-alert" class="size-6 text-red-400" />
<p class="text-sm text-gray-400 max-w-sm">
Couldn't load the interactive API reference (it's fetched from a CDN, which may be unreachable on this network).
</p>
<UButton :to="specUrl" target="_blank" size="sm" variant="outline" icon="i-lucide-file-json">
View the raw OpenAPI spec
</UButton>
</div>
<div v-show="status !== 'error'" ref="container" class="scalar-container" />
<template #fallback>
<div class="flex items-center justify-center h-screen text-gray-400">Loading API reference...</div>
</template>
Expand Down
Loading