Compare commits

...

2 Commits

Author SHA1 Message Date
Gregor Vostrak
68e369811c add e2e tests for shared reports 2025-08-14 16:24:46 +02:00
Constantin Graf
da98e0571c Add on premise build 2025-08-12 16:59:52 +02:00
4 changed files with 1658 additions and 0 deletions

216
.github/workflows/build-onpremise.yml vendored Normal file
View File

@@ -0,0 +1,216 @@
on:
push:
branches:
- main
- develop
tags:
- '*'
pull_request:
paths:
- '.github/workflows/build-onpremise.yml'
- 'docker/prod/**'
workflow_dispatch:
permissions:
packages: write
contents: read
attestations: write
id-token: write
env:
DOCKER_REPO: registry.on-premise.solidtime.io/solidtime/solidtime
name: Build - On Premise
jobs:
build:
strategy:
matrix:
include:
- runs-on: "ubuntu-24.04-arm"
platform: "linux/arm64"
- runs-on: "ubuntu-24.04"
platform: "linux/amd64"
runs-on: ${{ matrix.runs-on }}
timeout-minutes: 90
steps:
- name: "Check out code"
uses: actions/checkout@v4
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
- name: "Get build"
id: release-build
run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT"
- name: "Get Previous tag (normal push)"
id: previoustag
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
uses: "WyriHaximus/github-action-get-previous-tag@v1"
with:
prefix: "v"
- name: "Get version"
id: release-version
run: |
if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then
if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then
version=$(echo "${{ steps.previoustag.outputs.tag }}" | cut -c 2-)
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
else
echo "ERROR: No previous tag found";
exit 1;
fi
else
version=$(echo "${{ github.ref }}" | cut -c 12-)
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
fi
- name: "Copy .env template for production"
run: |
cp .env.production .env
rm .env.production .env.ci .env.example
- name: "Add version to .env"
run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.release-version.outputs.app_version }}/g' .env
- name: "Add build to .env"
run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.release-build.outputs.build }}/g' .env
- name: "Output .env"
run: cat .env
- name: "Setup PHP with PECL extension"
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, dom, fileinfo, pgsql
- name: "Install dependencies"
run: composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
- name: "Use Node.js"
uses: actions/setup-node@v4
with:
node-version: '20.x'
- name: "Checkout invoicing extension"
uses: actions/checkout@v4
with:
repository: solidtime-io/extension-invoicing
path: extensions/Invoicing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
- name: "Install composer dependencies in invoicing extension"
run: cd extensions/Invoicing && composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
- name: "Install npm dependencies in invoicing extension"
run: cd extensions/Invoicing && npm ci
- name: "Activate invoicing extension"
run: php artisan module:enable Invoicing
- name: "Install npm dependencies"
run: npm ci
- name: "Build"
run: npm run build
- name: "Prepare"
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: "Docker meta"
id: "meta"
uses: docker/metadata-action@v5
with:
images: |
${{ env.DOCKER_REPO }}
- name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v3
with:
registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }}
password: ${{ secrets.ONPREMISE_TOKEN }}
- name: "Set up QEMU"
uses: docker/setup-qemu-action@v3
- name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v3
- name: "Build and push by digest"
id: build
uses: docker/build-push-action@v6
with:
context: .
file: docker/prod/Dockerfile
build-args: |
DOCKER_FILES_BASE_PATH=docker/prod/
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,"name=${{ env.DOCKER_REPO }}",push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha
cache-to: type=gha,mode=max
- name: "Export digest"
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: "Upload digest"
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
timeout-minutes: 90
needs:
- build
steps:
- name: "Download digests"
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v3
with:
registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }}
password: ${{ secrets.ONPREMISE_TOKEN }}
- name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v3
- name: "Docker meta"
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.DOCKER_REPO }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: "Create manifest list and push"
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
- name: "Inspect image"
run: |
docker buildx imagetools inspect ${{ env.DOCKER_REPO }}:${{ steps.meta.outputs.version }}

View File

