Compare commits

..

2 Commits

Author SHA1 Message Date
Gregor Vostrak
44c36fdba1 use neutral grays consistently in light theme 2026-10-08 16:19:41 +02:00
Gregor Vostrak
4a2f78931a add playwright e2e support for extensions 2026-10-08 16:07:26 +02:00
24 changed files with 364 additions and 200 deletions

View File

@@ -0,0 +1,185 @@
name: Playwright Tests - Extensions
on: push
permissions:
contents: read
jobs:
test:
name: test (${{ matrix.variant }})
runs-on: ubuntu-latest
timeout-minutes: 60
# Same extension sets as build-private.yml and build-onpremise.yml, so the extension specs run
# with and without Billing.
strategy:
fail-fast: false
matrix:
include:
- variant: private
billing: true
extensions: Billing Services Invoicing
- variant: onpremise
billing: false
extensions: Invoicing
services:
mailpit:
image: 'axllent/mailpit:latest'
ports:
- 1025:1025
- 8025:8025
pgsql_test:
image: postgres:15
env:
PGPASSWORD: 'root'
POSTGRES_DB: 'laravel'
POSTGRES_USER: 'root'
POSTGRES_PASSWORD: 'root'
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
gotenberg:
image: gotenberg/gotenberg:8
ports:
- 3000:3000
options: >-
--health-cmd "curl --silent --fail http://localhost:3000/health"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: "Checkout code"
uses: actions/checkout@v7
- name: "Setup node"
uses: actions/setup-node@v7
with:
node-version: '20.x'
- name: "Setup PHP"
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
coverage: none
- name: "Read extension manifest"
id: extension-manifest
run: |
{
echo "billing_repository=$(jq -r '.Billing.repository' extensions/manifest.json)"
echo "billing_ref=$(jq -r '.Billing.ref' extensions/manifest.json)"
echo "services_repository=$(jq -r '.Services.repository' extensions/manifest.json)"
echo "services_ref=$(jq -r '.Services.ref' extensions/manifest.json)"
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
} >> "$GITHUB_OUTPUT"
- name: "Checkout billing extension"
if: matrix.billing
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.billing_repository }}
ref: ${{ steps.extension-manifest.outputs.billing_ref }}
path: extensions/Billing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_BILLING_EXTENSION }}
- name: "Install composer dependencies in billing extension"
if: matrix.billing
working-directory: extensions/Billing
env:
COMPOSER_AUTH: '{"http-basic": {"spark.laravel.com": {"username": "contact@solidtime.io", "password": "${{ secrets.LARAVEL_SPARK_API_KEY }}"}}}'
run: composer install -n --prefer-dist --ignore-platform-reqs
- name: "Install npm dependencies in billing extension"
if: matrix.billing
run: cd extensions/Billing && npm ci
- name: "Checkout services extension"
if: matrix.billing
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.services_repository }}
ref: ${{ steps.extension-manifest.outputs.services_ref }}
path: extensions/Services
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_SERVICES_EXTENSION }}
- name: "Install composer dependencies in services extension"
if: matrix.billing
working-directory: extensions/Services
run: composer install -n --prefer-dist --ignore-platform-reqs
- name: "Install npm dependencies in services extension"
if: matrix.billing
run: cd extensions/Services && npm ci
- name: "Checkout invoicing extension"
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.invoicing_repository }}
ref: ${{ steps.extension-manifest.outputs.invoicing_ref }}
path: extensions/Invoicing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
- name: "Install composer dependencies in invoicing extension"
working-directory: extensions/Invoicing
run: composer install -n --prefer-dist --ignore-platform-reqs
- name: "Install npm dependencies in invoicing extension"
run: cd extensions/Invoicing && npm ci
- name: "Run composer install"
run: composer install -n --prefer-dist
# Must run before `migrate` so the extensions' migrations are applied, and before the
# frontend build so vite collects the extensions' assets. It also writes
# modules_statuses.json, which playwright.config.ts reads to collect the extension specs.
- name: "Activate extensions"
run: |
for extension in ${{ matrix.extensions }}; do
php artisan module:enable "$extension"
done
- name: "Prepare Laravel Application"
run: |
cp .env.ci .env
php artisan key:generate
php artisan passport:keys
php artisan migrate --seed
- name: "Install dependencies"
run: npm ci
- name: "Build Frontend"
run: npm run build
- name: "Install FrankenPHP"
run: |
ARCH="$(uname -m)"
curl -fsSL "https://github.com/dunglas/frankenphp/releases/latest/download/frankenphp-linux-${ARCH}" -o /usr/local/bin/frankenphp
chmod +x /usr/local/bin/frankenphp
- name: "Run Laravel Octane Server"
run: php artisan octane:start --server=frankenphp --host=127.0.0.1 --port=8000 --workers=4 --max-requests=500 > /dev/null 2>&1 &
env:
OCTANE_SERVER: frankenphp
- name: "Install Playwright Browsers"
run: npx playwright install --with-deps
# Only the extension specs, the core specs run without extensions in playwright.yml
- name: "Run Playwright tests"
run: npx playwright test extensions/
env:
PLAYWRIGHT_BASE_URL: 'http://127.0.0.1:8000'
MAILPIT_BASE_URL: 'http://localhost:8025'
# No artifacts are uploaded on purpose. This repository is public, so every artifact is
# downloadable by anyone, and Playwright's reports carry the source of the specs that
# produced them (traces embed whole source files, error-context.md embeds a window of the
# spec). Those specs live in the private extension repositories. Failures are diagnosed from
# the job log; to inspect a trace, reproduce the failure locally with the extension checked out.

