mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 08:12:17 +01:00
Compare commits
5 Commits
feature/ad
...
feature/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68e369811c | ||
|
|
da98e0571c | ||
|
|
f68f05d1aa | ||
|
|
8fdc4c1219 | ||
|
|
93148299a9 |
15
.github/PULL_REQUEST_TEMPLATE.md
vendored
15
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,8 +1,11 @@
|
||||
<!--
|
||||
This project is early stage. The structure and APIs are still subject to change and not stable.
|
||||
Therefore, we do not currently accept any contributions, unless you are a member of the team.
|
||||
## What does this PR do?
|
||||
|
||||
As soon as we feel comfortable enough that the application structure is stable enough, we will open up the project for contributions.
|
||||
<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->
|
||||
|
||||
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.
|
||||
-->
|
||||
- Fixes #XXXX (GitHub issue number)
|
||||
|
||||
## Checklist (DO NOT REMOVE)
|
||||
|
||||
- [ ] I read the [contributing guide](https://github.com/solidtime-io/solidtime/blob/main/CONTRIBUTING.md)
|
||||
- [ ] I signed the [Contributor License Agreement](https://cla-assistant.io/solidtime-io/solidtime).
|
||||
- [ ] I commented my code, particularly in hard-to-understand areas
|
||||
|
||||
216
.github/workflows/build-onpremise.yml
vendored
Normal file
216
.github/workflows/build-onpremise.yml
vendored
Normal 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 }}
|
||||
81
CONTRIBUTING.md
Normal file
81
CONTRIBUTING.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Contributing to solidtime
|
||||
|
||||
Contributions are greatly apprecited, please make sure to read the rules and vision for solidtime before contributing.
|
||||
|
||||
## Rules
|
||||
|
||||
### Issues for Bugs, Discussions for Feature requests
|
||||
|
||||
In order to keep the issues of the repository clean we decided to only use them for bugs. Feature Requests and enhancement are handled in discussions. This also helps us to see which feature requests are popular as they can be upvoted.
|
||||
|
||||
### Only work on approved issues
|
||||
|
||||
To respect your time and help us manage contributions effectively, please open an issue or start a discussion and wait for approval before submitting a pull request (PR). This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons.
|
||||
|
||||
### Contributor License Agreement
|
||||
|
||||
You'll also notice that we’ve set up a [Contributor License Agreement (CLA)](https://cla-assistant.io/solidtime-io/solidtime), which must be signed before any PR can be merged. Don’t worry - the process is quick and only takes a few clicks.
|
||||
|
||||
We want to be transparent about why we require the CLA and what it means for your contributions and the codebase. That’s why we’ve written a few paragraphs below outlining our plans and vision for solidtime in the **Vision** part of this document.
|
||||
|
||||
### Prevent Duplicate Work
|
||||
|
||||
Before you submit a new PR, make sure that none exists already. If you plan to work on an issue, make sure to let us and others know by commenting on the issue/discussion.
|
||||
|
||||
### Give context
|
||||
|
||||
Tell us what you thinking was behind the decisions you made while drafting the PR. Treat the PR itself as documentation for everyone who wants to go back and understand why certain decisions were made.
|
||||
|
||||
### Summarize your PR
|
||||
|
||||
Please make sure to include a short summary at the top of your PR to make it easy for us to quickly check what the PR is about, without looking at the code changes.
|
||||
|
||||
### Use Github Keywords and Auto-Link Issues
|
||||
|
||||
Use phrases like "Closes #123" or "Fixes #123" in the PR description to link the PR with the issue that you are adressing.
|
||||
|
||||
### Mention what you tested and how
|
||||
|
||||
Explain how you tested and validated the implementation.
|
||||
|
||||
### Keep Naming consistent
|
||||
|
||||
Look at existing code patterns and use naming conventions that already exist in the code base.
|
||||
|
||||
### Testing
|
||||
|
||||
We have an exhaustive test-suite of PHPUnit (Backend) and Playwright (Frontend) testing. Whereever applicable please make sure to write add tests to the codebase.
|
||||
|
||||
### Linting & Formatting
|
||||
|
||||
Make sure to run linting and formatting commands before you commit the changes.
|
||||
|
||||
For backend changes:
|
||||
|
||||
```
|
||||
composer fix
|
||||
composer analyse
|
||||
```
|
||||
|
||||
For frontend changes:
|
||||
|
||||
```
|
||||
npm run lint:fix
|
||||
npm run format
|
||||
```
|
||||
|
||||
## Vision
|
||||
|
||||
We started solidtime to provide an open infrastructure solution for time tracking—one that empowers teams and individuals to fully own their data, instead of depending on proprietary platforms. We believe infrastructure software should be open, accessible, and built to last. However, competing with established market leaders in this space requires long-term financial sustainability.
|
||||
|
||||
solidtime is licensed under the AGPL, which we believe is the best available license to strike a balance between openness and financial viability. The AGPL gives us, as the copyright holders, certain exclusive rights that we plan to leverage to fund development. To ensure we retain those rights across the entire codebase, we've put a CLA in place that contributors must sign before submitting code.
|
||||
|
||||
One of solidtime’s key advantages is that it's built to be self-hostable. This makes it a great solution for organizations like governments, healthcare providers, and enterprises that are required to keep data on their own infrastructure due to regulations or internal policies. These organizations may need custom licenses, integrations, or modifications that aren't suitable for the open-source version. To support them, we offer relicensed versions of solidtime along with support plans.
|
||||
|
||||
We’ll also provide proprietary extensions for solidtime. These will be available to enterprise customers with support plans, but also to individual users or teams who don’t need support, at much more accessible price points. For companies running solidtime on their own infrastructure, this is the easiest way to support the project while gaining additional functionality. While we plan to make it easier to build custom extensions in the future, our current APIs are still highly experimental.
|
||||
|
||||
Finally - and perhaps most importantly - we offer a hosted SaaS version called solidtime Cloud, for users who can’t or don’t want to run the software themselves. This version includes proprietary extensions, always runs the latest commit, and includes monitoring and billing features available exclusively on this hosted instance. We expect solidtime Cloud to play a critical role in funding the project long-term.
|
||||
|
||||
Having full control over the source code’s licensing also gives us the ability to change the license of the main project in the future. That said, we have no plans to do so and would only consider it in extreme cases - for example, if a malicious actor were to directly compete with our hosted service in a way that threatens the sustainability of the project, the legal interpretation of AGPL changes in a way that would make it unreasonable to use for certain companies, or a new similar license gains wide-spread adoption. Regardless, solidtime will always remain free to self-host for individuals and companies who use it as part of their work, and all previous releases will remain licensed under AGPL.
|
||||
|
||||
If you are using the open-source version of solidtime and want to support us, the best way to do so is to spread the word.
|
||||
@@ -35,10 +35,9 @@ If you have a **feature request**, please [**create a discussion**](https://gith
|
||||
|
||||
## Contributing
|
||||
|
||||
This project is in a very early stage. The structure and APIs are still subject to change and not stable.
|
||||
Therefore, we do not currently accept any contributions, unless you are a member of the team.
|
||||
Please open an issue or start a discussion and wait for approval before submitting a pull request. This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons.
|
||||
|
||||
As soon as we feel comfortable enough that the application structure is stable enough, we will open up the project for contributions.
|
||||
Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) before sumbitting a Pull Request.
|
||||
|
||||
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.
|
||||
|
||||
|
||||
508
e2e/shared-reports-public.spec.ts
Normal file
508
e2e/shared-reports-public.spec.ts
Normal 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();
|
||||
});
|
||||
542
e2e/shared-reports-validation.spec.ts
Normal file
542
e2e/shared-reports-validation.spec.ts
Normal 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
392
e2e/shared-reports.spec.ts
Normal 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();
|
||||
});
|
||||
Reference in New Issue
Block a user