@@ -0,0 +1,508 @@
import { expect, Page, Browser } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
async function goToSharedReports(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/reporting/shared');
}
async function goToReporting(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/reporting');
}
async function createTimeEntryWithProject(page: Page, projectName: string, duration: string) {
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(projectName);
await page.getByRole('dialog').getByRole('button', { name: 'Create Project' }).click();
await page.getByText(projectName).waitFor({ state: 'visible' });
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await page.getByRole('button', { name: 'Manual time entry' }).click();
await page.getByTestId('time_entry_description').fill(`Time entry for ${projectName}`);
await page.getByRole('button', { name: 'No Project' }).click();
await page.getByText(projectName).click();
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
await Promise.all([
page.getByRole('button', { name: 'Create Time Entry' }).click(),
page.waitForResponse(
(response) => response.url().includes('/time-entries') && response.status() === 201
),
]);
}
async function createTimeEntryWithTag(page: Page, tagName: string, duration: string) {
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await page.getByRole('button', { name: 'Manual time entry' }).click();
await page.getByTestId('time_entry_description').fill(`Time entry with tag ${tagName}`);
await page.getByRole('button', { name: 'Tags' }).click();
await page.getByText('Create new tag').click();
await page.getByPlaceholder('Tag Name').fill(tagName);
await page.getByRole('button', { name: 'Create Tag' }).click();
await page.waitForLoadState('networkidle');
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
await page.getByRole('button', { name: 'Create Time Entry' }).click();
}
async function createTimeEntryWithBillableStatus(
page: Page,
isBillable: boolean,
duration: string
) {
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await page.getByRole('button', { name: 'Manual time entry' }).click();
await page
.getByTestId('time_entry_description')
.fill(`Time entry ${isBillable ? 'billable' : 'non-billable'}`);
await page.getByRole('button', { name: 'Non-Billable' }).click();
if (!isBillable) {
await page.getByRole('option', { name: 'Non Billable', exact: true }).click();
} else {
await page.getByRole('option', { name: 'Billable', exact: true }).click();
}
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
await page.getByRole('button', { name: 'Create Time Entry' }).click();
}
async function createReport(
page: Page,
reportName: string,
options: {
projectFilter?: string;
tagFilter?: string;
billableFilter?: 'billable' | 'non-billable' | 'all';
timeRange?: { start: string; end: string };
} = {}
) {
await goToReporting(page);
await page.waitForLoadState('networkidle');
// Apply filters if specified
if (options.projectFilter) {
await page.getByRole('button', { name: 'Project' }).nth(0).click();
await page.getByText(options.projectFilter).click();
await page.keyboard.press('Escape');
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
}
if (options.tagFilter) {
await page.getByRole('button', { name: 'Tags' }).click();
await page.getByText(options.tagFilter).click();
await page.keyboard.press('Escape');
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
}
if (options.billableFilter && options.billableFilter !== 'all') {
await page.getByRole('button', { name: 'Billable' }).click();
if (options.billableFilter === 'billable') {
await page.getByRole('option', { name: 'Billable', exact: true }).click();
} else {
await page.getByRole('option', { name: 'Non Billable', exact: true }).click();
}
await page.keyboard.press('Escape');
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
}
// Set custom time range if specified
if (options.timeRange) {
await page.getByRole('button', { name: 'This Week' }).click();
await page.getByRole('option', { name: 'Custom Range' }).click();
await page.locator('input[name="startDate"]').fill(options.timeRange.start);
await page.locator('input[name="endDate"]').fill(options.timeRange.end);
await page.getByRole('button', { name: 'Apply' }).click();
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
}
await page.waitForLoadState('networkidle');
// Save the report
await page.getByRole('button', { name: 'Save Report' }).click();
await page.getByLabel('Report Name').fill(reportName);
await page.getByRole('dialog').getByRole('button', { name: 'Create Report' }).click();
await page.waitForLoadState('networkidle');
}
async function makeReportPublic(page: Page, reportName: string): Promise<string> {
await goToSharedReports(page);
await page.waitForLoadState('networkidle');
// Find the report row and click the edit button
const reportRow = page.locator('tr').filter({ hasText: reportName });
await reportRow.getByRole('button', { name: 'Edit' }).click();
// Make the report public
await page.getByRole('switch', { name: 'Make report public' }).click();
// Wait for the API response
await page.waitForResponse(
(response) => response.url().includes('/reports/') && response.status() === 200
);
// Save the changes
await page.getByRole('button', { name: 'Save' }).click();
await page.waitForLoadState('networkidle');
// Get the public URL
const copyButton = reportRow.getByRole('button', { name: 'Copy URL' });
await copyButton.click();
// Extract the URL from clipboard or from the button's data attribute
const publicUrl = await page.evaluate(() => navigator.clipboard.readText());
return publicUrl;
}
async function createUnauthenticatedPage(browser: Browser): Promise<Page> {
const context = await browser.newContext();
const page = await context.newPage();
return page;
}
test('access public shared report without authentication', async ({ page, browser }) => {
const projectName = 'Public Access Project ' + Math.floor(Math.random() * 10000);
const reportName = 'Public Access Report ' + Math.floor(Math.random() * 10000);
// Create test data with authenticated user
await createTimeEntryWithProject(page, projectName, '2h 30min');
// Create and make report public
await createReport(page, reportName, { projectFilter: projectName });
const publicUrl = await makeReportPublic(page, reportName);
// Create unauthenticated page
const unauthenticatedPage = await createUnauthenticatedPage(browser);
// Access the public report URL
await unauthenticatedPage.goto(publicUrl);
await unauthenticatedPage.waitForLoadState('networkidle');
// Verify the report is accessible and displays data
await expect(unauthenticatedPage.getByText(reportName)).toBeVisible();
await expect(unauthenticatedPage.getByText(projectName)).toBeVisible();
await expect(unauthenticatedPage.getByText('2h 30min')).toBeVisible();
// Verify no authentication elements are present
await expect(unauthenticatedPage.getByRole('button', { name: 'Login' })).not.toBeVisible();
await expect(unauthenticatedPage.getByRole('button', { name: 'Register' })).not.toBeVisible();
await unauthenticatedPage.close();
});
test('access public shared report with project filter shows filtered data', async ({
page,
browser,
}) => {
const projectName = 'Filtered Project ' + Math.floor(Math.random() * 10000);
const otherProjectName = 'Other Project ' + Math.floor(Math.random() * 10000);
const reportName = 'Filtered Report ' + Math.floor(Math.random() * 10000);
// Create test data for two projects
await createTimeEntryWithProject(page, projectName, '1h 30min');
await createTimeEntryWithProject(page, otherProjectName, '45min');
// Create and make report public with project filter
await createReport(page, reportName, { projectFilter: projectName });
const publicUrl = await makeReportPublic(page, reportName);
// Create unauthenticated page
const unauthenticatedPage = await createUnauthenticatedPage(browser);
// Access the public report URL
await unauthenticatedPage.goto(publicUrl);
await unauthenticatedPage.waitForLoadState('networkidle');
// Verify only filtered project data is shown
await expect(unauthenticatedPage.getByText(projectName)).toBeVisible();
await expect(unauthenticatedPage.getByText(otherProjectName)).not.toBeVisible();
await expect(unauthenticatedPage.getByText('1h 30min')).toBeVisible();
await expect(unauthenticatedPage.getByText('45min')).not.toBeVisible();
await unauthenticatedPage.close();
});
test('access public shared report with tag filter shows filtered data', async ({
page,
browser,
}) => {
const tagName = 'PublicTag' + Math.floor(Math.random() * 10000);
const otherTagName = 'PrivateTag' + Math.floor(Math.random() * 10000);
const reportName = 'Tag Filtered Report ' + Math.floor(Math.random() * 10000);
// Create test data for two tags
await createTimeEntryWithTag(page, tagName, '2h');
await createTimeEntryWithTag(page, otherTagName, '1h');
// Create and make report public with tag filter
await createReport(page, reportName, { tagFilter: tagName });
const publicUrl = await makeReportPublic(page, reportName);
// Create unauthenticated page
const unauthenticatedPage = await createUnauthenticatedPage(browser);
// Access the public report URL
await unauthenticatedPage.goto(publicUrl);
await unauthenticatedPage.waitForLoadState('networkidle');
// Verify only filtered tag data is shown
await expect(unauthenticatedPage.getByText(tagName)).toBeVisible();
await expect(unauthenticatedPage.getByText(otherTagName)).not.toBeVisible();
await expect(unauthenticatedPage.getByText('2h 00min')).toBeVisible();
await expect(unauthenticatedPage.getByText('1h 00min')).not.toBeVisible();
await unauthenticatedPage.close();
});
test('access public shared report with billable filter shows filtered data', async ({
page,
browser,
}) => {
const reportName = 'Billable Filtered Report ' + Math.floor(Math.random() * 10000);
// Create test data for billable and non-billable entries
await createTimeEntryWithBillableStatus(page, true, '3h');
await createTimeEntryWithBillableStatus(page, false, '1h 30min');
// Create and make report public with billable filter
await createReport(page, reportName, { billableFilter: 'billable' });
const publicUrl = await makeReportPublic(page, reportName);
// Create unauthenticated page
const unauthenticatedPage = await createUnauthenticatedPage(browser);
// Access the public report URL
await unauthenticatedPage.goto(publicUrl);
await unauthenticatedPage.waitForLoadState('networkidle');
// Verify only billable data is shown
await expect(unauthenticatedPage.getByText('3h 00min')).toBeVisible();
await expect(unauthenticatedPage.getByText('1h 30min')).not.toBeVisible();
await unauthenticatedPage.close();
});
test('access public shared report with custom time range shows filtered data', async ({
page,
browser,
}) => {
const projectName = 'TimeRange Project ' + Math.floor(Math.random() * 10000);
const reportName = 'TimeRange Report ' + Math.floor(Math.random() * 10000);
// Create test data
await createTimeEntryWithProject(page, projectName, '2h 15min');
// Create and make report public with custom time range
const startDate = new Date();
startDate.setDate(startDate.getDate() - 7);
const endDate = new Date();
await createReport(page, reportName, {
projectFilter: projectName,
timeRange: {
start: startDate.toISOString().split('T')[0],
end: endDate.toISOString().split('T')[0],
},
});
const publicUrl = await makeReportPublic(page, reportName);
// Create unauthenticated page
const unauthenticatedPage = await createUnauthenticatedPage(browser);
// Access the public report URL
await unauthenticatedPage.goto(publicUrl);
await unauthenticatedPage.waitForLoadState('networkidle');
// Verify the data is shown within the time range
await expect(unauthenticatedPage.getByText(projectName)).toBeVisible();
await expect(unauthenticatedPage.getByText('2h 15min')).toBeVisible();
await unauthenticatedPage.close();
});
test('access public shared report with multiple filters shows correctly filtered data', async ({
page,
browser,
}) => {
const projectName = 'MultiFilter Project ' + Math.floor(Math.random() * 10000);
const tagName = 'MultiTag' + Math.floor(Math.random() * 10000);
const reportName = 'MultiFilter Report ' + Math.floor(Math.random() * 10000);
// Create test data
await createTimeEntryWithProject(page, projectName, '1h');
// Create a time entry with project, tag, and billable status
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await page.getByRole('button', { name: 'Manual time entry' }).click();
await page.getByTestId('time_entry_description').fill('Multi-filter entry');
// Set project
await page.getByRole('button', { name: 'No Project' }).click();
await page.getByText(projectName).click();
// Set tag
await page.getByRole('button', { name: 'Tags' }).click();
await page.getByText('Create new tag').click();
await page.getByPlaceholder('Tag Name').fill(tagName);
await page.getByRole('button', { name: 'Create Tag' }).click();
await page.waitForLoadState('networkidle');
// Set as billable
await page.getByRole('button', { name: 'Non-Billable' }).click();
await page.getByRole('option', { name: 'Billable', exact: true }).click();
await page.locator('[role="dialog"] input[name="Duration"]').fill('2h 30min');
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
await page.getByRole('button', { name: 'Create Time Entry' }).click();
// Create and make report public with multiple filters
await createReport(page, reportName, {
projectFilter: projectName,
tagFilter: tagName,
billableFilter: 'billable',
});
const publicUrl = await makeReportPublic(page, reportName);
// Create unauthenticated page
const unauthenticatedPage = await createUnauthenticatedPage(browser);
// Access the public report URL
await unauthenticatedPage.goto(publicUrl);
await unauthenticatedPage.waitForLoadState('networkidle');
// Verify the filtered data is shown
await expect(unauthenticatedPage.getByText(projectName)).toBeVisible();
await expect(unauthenticatedPage.getByText(tagName)).toBeVisible();
await expect(unauthenticatedPage.getByText('2h 30min')).toBeVisible();
await unauthenticatedPage.close();
});
test('cannot access private shared report without authentication', async ({ page, browser }) => {
const projectName = 'Private Project ' + Math.floor(Math.random() * 10000);
const reportName = 'Private Report ' + Math.floor(Math.random() * 10000);
// Create test data
await createTimeEntryWithProject(page, projectName, '1h');
// Create report but don't make it public
await createReport(page, reportName, { projectFilter: projectName });
// Try to access the shared reports page without authentication
const unauthenticatedPage = await createUnauthenticatedPage(browser);
await unauthenticatedPage.goto(PLAYWRIGHT_BASE_URL + '/reporting/shared');
// Should redirect to login or show unauthorized
await expect(unauthenticatedPage.getByRole('button', { name: 'Login' })).toBeVisible();
await unauthenticatedPage.close();
});
test('cannot access public shared report with invalid share secret', async ({ page, browser }) => {
const projectName = 'Invalid Secret Project ' + Math.floor(Math.random() * 10000);
const reportName = 'Invalid Secret Report ' + Math.floor(Math.random() * 10000);
// Create test data
await createTimeEntryWithProject(page, projectName, '1h');
// Create and make report public
await createReport(page, reportName, { projectFilter: projectName });
await makeReportPublic(page, reportName);
// Create unauthenticated page
const unauthenticatedPage = await createUnauthenticatedPage(browser);
// Try to access with invalid share secret
const invalidUrl = PLAYWRIGHT_BASE_URL + '/shared-report#invalid-secret-123';
await unauthenticatedPage.goto(invalidUrl);
// Should show error or not found
await expect(unauthenticatedPage.getByText('Report not found')).toBeVisible();
await unauthenticatedPage.close();
});
test('public shared report displays charts and visualizations', async ({ page, browser }) => {
const projectName = 'Chart Project ' + Math.floor(Math.random() * 10000);
const reportName = 'Chart Report ' + Math.floor(Math.random() * 10000);
// Create test data
await createTimeEntryWithProject(page, projectName, '4h');
// Create and make report public
await createReport(page, reportName, { projectFilter: projectName });
const publicUrl = await makeReportPublic(page, reportName);
// Create unauthenticated page
const unauthenticatedPage = await createUnauthenticatedPage(browser);
// Access the public report URL
await unauthenticatedPage.goto(publicUrl);
await unauthenticatedPage.waitForLoadState('networkidle');
// Verify charts are displayed
await expect(unauthenticatedPage.locator('canvas')).toBeVisible();
// Verify summary statistics
await expect(unauthenticatedPage.getByText('Total Time')).toBeVisible();
await expect(unauthenticatedPage.getByText('4h 00min')).toBeVisible();
await unauthenticatedPage.close();
});
test('public shared report shows correct report metadata', async ({ page, browser }) => {
const projectName = 'Metadata Project ' + Math.floor(Math.random() * 10000);
const reportName = 'Metadata Report ' + Math.floor(Math.random() * 10000);
const description = 'This is a public report showing project data';
// Create test data
await createTimeEntryWithProject(page, projectName, '1h 45min');
// Create report
await createReport(page, reportName, { projectFilter: projectName });
// Add description and make public
await goToSharedReports(page);
await page.waitForLoadState('networkidle');
const reportRow = page.locator('tr').filter({ hasText: reportName });
await reportRow.getByRole('button', { name: 'Edit' }).click();
await page.getByLabel('Description').fill(description);
await page.getByRole('switch', { name: 'Make report public' }).click();
await page.waitForResponse(
(response) => response.url().includes('/reports/') && response.status() === 200
);
await page.getByRole('button', { name: 'Save' }).click();
await page.waitForLoadState('networkidle');
// Get public URL
const copyButton = reportRow.getByRole('button', { name: 'Copy URL' });
await copyButton.click();
const publicUrl = await page.evaluate(() => navigator.clipboard.readText());
// Create unauthenticated page
const unauthenticatedPage = await createUnauthenticatedPage(browser);
// Access the public report URL
await unauthenticatedPage.goto(publicUrl);
await unauthenticatedPage.waitForLoadState('networkidle');
// Verify report metadata
await expect(unauthenticatedPage.getByText(reportName)).toBeVisible();
await expect(unauthenticatedPage.getByText(description)).toBeVisible();
await unauthenticatedPage.close();
});