View File

@@ -43,10 +43,9 @@ class UserUpdateRequest extends BaseFormRequest
'email' => [
'email:rfc,strict',
'max:255',
UniqueEloquent::make(User::class, 'email')->query(function (Builder $query) {
UniqueEloquent::make(User::class, 'email')->ignore($this->user->id)->query(function (Builder $query) {
/** @var Builder<User> $query */
return $query->where('is_placeholder', '=', false)
->whereKeyNot($this->user->getKey());
return $query->where('is_placeholder', '=', false);
}),
],
'photo' => [

View File

@@ -21,15 +21,15 @@ class ApiTokenResource extends BaseResource
public function toArray(Request $request): array
{
return [
/** ID of the API token, this ID is NOT a UUID */
/** @var string $id ID of the API token, this ID is NOT a UUID */
'id' => $this->resource->id,
/** @var string $name Name of the API token */
'name' => $this->resource->name,
/** Whether the API token is revoked */
/** @var bool $revoked Whether the API token is revoked */
'revoked' => $this->resource->revoked,
/** List of scopes that the API token has */
/** @var array<string> $scopes List of scopes that the API token has */
'scopes' => $this->resource->scopes,
/** When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
/** @var string $created_at When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
'created_at' => $this->formatDateTime($this->resource->created_at),
/** @var string|null $expires_at At what time the API token expires (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
'expires_at' => $this->formatDateTime($this->resource->expires_at),

View File

@@ -29,15 +29,15 @@ class ApiTokenWithAccessTokenResource extends BaseResource
public function toArray(Request $request): array
{
return [
/** ID of the API token, this ID is NOT a UUID */
/** @var string $id ID of the API token, this ID is NOT a UUID */
'id' => $this->resource->id,
/** @var string $name Name of the API token */
'name' => $this->resource->name,
/** Whether the API token is revoked */
/** @var bool $revoked Whether the API token is revoked */
'revoked' => $this->resource->revoked,
/** List of scopes that the API token has */
/** @var array<string> $scopes List of scopes that the API token has */
'scopes' => $this->resource->scopes,
/** When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
/** @var string $created_at When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
'created_at' => $this->formatDateTime($this->resource->created_at),
/** @var string|null $expires_at At what time the API token expires (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
'expires_at' => $this->formatDateTime($this->resource->expires_at),

View File

@@ -21,15 +21,15 @@ class ClientResource extends BaseResource
public function toArray(Request $request): array
{
return [
/** ID */
/** @var string $id ID */
'id' => $this->resource->id,
/** Name */
/** @var string $name Name */
'name' => $this->resource->name,
/** Whether the client is archived */
/** @var bool $is_archived Whether the client is archived */
'is_archived' => $this->resource->is_archived,
/** When the tag was created */
/** @var string $created_at When the tag was created */
'created_at' => $this->formatDateTime($this->resource->created_at),
/** When the tag was last updated */
/** @var string $updated_at When the tag was last updated */
'updated_at' => $this->formatDateTime($this->resource->updated_at),
];
}

View File

@@ -21,11 +21,11 @@ class InvitationResource extends BaseResource
public function toArray(Request $request): array
{
return [
/** ID of the invitation */
/** @var string $id ID of the invitation */
'id' => $this->resource->id,
/** Email */
/** @var string $email Email */
'email' => $this->resource->email,
/** Role */
/** @var string $role Role */
'role' => $this->resource->role,
];
}

View File

@@ -22,19 +22,19 @@ class MemberResource extends BaseResource
public function toArray(Request $request): array
{
return [
/** ID of membership */
/** @var string $id ID of membership */
'id' => $this->resource->id,
/** ID of user */
/** @var string $id ID of user */
'user_id' => $this->resource->user->id,
/** Name */
/** @var string $name Name */
'name' => $this->resource->user->name,
/** Email */
/** @var string $email Email */
'email' => $this->resource->user->email,
/** Role */
/** @var string $role Role */
'role' => $this->resource->role,
/** Placeholder user for imports, user might not really exist and does not know about this placeholder membership */
/** @var bool $is_placeholder Placeholder user for imports, user might not really exist and does not know about this placeholder membership */
'is_placeholder' => $this->resource->user->is_placeholder,
/** Billable rate in cents per hour */
/** @var int|null $billable_rate Billable rate in cents per hour */
'billable_rate' => $this->resource->billable_rate,
];
}

View File

@@ -21,17 +21,17 @@ class PersonalMembershipResource extends BaseResource
public function toArray(Request $request): array
{
return [
/** ID of membership */
/** @var string $id ID of membership */
'id' => $this->resource->id,
'organization' => [
/** ID of organization */
/** @var string $id ID of organization */
'id' => $this->resource->organization->id,
/** Name of organization */
/** @var string $name Name of organization */
'name' => $this->resource->organization->name,
/** Currency code (ISO 4217) of organization */
/** @var string $currency Currency code (ISO 4217) of organization */
'currency' => $this->resource->organization->currency,
],
/** Role */
/** @var string $role Role */
'role' => $this->resource->role,
];
}

View File

@@ -43,23 +43,23 @@ class OrganizationResource extends BaseResource
$currencyService = app(CurrencyService::class);
return [
/** ID */
/** @var string $id ID */
'id' => $this->resource->id,
/** Name */
/** @var string $name Name */
'name' => $this->resource->name,
/** Personal organizations automatically created after registration */
/** @var bool $color Personal organizations automatically created after registration */
'is_personal' => $this->resource->personal_team,
/** Billable rate in cents per hour */
/** @var int|null $billable_rate Billable rate in cents per hour */
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
/** Can members of the organization with role "employee" see the billable rates */
/** @var bool $employees_can_see_billable_rates Can members of the organization with role "employee" see the billable rates */
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
/** Can members of the organization with role "employee" manage tasks in public projects and projects they are assigned to */
/** @var bool $employees_can_manage_tasks Can members of the organization with role "employee" manage tasks in public projects and projects they are assigned to */
'employees_can_manage_tasks' => $this->resource->employees_can_manage_tasks,
/** Prevent creating overlapping time entries (only new entries) */
/** @var bool $prevent_overlapping_time_entries Prevent creating overlapping time entries (only new entries) */
'prevent_overlapping_time_entries' => $this->resource->prevent_overlapping_time_entries,
/** Whether members of the organization can track breaks */
/** @var bool $breaks_enabled Whether members of the organization can track breaks */
'breaks_enabled' => $this->resource->breaks_enabled,
/** Currency code (ISO 4217) */
/** @var string $currency Currency code (ISO 4217) */
'currency' => $this->resource->currency,
/** @var string $currency_symbol Currency symbol */
'currency_symbol' => $currencyService->getCurrencySymbol($this->resource->currency),

View File

@@ -30,25 +30,25 @@ class ProjectResource extends BaseResource
public function toArray(Request $request): array
{
return [
/** ID of project */
/** @var string $id ID of project */
'id' => $this->resource->id,
/** Name of project */
/** @var string $name Name of project */
'name' => $this->resource->name,
/** Color of project */
/** @var string $color Color of project */
'color' => $this->resource->color,
/** @var string|null $client_id ID of client */
'client_id' => $this->resource->client_id,
/** Whether the client is archived */
/** @var bool $is_archived Whether the client is archived */
'is_archived' => $this->resource->is_archived,
/** Billable rate in cents per hour */
/** @var int|null $billable_rate Billable rate in cents per hour */
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
/** Project time entries billable default */
/** @var bool $is_billable Project time entries billable default */
'is_billable' => $this->resource->is_billable,
/** Estimated time in seconds */
/** @var int|null $estimated_time Estimated time in seconds */
'estimated_time' => $this->resource->estimated_time,
/** Spent time on this project in seconds (sum of the duration of all associated time entries, excl. still running time entries) */
/** @var int $spent_time Spent time on this project in seconds (sum of the duration of all associated time entries, excl. still running time entries) */
'spent_time' => $this->resource->spent_time,
/** Whether the project is public */
/** @var bool $is_public Whether the project is public */
'is_public' => $this->resource->is_public,
];
}

View File

@@ -21,13 +21,13 @@ class ProjectMemberResource extends BaseResource
public function toArray(Request $request): array
{
return [
/** ID of project member */
/** @var string $id ID of project member */
'id' => $this->resource->id,
/** Billable rate in cents per hour */
/** @var int|null $billable_rate Billable rate in cents per hour */
'billable_rate' => $this->resource->billable_rate,
/** ID of the organization member */
/** @var string $member_id ID of the organization member */
'member_id' => $this->resource->member_id,
/** ID of the project */
/** @var string $project_id ID of the project */
'project_id' => $this->resource->project_id,
];
}

View File

@@ -21,36 +21,36 @@ class DetailedReportResource extends BaseResource
public function toArray(Request $request): array
{
return [
/** ID of the report */
/** @var string $id ID of the report */
'id' => $this->resource->id,
/** Name */
/** @var string $name Name */
'name' => $this->resource->name,
/** Description */
/** @var string|null $email Description */
'description' => $this->resource->description,
/** Whether the report can be accessed via an external link */
/** @var bool $is_public Whether the report can be accessed via an external link */
'is_public' => $this->resource->is_public,
/** @var string|null $public_until Date until the report is public */
'public_until' => $this->formatDateTime($this->resource->public_until),
/** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */
'shareable_link' => $this->resource->getShareableLink(),
'properties' => [
/** Type of first grouping */
/** @var string $group Type of first grouping */
'group' => $this->resource->properties->group->value,
/** Type of second grouping */
/** @var string $sub_group Type of second grouping */
'sub_group' => $this->resource->properties->subGroup->value,
/** Type of grouping of the historic aggregation (time chart) */
/** @var string $history_group Type of grouping of the historic aggregation (time chart) */
'history_group' => $this->resource->properties->historyGroup->value,
/** Start date of the report */
/** @var string $start Start date of the report */
'start' => $this->formatDateTime($this->resource->properties->start),
/** End date of the report */
/** @var string $end End date of the report */
'end' => $this->formatDateTime($this->resource->properties->end),
/** Whether the report is active */
/** @var bool|null $active Whether the report is active */
'active' => $this->resource->properties->active,
/** @var array<string>|null $member_ids Filter by multiple member IDs, member IDs are OR combined */
'member_ids' => $this->resource->properties->memberIds?->toArray(),
/** Filter by billable status */
/** @var bool|null $billable Filter by billable status */
'billable' => $this->resource->properties->billable,
/** Filter by time entry type */
/** @var string|null $time_entry_type Filter by time entry type */
'time_entry_type' => $this->resource->properties->timeEntryType?->value,
/** @var array<string>|null $client_ids Filter by client IDs, client IDs are OR combined */
'client_ids' => $this->resource->properties->clientIds?->toArray(),
@@ -58,18 +58,18 @@ class DetailedReportResource extends BaseResource
'project_ids' => $this->resource->properties->projectIds?->toArray(),
/** @var array<string>|null $tags_ids Filter by tag IDs, tag IDs are OR combined */
'tag_ids' => $this->resource->properties->tagIds?->toArray(),
/** Tag match type */
/** @var string|null $tag_match_type Tag match type */
'tag_match_type' => $this->resource->properties->tagMatchType?->value,
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
'task_ids' => $this->resource->properties->taskIds?->toArray(),
/** Rounding type for time entries */
/** @var string|null $rounding_type Rounding type for time entries */
'rounding_type' => $this->resource->properties->roundingType?->value,
/** Rounding minutes for time entries */
/** @var int|null $rounding_minutes Rounding minutes for time entries */
'rounding_minutes' => $this->resource->properties->roundingMinutes,
],
/** Date when the report was created */
/** @var string $created_at Date when the report was created */
'created_at' => $this->formatDateTime($this->resource->created_at),
/** Date when the report was last updated */
/** @var string $updated_at Date when the report was last updated */
'updated_at' => $this->formatDateTime($this->resource->updated_at),
];
}

View File

@@ -73,13 +73,13 @@ class DetailedWithDataReportResource extends BaseResource
$currencyService = app(CurrencyService::class);
return [
/** Name */
/** @var string $name Name */
'name' => $this->resource->name,
/** Description */
/** @var string|null $email Description */
'description' => $this->resource->description,
/** @var string|null $public_until Date until the report is public */
'public_until' => $this->formatDateTime($this->resource->public_until),
/** Currency code (ISO 4217) */
/** @var string $currency Currency code (ISO 4217) */
'currency' => $this->resource->organization->currency,
/** @var NumberFormat $number_format Number format */
'number_format' => $this->resource->organization->number_format->value,
@@ -94,15 +94,15 @@ class DetailedWithDataReportResource extends BaseResource
/** @var TimeFormat $time_format Time format */
'time_format' => $this->resource->organization->time_format->value,
'properties' => [
/** Type of first grouping */
/** @var string $group Type of first grouping */
'group' => $this->resource->properties->group->value,
/** Type of second grouping */
/** @var string $sub_group Type of second grouping */
'sub_group' => $this->resource->properties->subGroup->value,
/** Type of grouping of the historic aggregation (time chart) */
/** @var string $history_group Type of grouping of the historic aggregation (time chart) */
'history_group' => $this->resource->properties->historyGroup->value,
/** Start date of the report */
/** @var string $start Start date of the report */
'start' => $this->formatDateTime($this->resource->properties->start),
/** End date of the report */
/** @var string $end End date of the report */
'end' => $this->formatDateTime($this->resource->properties->end),
],
/** @var array{

View File

@@ -21,21 +21,21 @@ class ReportResource extends BaseResource
public function toArray(Request $request): array
{
return [
/** ID of the report */
/** @var string $id ID of the report */
'id' => $this->resource->id,
/** Name */
/** @var string $name Name */
'name' => $this->resource->name,
/** Description */
/** @var string|null $email Description */
'description' => $this->resource->description,
/** Whether the report can be accessed via an external link */
/** @var bool $is_public Whether the report can be accessed via an external link */
'is_public' => $this->resource->is_public,
/** @var string|null $public_until Date until the report is public */
'public_until' => $this->formatDateTime($this->resource->public_until),
/** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */
'shareable_link' => $this->resource->getShareableLink(),
/** Date when the report was created */
/** @var string $created_at Date when the report was created */
'created_at' => $this->formatDateTime($this->resource->created_at),
/** Date when the report was last updated */
/** @var string $updated_at Date when the report was last updated */
'updated_at' => $this->formatDateTime($this->resource->updated_at),
];
}

View File

@@ -21,13 +21,13 @@ class TagResource extends BaseResource
public function toArray(Request $request): array
{
return [
/** ID */
/** @var string $id ID */
'id' => $this->resource->id,
/** Name */
/** @var string $name Name */
'name' => $this->resource->name,
/** When the tag was created */
/** @var string $created_at When the tag was created */
'created_at' => $this->formatDateTime($this->resource->created_at),
/** When the tag was last updated */
/** @var string $updated_at When the tag was last updated */
'updated_at' => $this->formatDateTime($this->resource->updated_at),
];
}

View File

@@ -22,21 +22,21 @@ class TaskResource extends BaseResource
public function toArray(Request $request): array
{
return [
/** ID */
/** @var string $id ID */
'id' => $this->resource->id,
/** Name */
/** @var string $name Name */
'name' => $this->resource->name,
/** Whether the task is done */
/** @var bool $is_done Whether the task is done */
'is_done' => $this->resource->is_done,
/** ID of the project */
/** @var string $project_id ID of the project */
'project_id' => $this->resource->project_id,
/** Estimated time in seconds */
/** @var int|null $estimated_time Estimated time in seconds */
'estimated_time' => $this->resource->estimated_time,
/** Spent time on this task in seconds (sum of the duration of all associated time entries, excl. still running time entries) */
/** @var int $spent_time Spent time on this task in seconds (sum of the duration of all associated time entries, excl. still running time entries) */
'spent_time' => $this->resource->spent_time,
/** When the tag was created */
/** @var string $created_at When the tag was created */
'created_at' => $this->formatDateTime($this->resource->created_at),
/** When the tag was last updated */
/** @var string $updated_at When the tag was last updated */
'updated_at' => $this->formatDateTime($this->resource->updated_at),
];
}

View File

@@ -21,10 +21,10 @@ class TimeEntryResource extends BaseResource
public function toArray(Request $request): array
{
return [
/** ID of time entry */
/** @var string $id ID of time entry */
'id' => $this->resource->id,
/**
* Start of time entry (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z)
* @var string $start Start of time entry (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z)
*/
'start' => $this->formatDateTime($this->resource->start),
/**
@@ -35,19 +35,19 @@ class TimeEntryResource extends BaseResource
'duration' => (int) $this->resource->getDuration()?->totalSeconds,
/** @var string|null $description Description of time entry */
'description' => $this->resource->description,
/** ID of task */
/** @var string|null $task_id ID of task */
'task_id' => $this->resource->task_id,
/** ID of project */
/** @var string|null $project_id ID of project */
'project_id' => $this->resource->project_id,
/** ID of organization */
/** @var string $organization_id ID of organization */
'organization_id' => $this->resource->organization_id,
/** ID of user */
/** @var string $user_id ID of user */
'user_id' => $this->resource->user_id,
/** List of tag IDs */
/** @var array<string> $tags List of tag IDs */
'tags' => $this->resource->tags ?? [],
/** Whether time entry is billable */
/** @var bool $billable Whether time entry is billable */
'billable' => $this->resource->billable,
/** Type of the time entry (`work` time or a `break`) */
/** @var string $type Type of the time entry (`work` time or a `break`) */
'type' => $this->resource->type->value,
];
}

View File

@@ -22,21 +22,21 @@ class UserResource extends BaseResource
public function toArray(Request $request): array
{
return [
/** ID of user */
/** @var string $id ID of user */
'id' => $this->resource->id,
/** Name of user */
/** @var string $name Name of user */
'name' => $this->resource->name,
/** Email of user */
/** @var string $email Email of user */
'email' => $this->resource->email,
/** Email address awaiting verification (set when the user has requested an email change but not yet verified the new address) */
/** @var string|null $pending_email Email address awaiting verification (set when the user has requested an email change but not yet verified the new address) */
'pending_email' => $this->resource->pending_email,
/** Profile photo URL */
/** @var string $profile_photo_url Profile photo URL */
'profile_photo_url' => $this->resource->profile_photo_url,
/** Timezone (f.e. Europe/Berlin or America/New_York) */
/** @var string $timezone Timezone (f.e. Europe/Berlin or America/New_York) */
'timezone' => $this->resource->timezone,
/** @var Weekday $week_start Starting day of the week */
'week_start' => $this->resource->week_start->value,
/** Whether to email the user when a time entry has been running for more than 8 hours */
/** @var bool $send_time_entry_still_running_email Whether to email the user when a time entry has been running for more than 8 hours */
'send_time_entry_still_running_email' => $this->resource->send_time_entry_still_running_email,
];
}

View File

@@ -9,7 +9,7 @@
},
"Invoicing": {
"repository": "solidtime-io/extension-invoicing",
"ref": "v0.0.9"
"ref": "v0.0.8"
},
"Auditing": {
"repository": "solidtime-io/extension-auditing",

View File

@@ -1,4 +1,5 @@
import { defineConfig, devices } from '@playwright/test';
import { existsSync, readFileSync } from 'node:fs';
/**
* Read environment variables from file.
@@ -6,11 +7,57 @@ import { defineConfig, devices } from '@playwright/test';
*/
// require('dotenv').config();
/**
* Extensions ship their e2e specs in extensions/<Name>/tests/e2e. Only the specs of extensions
* that are enabled in modules_statuses.json (php artisan module:enable <Name>) are collected,
* a checked out but disabled extension would fail its specs otherwise.
*/
function enabledExtensions(): string[] {
const statusesFile = './modules_statuses.json';
if (!existsSync(statusesFile)) {
return [];
}
const statuses: Record<string, boolean> = JSON.parse(readFileSync(statusesFile, 'utf-8'));
return Object.entries(statuses)
.filter(([, enabled]) => enabled)
.map(([name]) => name);
}
/*
* Every test root gets its own testDir (instead of testDir: '.') so playwright does not walk the
* whole repository, including node_modules, vendor and nested worktrees, to find the specs.
*/
const testRoots = [
{ suffix: '', testDir: './e2e' },
...enabledExtensions().map((name) => ({
suffix: '-' + name,
testDir: `./extensions/${name}/tests/e2e`,
})),
];
const browsers = [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
// Firefox only in CI to keep local runs fast
...(process.env.CI
? [
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
]
: []),
];
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './e2e',
/* Resolves the @e2e/* and @e2e-support/* aliases that extension specs import core helpers with */
tsconfig: './tsconfig.json',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
@@ -32,23 +79,14 @@ export default defineConfig({
timeout: 20 * 1000,
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
// Firefox only in CI to keep local runs fast
...(process.env.CI
? [
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
]
: []),
],
/* Configure projects for major browsers, core specs keep the plain browser project name */
projects: browsers.flatMap((browser) =>
testRoots.map((root) => ({
name: browser.name + root.suffix,
testDir: root.testDir,
use: browser.use,
}))
),
/* Run your local dev server before starting the tests */
// webServer: {

View File

@@ -66,13 +66,13 @@
:root.light {
--color-bg-primary: #ffffff;
--color-bg-secondary: #fcfcfc;
--color-bg-tertiary: #eeeeef;
--color-bg-quaternary: #e1e1e3;
--color-bg-tertiary: #eeeeee;
--color-bg-quaternary: #e1e1e1;
--color-bg-background: #f5f5f5;
--color-text-primary: #18181b;
--color-text-secondary: #3f3f46;
--color-text-tertiary: #57575c;
--color-text-quaternary: #a1a1aa;
--color-text-primary: #181818;
--color-text-secondary: #404040;
--color-text-tertiary: #575757;
--color-text-quaternary: #a2a2a2;
--color-border-primary: #e7e7e7;
--color-border-secondary: #e5e5e5;
--color-border-tertiary: #dfdfdf;

View File

@@ -5,17 +5,14 @@ declare(strict_types=1);
namespace Tests\Unit\ApiDocs;
use App\Http\Controllers\Api\FallbackController;
use Dedoc\Scramble\Contracts\Diagnostics\Diagnostic;
use Dedoc\Scramble\Generator;
use Dedoc\Scramble\Infer\Context;
use Dedoc\Scramble\Scramble;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use PHPUnit\Framework\Attributes\CoversClass;
use Tests\TestCaseWithDatabase;
use Tests\TestCase;
#[CoversClass(FallbackController::class)]
class ApiDocsExportTest extends TestCaseWithDatabase
class ApiDocsExportTest extends TestCase
{
private string $path;
@@ -74,25 +71,6 @@ class ApiDocsExportTest extends TestCaseWithDatabase
$this->assertNotEmpty($docs['paths']);
}
public function test_api_docs_are_generated_without_diagnostics(): void
{
// Act
$result = app(Generator::class)->generate(Scramble::getGeneratorConfig('default'));
// Assert
$diagnostics = $result->diagnostics()->map(function (Diagnostic $diagnostic): string {
$location = $diagnostic->codeLocation();
return sprintf(
'[%s] %s%s',
$diagnostic->code(),
$diagnostic->message(),
$location !== null ? ' ('.$location->file.':'.$location->line.')' : ''
);
})->all();
$this->assertSame([], $diagnostics);
}
public function test_paginated_endpoints_are_documented_with_pagination(): void
{
// Act

View File

@@ -245,44 +245,6 @@ class UserEndpointTest extends ApiEndpointTestAbstract
Mail::assertNothingSent();
}
public function test_update_fails_if_email_is_already_used_by_another_user(): void
{
// Arrange
Mail::fake();
$data = $this->createUserWithPermission();
User::factory()->create(['email' => 'taken@example.com']);
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.users.update', $data->user->getKey()), [
'email' => 'taken@example.com',
]);
// Assert
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['email']);
$this->assertNull($data->user->fresh()->pending_email);
Mail::assertNothingSent();
}
public function test_update_email_succeeds_if_email_is_only_used_by_a_placeholder_user(): void
{
// Arrange
Mail::fake();
$data = $this->createUserWithPermission();
User::factory()->placeholder()->create(['email' => 'placeholder@example.com']);
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.users.update', $data->user->getKey()), [
'email' => 'placeholder@example.com',
]);
// Assert
$response->assertSuccessful();
$this->assertSame('placeholder@example.com', $data->user->fresh()->pending_email);
}
public function test_update_fails_if_email_format_is_invalid(): void
{
// Arrange

View File

@@ -6,7 +6,9 @@
"compilerOptions": {
"paths": {
"@/*": ["./resources/js/*"],
"@solidtime/ui": ["./resources/js/packages/ui/src/index.ts"]
"@solidtime/ui": ["./resources/js/packages/ui/src/index.ts"],
"@e2e/*": ["./e2e/*"],
"@e2e-support/*": ["./playwright/*"]
}
},
"skipLibCheck": true,