View File

@@ -0,0 +1,542 @@
import { expect, Page, Browser } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
async function goToSharedReports(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/reporting/shared');
}
async function goToReporting(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/reporting');
}
async function createTimeEntryWithProject(
page: Page,
projectName: string,
duration: string,
description: string = ''
) {
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(projectName);
await page.getByRole('dialog').getByRole('button', { name: 'Create Project' }).click();
await page.getByText(projectName).waitFor({ state: 'visible' });
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await page.getByRole('button', { name: 'Manual time entry' }).click();
await page
.getByTestId('time_entry_description')
.fill(description || `Time entry for ${projectName}`);
await page.getByRole('button', { name: 'No Project' }).click();
await page.getByText(projectName).click();
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
await Promise.all([
page.getByRole('button', { name: 'Create Time Entry' }).click(),
page.waitForResponse(
(response) => response.url().includes('/time-entries') && response.status() === 201
),
]);
}
async function createTimeEntryWithTag(
page: Page,
tagName: string,
duration: string,
description: string = ''
) {
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await page.getByRole('button', { name: 'Manual time entry' }).click();
await page
.getByTestId('time_entry_description')
.fill(description || `Time entry with tag ${tagName}`);
await page.getByRole('button', { name: 'Tags' }).click();
await page.getByText('Create new tag').click();
await page.getByPlaceholder('Tag Name').fill(tagName);
await page.getByRole('button', { name: 'Create Tag' }).click();
await page.waitForLoadState('networkidle');
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
await page.getByRole('button', { name: 'Create Time Entry' }).click();
}
async function createTimeEntryWithBillableStatus(
page: Page,
isBillable: boolean,
duration: string,
description: string = ''
) {
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await page.getByRole('button', { name: 'Manual time entry' }).click();
await page
.getByTestId('time_entry_description')
.fill(description || `Time entry ${isBillable ? 'billable' : 'non-billable'}`);
await page.getByRole('button', { name: 'Non-Billable' }).click();
if (!isBillable) {
await page.getByRole('option', { name: 'Non Billable', exact: true }).click();
} else {
await page.getByRole('option', { name: 'Billable', exact: true }).click();
}
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
await page.getByRole('button', { name: 'Create Time Entry' }).click();
}
async function createReport(
page: Page,
reportName: string,
options: {
projectFilter?: string;
tagFilter?: string;
billableFilter?: 'billable' | 'non-billable' | 'all';
timeRange?: { start: string; end: string };
} = {}
) {
await goToReporting(page);
await page.waitForLoadState('networkidle');
// Apply filters if specified
if (options.projectFilter) {
await page.getByRole('button', { name: 'Project' }).nth(0).click();
await page.getByText(options.projectFilter).click();
await page.keyboard.press('Escape');
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
}
if (options.tagFilter) {
await page.getByRole('button', { name: 'Tags' }).click();
await page.getByText(options.tagFilter).click();
await page.keyboard.press('Escape');
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
}
if (options.billableFilter && options.billableFilter !== 'all') {
await page.getByRole('button', { name: 'Billable' }).click();
if (options.billableFilter === 'billable') {
await page.getByRole('option', { name: 'Billable', exact: true }).click();
} else {
await page.getByRole('option', { name: 'Non Billable', exact: true }).click();
}
await page.keyboard.press('Escape');
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
}
// Set custom time range if specified
if (options.timeRange) {
await page.getByRole('button', { name: 'This Week' }).click();
await page.getByRole('option', { name: 'Custom Range' }).click();
await page.locator('input[name="startDate"]').fill(options.timeRange.start);
await page.locator('input[name="endDate"]').fill(options.timeRange.end);
await page.getByRole('button', { name: 'Apply' }).click();
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
}
await page.waitForLoadState('networkidle');
// Save the report
await page.getByRole('button', { name: 'Save Report' }).click();
await page.getByLabel('Report Name').fill(reportName);
await page.getByRole('dialog').getByRole('button', { name: 'Create Report' }).click();
await page.waitForLoadState('networkidle');
}
async function makeReportPublic(page: Page, reportName: string): Promise<string> {
await goToSharedReports(page);
await page.waitForLoadState('networkidle');
// Find the report row and click the edit button
const reportRow = page.locator('tr').filter({ hasText: reportName });
await reportRow.getByRole('button', { name: 'Edit' }).click();
// Make the report public
await page.getByRole('switch', { name: 'Make report public' }).click();
// Wait for the API response
await page.waitForResponse(
(response) => response.url().includes('/reports/') && response.status() === 200
);
// Save the changes
await page.getByRole('button', { name: 'Save' }).click();
await page.waitForLoadState('networkidle');
// Get the public URL
const copyButton = reportRow.getByRole('button', { name: 'Copy URL' });
await copyButton.click();
// Extract the URL from clipboard or from the button's data attribute
const publicUrl = await page.evaluate(() => navigator.clipboard.readText());
return publicUrl;
}
async function createUnauthenticatedPage(browser: Browser): Promise<Page> {
const context = await browser.newContext();
const page = await context.newPage();
return page;
}
test('verify shared report data accuracy with project filter', async ({ page, browser }) => {
const projectName = 'Accuracy Project ' + Math.floor(Math.random() * 10000);
const otherProjectName = 'Other Accuracy Project ' + Math.floor(Math.random() * 10000);
const reportName = 'Accuracy Report ' + Math.floor(Math.random() * 10000);
// Create test data with specific durations
await createTimeEntryWithProject(page, projectName, '2h 30min', 'Task 1');
await createTimeEntryWithProject(page, projectName, '1h 15min', 'Task 2');
await createTimeEntryWithProject(page, otherProjectName, '3h', 'Other task');
// Create and make report public with project filter
await createReport(page, reportName, { projectFilter: projectName });
const publicUrl = await makeReportPublic(page, reportName);
// Verify data in authenticated reporting view
await goToReporting(page);
await page.getByRole('button', { name: 'Project' }).nth(0).click();
await page.getByText(projectName).click();
await page.keyboard.press('Escape');
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
// Note expected total: 2h 30min + 1h 15min = 3h 45min
await expect(page.getByText('3h 45min')).toBeVisible();
// Verify same data in public view
const unauthenticatedPage = await createUnauthenticatedPage(browser);
await unauthenticatedPage.goto(publicUrl);
await unauthenticatedPage.waitForLoadState('networkidle');
// Verify total time matches
await expect(unauthenticatedPage.getByText('3h 45min')).toBeVisible();
await expect(unauthenticatedPage.getByText(projectName)).toBeVisible();
await expect(unauthenticatedPage.getByText('Task 1')).toBeVisible();
await expect(unauthenticatedPage.getByText('Task 2')).toBeVisible();
await expect(unauthenticatedPage.getByText(otherProjectName)).not.toBeVisible();
await unauthenticatedPage.close();
});
test('verify shared report data accuracy with tag filter', async ({ page, browser }) => {
const tagName = 'AccuracyTag' + Math.floor(Math.random() * 10000);
const otherTagName = 'OtherTag' + Math.floor(Math.random() * 10000);
const reportName = 'Tag Accuracy Report ' + Math.floor(Math.random() * 10000);
// Create test data with specific durations
await createTimeEntryWithTag(page, tagName, '1h 30min', 'Tagged task 1');
await createTimeEntryWithTag(page, tagName, '2h 15min', 'Tagged task 2');
await createTimeEntryWithTag(page, otherTagName, '45min', 'Other tagged task');
// Create and make report public with tag filter
await createReport(page, reportName, { tagFilter: tagName });
const publicUrl = await makeReportPublic(page, reportName);
// Verify data in authenticated reporting view
await goToReporting(page);
await page.getByRole('button', { name: 'Tags' }).click();
await page.getByText(tagName).click();
await page.keyboard.press('Escape');
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
// Note expected total: 1h 30min + 2h 15min = 3h 45min
await expect(page.getByText('3h 45min')).toBeVisible();
// Verify same data in public view
const unauthenticatedPage = await createUnauthenticatedPage(browser);
await unauthenticatedPage.goto(publicUrl);
await unauthenticatedPage.waitForLoadState('networkidle');
// Verify total time matches
await expect(unauthenticatedPage.getByText('3h 45min')).toBeVisible();
await expect(unauthenticatedPage.getByText(tagName)).toBeVisible();
await expect(unauthenticatedPage.getByText('Tagged task 1')).toBeVisible();
await expect(unauthenticatedPage.getByText('Tagged task 2')).toBeVisible();
await expect(unauthenticatedPage.getByText(otherTagName)).not.toBeVisible();
await unauthenticatedPage.close();
});
test('verify shared report data accuracy with billable filter', async ({ page, browser }) => {
const reportName = 'Billable Accuracy Report ' + Math.floor(Math.random() * 10000);
// Create test data with specific durations
await createTimeEntryWithBillableStatus(page, true, '2h', 'Billable task 1');
await createTimeEntryWithBillableStatus(page, true, '1h 30min', 'Billable task 2');
await createTimeEntryWithBillableStatus(page, false, '45min', 'Non-billable task');
// Create and make report public with billable filter
await createReport(page, reportName, { billableFilter: 'billable' });
const publicUrl = await makeReportPublic(page, reportName);
// Verify data in authenticated reporting view
await goToReporting(page);
await page.getByRole('button', { name: 'Billable' }).click();
await page.getByRole('option', { name: 'Billable', exact: true }).click();
await page.keyboard.press('Escape');
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
// Note expected total: 2h + 1h 30min = 3h 30min
await expect(page.getByText('3h 30min')).toBeVisible();
// Verify same data in public view
const unauthenticatedPage = await createUnauthenticatedPage(browser);
await unauthenticatedPage.goto(publicUrl);
await unauthenticatedPage.waitForLoadState('networkidle');
// Verify total time matches
await expect(unauthenticatedPage.getByText('3h 30min')).toBeVisible();
await expect(unauthenticatedPage.getByText('Billable task 1')).toBeVisible();
await expect(unauthenticatedPage.getByText('Billable task 2')).toBeVisible();
await expect(unauthenticatedPage.getByText('Non-billable task')).not.toBeVisible();
await unauthenticatedPage.close();
});
test('verify shared report data accuracy with non-billable filter', async ({ page, browser }) => {
const reportName = 'Non-Billable Accuracy Report ' + Math.floor(Math.random() * 10000);
// Create test data with specific durations
await createTimeEntryWithBillableStatus(page, false, '1h 45min', 'Non-billable task 1');
await createTimeEntryWithBillableStatus(page, false, '2h 30min', 'Non-billable task 2');
await createTimeEntryWithBillableStatus(page, true, '1h', 'Billable task');
// Create and make report public with non-billable filter
await createReport(page, reportName, { billableFilter: 'non-billable' });
const publicUrl = await makeReportPublic(page, reportName);
// Verify data in authenticated reporting view
await goToReporting(page);
await page.getByRole('button', { name: 'Billable' }).click();
await page.getByRole('option', { name: 'Non Billable', exact: true }).click();
await page.keyboard.press('Escape');
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
// Note expected total: 1h 45min + 2h 30min = 4h 15min
await expect(page.getByText('4h 15min')).toBeVisible();
// Verify same data in public view
const unauthenticatedPage = await createUnauthenticatedPage(browser);
await unauthenticatedPage.goto(publicUrl);
await unauthenticatedPage.waitForLoadState('networkidle');
// Verify total time matches
await expect(unauthenticatedPage.getByText('4h 15min')).toBeVisible();
await expect(unauthenticatedPage.getByText('Non-billable task 1')).toBeVisible();
await expect(unauthenticatedPage.getByText('Non-billable task 2')).toBeVisible();
await expect(unauthenticatedPage.getByText('Billable task')).not.toBeVisible();
await unauthenticatedPage.close();
});
test('verify shared report data accuracy with multiple filters', async ({ page, browser }) => {
const projectName = 'MultiAccuracy Project ' + Math.floor(Math.random() * 10000);
const tagName = 'MultiAccuracyTag' + Math.floor(Math.random() * 10000);
const reportName = 'MultiAccuracy Report ' + Math.floor(Math.random() * 10000);
// Create test data
await createTimeEntryWithProject(page, projectName, '1h', 'Project only');
// Create a time entry with project, tag, and billable status
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await page.getByRole('button', { name: 'Manual time entry' }).click();
await page.getByTestId('time_entry_description').fill('Multi-filter matched entry');
// Set project
await page.getByRole('button', { name: 'No Project' }).click();
await page.getByText(projectName).click();
// Set tag
await page.getByRole('button', { name: 'Tags' }).click();
await page.getByText('Create new tag').click();
await page.getByPlaceholder('Tag Name').fill(tagName);
await page.getByRole('button', { name: 'Create Tag' }).click();
await page.waitForLoadState('networkidle');
// Set as billable
await page.getByRole('button', { name: 'Non-Billable' }).click();
await page.getByRole('option', { name: 'Billable', exact: true }).click();
await page.locator('[role="dialog"] input[name="Duration"]').fill('2h 30min');
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
await page.getByRole('button', { name: 'Create Time Entry' }).click();
// Create another entry that won't match all filters
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await page.getByRole('button', { name: 'Manual time entry' }).click();
await page.getByTestId('time_entry_description').fill('Partial match entry');
// Set same project but different tag and non-billable
await page.getByRole('button', { name: 'No Project' }).click();
await page.getByText(projectName).click();
await page.getByRole('button', { name: 'Tags' }).click();
await page.getByText('Create new tag').click();
await page.getByPlaceholder('Tag Name').fill('DifferentTag');
await page.getByRole('button', { name: 'Create Tag' }).click();
await page.waitForLoadState('networkidle');
await page.locator('[role="dialog"] input[name="Duration"]').fill('1h 15min');
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
await page.getByRole('button', { name: 'Create Time Entry' }).click();
// Create and make report public with multiple filters
await createReport(page, reportName, {
projectFilter: projectName,
tagFilter: tagName,
billableFilter: 'billable',
});
const publicUrl = await makeReportPublic(page, reportName);
// Verify data in authenticated reporting view
await goToReporting(page);
await page.getByRole('button', { name: 'Project' }).nth(0).click();
await page.getByText(projectName).click();
await page.keyboard.press('Escape');
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
await page.getByRole('button', { name: 'Tags' }).click();
await page.getByText(tagName).click();
await page.keyboard.press('Escape');
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
await page.getByRole('button', { name: 'Billable' }).click();
await page.getByRole('option', { name: 'Billable', exact: true }).click();
await page.keyboard.press('Escape');
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
// Should only show the entry that matches all filters (2h 30min)
await expect(page.getByText('2h 30min')).toBeVisible();
// Verify same data in public view
const unauthenticatedPage = await createUnauthenticatedPage(browser);
await unauthenticatedPage.goto(publicUrl);
await unauthenticatedPage.waitForLoadState('networkidle');
// Verify only the matching entry is shown
await expect(unauthenticatedPage.getByText('2h 30min')).toBeVisible();
await expect(unauthenticatedPage.getByText('Multi-filter matched entry')).toBeVisible();
await expect(unauthenticatedPage.getByText('Project only')).not.toBeVisible();
await expect(unauthenticatedPage.getByText('Partial match entry')).not.toBeVisible();
await unauthenticatedPage.close();
});
test('verify shared report data accuracy with time range filter', async ({ page, browser }) => {
const projectName = 'TimeRange Accuracy Project ' + Math.floor(Math.random() * 10000);
const reportName = 'TimeRange Accuracy Report ' + Math.floor(Math.random() * 10000);
// Create test data within date range
await createTimeEntryWithProject(page, projectName, '1h 30min', 'Within range 1');
await createTimeEntryWithProject(page, projectName, '2h 15min', 'Within range 2');
// Create and make report public with time range
const startDate = new Date();
startDate.setDate(startDate.getDate() - 1);
const endDate = new Date();
endDate.setDate(endDate.getDate() + 1);
await createReport(page, reportName, {
projectFilter: projectName,
timeRange: {
start: startDate.toISOString().split('T')[0],
end: endDate.toISOString().split('T')[0],
},
});
const publicUrl = await makeReportPublic(page, reportName);
// Verify data in authenticated reporting view
await goToReporting(page);
await page.getByRole('button', { name: 'Project' }).nth(0).click();
await page.getByText(projectName).click();
await page.keyboard.press('Escape');
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
await page.getByRole('button', { name: 'This Week' }).click();
await page.getByRole('option', { name: 'Custom Range' }).click();
await page.locator('input[name="startDate"]').fill(startDate.toISOString().split('T')[0]);
await page.locator('input[name="endDate"]').fill(endDate.toISOString().split('T')[0]);
await page.getByRole('button', { name: 'Apply' }).click();
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
// Note expected total: 1h 30min + 2h 15min = 3h 45min
await expect(page.getByText('3h 45min')).toBeVisible();
// Verify same data in public view
const unauthenticatedPage = await createUnauthenticatedPage(browser);
await unauthenticatedPage.goto(publicUrl);
await unauthenticatedPage.waitForLoadState('networkidle');
// Verify total time matches
await expect(unauthenticatedPage.getByText('3h 45min')).toBeVisible();
await expect(unauthenticatedPage.getByText('Within range 1')).toBeVisible();
await expect(unauthenticatedPage.getByText('Within range 2')).toBeVisible();
await unauthenticatedPage.close();
});
test('verify shared report shows zero data when no entries match filters', async ({
page,
browser,
}) => {
const projectName = 'NoMatch Project ' + Math.floor(Math.random() * 10000);
const tagName = 'NoMatchTag' + Math.floor(Math.random() * 10000);
const reportName = 'NoMatch Report ' + Math.floor(Math.random() * 10000);
// Create test data that won't match our filters
await createTimeEntryWithProject(page, 'Other Project', '1h', 'Other entry');
// Create and make report public with filters that won't match
await createReport(page, reportName, {
projectFilter: projectName, // This project doesn't exist
tagFilter: tagName, // This tag doesn't exist
});
const publicUrl = await makeReportPublic(page, reportName);
// Verify data in public view shows zero/empty results
const unauthenticatedPage = await createUnauthenticatedPage(browser);
await unauthenticatedPage.goto(publicUrl);
await unauthenticatedPage.waitForLoadState('networkidle');
// Verify no data is shown
await expect(unauthenticatedPage.getByText('0h 00min')).toBeVisible();
await expect(unauthenticatedPage.getByText('No data available')).toBeVisible();
await unauthenticatedPage.close();
});

392
e2e/shared-reports.spec.ts Normal file
View File

@@ -0,0 +1,392 @@
import { expect, Page } from '@playwright/test';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import { test } from '../playwright/fixtures';
async function goToSharedReports(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/reporting/shared');
}
async function goToReporting(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/reporting');
}
async function createTimeEntryWithProject(page: Page, projectName: string, duration: string) {
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
await page.getByRole('button', { name: 'Create Project' }).click();
await page.getByLabel('Project Name').fill(projectName);
await page.getByRole('dialog').getByRole('button', { name: 'Create Project' }).click();
await page.getByText(projectName).waitFor({ state: 'visible' });
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await page.getByRole('button', { name: 'Manual time entry' }).click();
await page.getByTestId('time_entry_description').fill(`Time entry for ${projectName}`);
await page.getByRole('button', { name: 'No Project' }).click();
await page.getByText(projectName).click();
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
await Promise.all([
page.getByRole('button', { name: 'Create Time Entry' }).click(),
page.waitForResponse(
(response) => response.url().includes('/time-entries') && response.status() === 201
),
]);
}
async function createTimeEntryWithTag(page: Page, tagName: string, duration: string) {
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await page.getByRole('button', { name: 'Manual time entry' }).click();
await page.getByTestId('time_entry_description').fill(`Time entry with tag ${tagName}`);
await page.getByRole('button', { name: 'Tags' }).click();
await page.getByText('Create new tag').click();
await page.getByPlaceholder('Tag Name').fill(tagName);
await page.getByRole('button', { name: 'Create Tag' }).click();
await page.waitForLoadState('networkidle');
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
await page.getByRole('button', { name: 'Create Time Entry' }).click();
}
async function createTimeEntryWithBillableStatus(
page: Page,
isBillable: boolean,
duration: string
) {
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await page.getByRole('button', { name: 'Manual time entry' }).click();
await page
.getByTestId('time_entry_description')
.fill(`Time entry ${isBillable ? 'billable' : 'non-billable'}`);
await page.getByRole('button', { name: 'Non-Billable' }).click();
if (!isBillable) {
await page.getByRole('option', { name: 'Non Billable', exact: true }).click();
} else {
await page.getByRole('option', { name: 'Billable', exact: true }).click();
}
await page.locator('[role="dialog"] input[name="Duration"]').fill(duration);
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
await page.getByRole('button', { name: 'Create Time Entry' }).click();
}
async function createReport(
page: Page,
reportName: string,
options: {
projectFilter?: string;
tagFilter?: string;
billableFilter?: 'billable' | 'non-billable' | 'all';
timeRange?: { start: string; end: string };
} = {}
) {
await goToReporting(page);
await page.waitForLoadState('networkidle');
// Apply filters if specified
if (options.projectFilter) {
await page.getByRole('button', { name: 'Project' }).nth(0).click();
await page.getByText(options.projectFilter).click();
await page.keyboard.press('Escape');
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
}
if (options.tagFilter) {
await page.getByRole('button', { name: 'Tags' }).click();
await page.getByText(options.tagFilter).click();
await page.keyboard.press('Escape');
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
}
if (options.billableFilter && options.billableFilter !== 'all') {
await page.getByRole('button', { name: 'Billable' }).click();
if (options.billableFilter === 'billable') {
await page.getByRole('option', { name: 'Billable', exact: true }).click();
} else {
await page.getByRole('option', { name: 'Non Billable', exact: true }).click();
}
await page.keyboard.press('Escape');
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
}
// Set custom time range if specified
if (options.timeRange) {
await page.getByRole('button', { name: 'This Week' }).click();
await page.getByRole('option', { name: 'Custom Range' }).click();
await page.locator('input[name="startDate"]').fill(options.timeRange.start);
await page.locator('input[name="endDate"]').fill(options.timeRange.end);
await page.getByRole('button', { name: 'Apply' }).click();
await page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') && response.status() === 200
);
}
await page.waitForLoadState('networkidle');
// Save the report
await page.getByRole('button', { name: 'Save Report' }).click();
await page.getByLabel('Report Name').fill(reportName);
await page.getByRole('dialog').getByRole('button', { name: 'Create Report' }).click();
await page.waitForLoadState('networkidle');
}
async function makeReportPublic(page: Page, reportName: string): Promise<string> {
await goToSharedReports(page);
await page.waitForLoadState('networkidle');
// Find the report row and click the edit button
const reportRow = page.locator('tr').filter({ hasText: reportName });
await reportRow.getByRole('button', { name: 'Edit' }).click();
// Make the report public
await page.getByRole('switch', { name: 'Make report public' }).click();
// Wait for the API response
await page.waitForResponse(
(response) => response.url().includes('/reports/') && response.status() === 200
);
// Save the changes
await page.getByRole('button', { name: 'Save' }).click();
await page.waitForLoadState('networkidle');
// Get the public URL
const copyButton = reportRow.getByRole('button', { name: 'Copy URL' });
await copyButton.click();
// Extract the URL from clipboard or from the button's data attribute
const publicUrl = await page.evaluate(() => navigator.clipboard.readText());
return publicUrl;
}
test('create shared report with project filter', async ({ page }) => {
const projectName = 'Shared Report Project ' + Math.floor(Math.random() * 10000);
const reportName = 'Project Report ' + Math.floor(Math.random() * 10000);
// Create test data
await createTimeEntryWithProject(page, projectName, '2h');
await createTimeEntryWithProject(page, 'Other Project', '1h');
// Create a report with project filter
await createReport(page, reportName, { projectFilter: projectName });
// Make the report public
const publicUrl = await makeReportPublic(page, reportName);
// Verify the report appears in shared reports list
await expect(page.getByText(reportName)).toBeVisible();
await expect(page.getByText('Public')).toBeVisible();
expect(publicUrl).toContain('/shared-report#');
});
test('create shared report with tag filter', async ({ page }) => {
const tagName = 'SharedTag' + Math.floor(Math.random() * 10000);
const reportName = 'Tag Report ' + Math.floor(Math.random() * 10000);
// Create test data
await createTimeEntryWithTag(page, tagName, '1h 30min');
await createTimeEntryWithTag(page, 'OtherTag', '45min');
// Create a report with tag filter
await createReport(page, reportName, { tagFilter: tagName });
// Make the report public
const publicUrl = await makeReportPublic(page, reportName);
// Verify the report appears in shared reports list
await expect(page.getByText(reportName)).toBeVisible();
await expect(page.getByText('Public')).toBeVisible();
expect(publicUrl).toContain('/shared-report#');
});
test('create shared report with billable filter', async ({ page }) => {
const reportName = 'Billable Report ' + Math.floor(Math.random() * 10000);
// Create test data
await createTimeEntryWithBillableStatus(page, true, '2h');
await createTimeEntryWithBillableStatus(page, false, '1h');
// Create a report with billable filter
await createReport(page, reportName, { billableFilter: 'billable' });
// Make the report public
const publicUrl = await makeReportPublic(page, reportName);
// Verify the report appears in shared reports list
await expect(page.getByText(reportName)).toBeVisible();
await expect(page.getByText('Public')).toBeVisible();
expect(publicUrl).toContain('/shared-report#');
});
test('create shared report with custom time range', async ({ page }) => {
const projectName = 'TimeRange Project ' + Math.floor(Math.random() * 10000);
const reportName = 'TimeRange Report ' + Math.floor(Math.random() * 10000);
// Create test data
await createTimeEntryWithProject(page, projectName, '3h');
// Create a report with custom time range (last 30 days)
const startDate = new Date();
startDate.setDate(startDate.getDate() - 30);
const endDate = new Date();
await createReport(page, reportName, {
projectFilter: projectName,
timeRange: {
start: startDate.toISOString().split('T')[0],
end: endDate.toISOString().split('T')[0],
},
});
// Make the report public
const publicUrl = await makeReportPublic(page, reportName);
// Verify the report appears in shared reports list
await expect(page.getByText(reportName)).toBeVisible();
await expect(page.getByText('Public')).toBeVisible();
expect(publicUrl).toContain('/shared-report#');
});
test('create shared report with multiple filters', async ({ page }) => {
const projectName = 'MultiFilter Project ' + Math.floor(Math.random() * 10000);
const tagName = 'MultiTag' + Math.floor(Math.random() * 10000);
const reportName = 'MultiFilter Report ' + Math.floor(Math.random() * 10000);
// Create test data
await createTimeEntryWithProject(page, projectName, '2h');
// Create a time entry with both project and tag
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await page.getByRole('button', { name: 'Manual time entry' }).click();
await page.getByTestId('time_entry_description').fill('Multi-filter entry');
// Set project
await page.getByRole('button', { name: 'No Project' }).click();
await page.getByText(projectName).click();
// Set tag
await page.getByRole('button', { name: 'Tags' }).click();
await page.getByText('Create new tag').click();
await page.getByPlaceholder('Tag Name').fill(tagName);
await page.getByRole('button', { name: 'Create Tag' }).click();
await page.waitForLoadState('networkidle');
// Set as billable
await page.getByRole('button', { name: 'Non-Billable' }).click();
await page.getByRole('option', { name: 'Billable', exact: true }).click();
await page.locator('[role="dialog"] input[name="Duration"]').fill('1h 30min');
await page.locator('[role="dialog"] input[name="Duration"]').press('Tab');
await page.getByRole('button', { name: 'Create Time Entry' }).click();
// Create a report with multiple filters
await createReport(page, reportName, {
projectFilter: projectName,
tagFilter: tagName,
billableFilter: 'billable',
});
// Make the report public
const publicUrl = await makeReportPublic(page, reportName);
// Verify the report appears in shared reports list
await expect(page.getByText(reportName)).toBeVisible();
await expect(page.getByText('Public')).toBeVisible();
expect(publicUrl).toContain('/shared-report#');
});
test('toggle report visibility from public to private', async ({ page }) => {
const projectName = 'Toggle Project ' + Math.floor(Math.random() * 10000);
const reportName = 'Toggle Report ' + Math.floor(Math.random() * 10000);
// Create test data
await createTimeEntryWithProject(page, projectName, '1h');
// Create a report
await createReport(page, reportName, { projectFilter: projectName });
// Make the report public
await makeReportPublic(page, reportName);
// Verify it's public
await expect(page.getByText('Public')).toBeVisible();
// Make it private again
const reportRow = page.locator('tr').filter({ hasText: reportName });
await reportRow.getByRole('button', { name: 'Edit' }).click();
await page.getByRole('switch', { name: 'Make report public' }).click();
await page.waitForResponse(
(response) => response.url().includes('/reports/') && response.status() === 200
);
await page.getByRole('button', { name: 'Save' }).click();
await page.waitForLoadState('networkidle');
// Verify it's now private
await expect(page.getByText('Private')).toBeVisible();
await expect(page.getByText('Public')).not.toBeVisible();
});
test('edit shared report name and description', async ({ page }) => {
const projectName = 'Edit Project ' + Math.floor(Math.random() * 10000);
const reportName = 'Original Report ' + Math.floor(Math.random() * 10000);
const updatedName = 'Updated Report ' + Math.floor(Math.random() * 10000);
const description = 'This is an updated description';
// Create test data
await createTimeEntryWithProject(page, projectName, '1h');
// Create a report
await createReport(page, reportName, { projectFilter: projectName });
// Make the report public
await makeReportPublic(page, reportName);
// Edit the report
const reportRow = page.locator('tr').filter({ hasText: reportName });
await reportRow.getByRole('button', { name: 'Edit' }).click();
await page.getByLabel('Report Name').fill(updatedName);
await page.getByLabel('Description').fill(description);
await page.getByRole('button', { name: 'Save' }).click();
await page.waitForLoadState('networkidle');
// Verify the changes
await expect(page.getByText(updatedName)).toBeVisible();
await expect(page.getByText(reportName)).not.toBeVisible();
});
test('delete shared report', async ({ page }) => {
const projectName = 'Delete Project ' + Math.floor(Math.random() * 10000);
const reportName = 'Delete Report ' + Math.floor(Math.random() * 10000);
// Create test data
await createTimeEntryWithProject(page, projectName, '1h');
// Create a report
await createReport(page, reportName, { projectFilter: projectName });
// Make the report public
await makeReportPublic(page, reportName);
// Delete the report
const reportRow = page.locator('tr').filter({ hasText: reportName });
await reportRow.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete Report' }).click();
await page.waitForLoadState('networkidle');
// Verify the report is deleted
await expect(page.getByText(reportName)).not.toBeVisible();
});