Browse Source

Playwright (#1048)

* Basic test

* Run on playwright branch.

* Another test

* Fix tests again?

* Make screenshot.

* Update snapshots.

* Add new workflow.

* Disable onboarding.

* Run workflow once.

* Add linux snapshots.

* Fix URLs

* More tests.

* More tests.
pull/1049/head
Sebastian Stehle 3 years ago
committed by GitHub
parent
commit
853a130d56
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 31
      .github/workflows/dev.yml
  2. 57
      .github/workflows/make-screenshots.yml
  3. 31
      .github/workflows/release.yml
  4. 1
      backend/i18n/frontend_en.json
  5. 1
      backend/i18n/frontend_fr.json
  6. 1
      backend/i18n/frontend_it.json
  7. 1
      backend/i18n/frontend_nl.json
  8. 1
      backend/i18n/frontend_pt.json
  9. 1
      backend/i18n/frontend_zh.json
  10. 1
      backend/i18n/source/frontend_en.json
  11. 2
      backend/src/Squidex/Areas/Api/Controllers/Apps/AppAssetsController.cs
  12. 2
      backend/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs
  13. 2
      backend/src/Squidex/Areas/Api/Controllers/Apps/AppSettingsController.cs
  14. 4
      backend/src/Squidex/Areas/Api/Controllers/History/HistoryController.cs
  15. 8
      backend/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs
  16. 101
      backend/src/Squidex/Areas/IdentityServer/Config/CreateAdminInitializer.cs
  17. 10
      frontend/package-lock.json
  18. 2
      frontend/package.json
  19. 1
      frontend/src/app/features/apps/pages/app.component.html
  20. 1
      frontend/src/app/features/apps/pages/apps-page.component.ts
  21. 1
      frontend/src/app/features/apps/pages/team.component.html
  22. 2
      frontend/src/app/features/content/pages/content/content-page.component.html
  23. 1
      frontend/src/app/features/content/pages/contents/contents-page.component.html
  24. 8
      frontend/src/app/features/content/shared/forms/array-editor.component.scss
  25. 1
      frontend/src/app/features/content/shared/list/content.component.html
  26. 1
      frontend/src/app/features/content/shared/references/reference-item.component.html
  27. 1
      frontend/src/app/features/dashboard/pages/dashboard-config.component.html
  28. 1
      frontend/src/app/features/rules/pages/events/rule-event.component.html
  29. 3
      frontend/src/app/features/rules/pages/rule/rule-page.component.html
  30. 3
      frontend/src/app/features/rules/pages/rules/rule.component.html
  31. 1
      frontend/src/app/features/rules/pages/simulator/simulated-rule-event.component.html
  32. 4
      frontend/src/app/features/schemas/pages/schema/fields/field.component.html
  33. 3
      frontend/src/app/features/schemas/pages/schema/schema-page.component.html
  34. 7
      frontend/src/app/features/schemas/pages/schemas/schemas-page.component.html
  35. 1
      frontend/src/app/features/settings/pages/languages/language.component.html
  36. 1
      frontend/src/app/features/settings/pages/roles/role.component.html
  37. 1
      frontend/src/app/features/settings/pages/workflows/workflow.component.html
  38. 6
      frontend/src/app/framework/angular/forms/editable-title.component.html
  39. 2
      frontend/src/app/framework/angular/forms/editable-title.component.ts
  40. 1
      frontend/src/app/framework/angular/forms/editors/toggle.component.html
  41. 3
      frontend/src/app/framework/angular/layout.component.html
  42. 2
      frontend/src/app/framework/angular/modals/modal-dialog.component.html
  43. 1
      frontend/src/app/shared/components/assets/asset-folder.component.html
  44. 14
      frontend/src/app/shell/pages/app/left-menu.component.html
  45. 8
      frontend/src/app/theme/_common.scss
  46. 116
      tools/TestSuite/TestSuite.ApiTests/AssetFoldersTests.cs
  47. 27
      tools/TestSuite/TestSuite.ApiTests/AssetTests.cs
  48. 73
      tools/TestSuite/TestSuite.ApiTests/StatisticsTests.cs
  49. 54
      tools/TestSuite/TestSuite.ApiTests/TeamCreationTests.cs
  50. 55
      tools/TestSuite/TestSuite.ApiTests/TeamStatisticsTests.cs
  51. 6
      tools/TestSuite/TestSuite.Shared/Fixtures/CreatedAppFixture.cs
  52. 46
      tools/TestSuite/TestSuite.Shared/Fixtures/CreatedTeamFixture.cs
  53. 33
      tools/TestSuite/docker-compose.yml
  54. 135
      tools/e2e/.eslintrc.js
  55. 10
      tools/e2e/.gitignore
  56. 2917
      tools/e2e/package-lock.json
  57. 24
      tools/e2e/package.json
  58. 78
      tools/e2e/playwright.config.ts
  59. BIN
      tools/e2e/snapshots/given-app/dashboard_page.spec.ts-snapshots/visual-test-1-given-app-linux.png
  60. BIN
      tools/e2e/snapshots/given-app/dashboard_page.spec.ts-snapshots/visual-test-1-given-app-win32.png
  61. BIN
      tools/e2e/snapshots/login.spec.ts-snapshots/visual-test-1-logged-out-linux.png
  62. BIN
      tools/e2e/snapshots/login.spec.ts-snapshots/visual-test-1-logged-out-win32.png
  63. BIN
      tools/e2e/snapshots/start-page.spec.ts-snapshots/visual-test-1-logged-out-linux.png
  64. BIN
      tools/e2e/snapshots/start-page.spec.ts-snapshots/visual-test-1-logged-out-win32.png
  65. 40
      tools/e2e/tests/_fixture.ts
  66. 24
      tools/e2e/tests/given-app/_fixture.ts
  67. 24
      tools/e2e/tests/given-app/_setup.ts
  68. 16
      tools/e2e/tests/given-app/dashboard_page.spec.ts
  69. 89
      tools/e2e/tests/given-app/rules.spec.ts
  70. 90
      tools/e2e/tests/given-app/schemas.spec.ts
  71. 27
      tools/e2e/tests/given-login/_fixture.ts
  72. 31
      tools/e2e/tests/given-login/_setup.ts
  73. 14
      tools/e2e/tests/given-login/apps-page.spec.ts
  74. 24
      tools/e2e/tests/given-login/apps.spec.ts
  75. 42
      tools/e2e/tests/login.spec.ts
  76. 18
      tools/e2e/tests/start-page.spec.ts
  77. 48
      tools/e2e/tests/utils.ts
  78. 109
      tools/e2e/tsconfig.json

31
.github/workflows/dev.yml

@ -21,12 +21,17 @@ jobs:
- name: Prepare - Inject short Variables
uses: rlespinasse/github-slug-action@v4.4.1
- name: Prepare - Set up QEMU
- name: Prepare - Setup QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Prepare - Set up Docker Buildx
- name: Prepare - Setup Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
- name: Prepare - Setup Node
uses: actions/setup-node@v3
with:
node-version: 18
- name: Build - BUILD
uses: docker/build-push-action@v5.1.0
with:
@ -40,6 +45,28 @@ jobs:
run: docker-compose up -d
working-directory: tools/TestSuite
- name: Test - Install Playwright Dependencies
run: npm ci
working-directory: './tools/e2e'
- name: Test - Install Playwright Browsers
run: npx playwright install --with-deps
working-directory: './tools/e2e'
- name: Test - Run Playwright Tests
run: npx playwright test
working-directory: './tools/e2e'
env:
BASE__URL: http://localhost:8080
- name: Test - Upload Playwright Artifacts
if: always()
uses: actions/upload-artifact@v3
with:
name: playwright-report
path: tools/e2e/playwright-report/
retention-days: 30
- name: Test - RUN
uses: kohlerdominik/docker-run-action@v1.1.0
with:

57
.github/workflows/make-screenshots.yml

@ -0,0 +1,57 @@
name: Screenshot
concurrency: build
on:
workflow_dispatch:
jobs:
screenshot:
runs-on: ubuntu-latest
steps:
- name: Prepare - Checkout
uses: actions/checkout@v4.1.1
- name: Prepare - Setup QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Prepare - Setup Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
- name: Prepare - Setup Node
uses: actions/setup-node@v3
with:
node-version: 18
- name: Build - BUILD
uses: docker/build-push-action@v5.1.0
with:
load: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: squidex-local
- name: Test - Start Compose
run: docker-compose up -d
working-directory: tools/TestSuite
- name: Test - Install Playwright Dependencies
run: npm ci
working-directory: './tools/e2e'
- name: Test - Install Playwright Browsers
run: npx playwright install --with-deps
working-directory: './tools/e2e'
- name: Test - Run Playwright Tests
run: npx playwright test --update-snapshots
working-directory: './tools/e2e'
env:
BASE__URL: http://localhost:8080
- name: Test - Upload Playwright Artifacts
if: always()
uses: actions/upload-artifact@v3
with:
name: snapshots
path: tools/e2e/snapshots/
retention-days: 30

31
.github/workflows/release.yml

@ -16,12 +16,17 @@ jobs:
- name: Prepare - Inject short Variables
uses: rlespinasse/github-slug-action@v4.4.1
- name: Prepare - Set up QEMU
- name: Prepare - Setup QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Prepare - Set up Docker Buildx
- name: Prepare - Setup Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
- name: Prepare - Setup Node
uses: actions/setup-node@v3
with:
node-version: 18
- name: Build - BUILD
uses: docker/build-push-action@v5.1.0
with:
@ -35,6 +40,28 @@ jobs:
run: docker-compose up -d
working-directory: tools/TestSuite
- name: Test - Install Playwright Dependencies
run: npm ci
working-directory: './tools/e2e'
- name: Test - Install Playwright Browsers
run: npx playwright install --with-deps
working-directory: './tools/e2e'
- name: Test - Run Playwright Tests
run: npx playwright test
working-directory: './tools/e2e'
env:
BASE__URL: http://localhost:8080
- name: Test - Upload Playwright Artifacts
if: always()
uses: actions/upload-artifact@v3
with:
name: playwright-report
path: tools/e2e/playwright-report/
retention-days: 30
- name: Test - RUN
uses: kohlerdominik/docker-run-action@v1.1.0
with:

1
backend/i18n/frontend_en.json

@ -350,6 +350,7 @@
"common.notSupported": "Not Supported",
"common.noValue": "- No value -",
"common.openAPI": "Open API",
"common.options": "Options",
"common.or": "or",
"common.pagerInfo": "{itemFirst}-{itemLast} of {numberOfItems}",
"common.pagerInfoNoTotal": "{itemFirst}-{itemLast} of total?",

1
backend/i18n/frontend_fr.json

@ -350,6 +350,7 @@
"common.notSupported": "Non supporté",
"common.noValue": "- Aucune valeur -",
"common.openAPI": "Ouvrir l'API",
"common.options": "Options",
"common.or": "ou",
"common.pagerInfo": "{itemFirst}-{itemLast} de {numberOfItems}",
"common.pagerInfoNoTotal": "{itemFirst}-{itemLast} Du total?",

1
backend/i18n/frontend_it.json

@ -350,6 +350,7 @@
"common.notSupported": "Not Supported",
"common.noValue": "- Nessun valore -",
"common.openAPI": "Open API",
"common.options": "Options",
"common.or": "o",
"common.pagerInfo": "{itemFirst}-{itemLast} of {numberOfItems}",
"common.pagerInfoNoTotal": "{itemFirst}-{itemLast} of total?",

1
backend/i18n/frontend_nl.json

@ -350,6 +350,7 @@
"common.notSupported": "Not Supported",
"common.noValue": "- Geen waarde -",
"common.openAPI": "Open API",
"common.options": "Options",
"common.or": "of",
"common.pagerInfo": "{itemFirst} - {itemLast} van {numberOfItems}",
"common.pagerInfoNoTotal": "{itemFirst}-{itemLast} of total?",

1
backend/i18n/frontend_pt.json

@ -350,6 +350,7 @@
"common.notSupported": "Não suportado",
"common.noValue": "- Sem valor -",
"common.openAPI": "Open API",
"common.options": "Options",
"common.or": "ou",
"common.pagerInfo": "{itemFirst}-{itemLast} de {numberOfItems}",
"common.pagerInfoNoTotal": "{itemFirst}-{itemLast} do total?",

1
backend/i18n/frontend_zh.json

@ -350,6 +350,7 @@
"common.notSupported": "Not Supported",
"common.noValue": "- 无值 -",
"common.openAPI": "Open API",
"common.options": "Options",
"common.or": "或",
"common.pagerInfo": "{itemFirst}-{itemLast} 的 {numberOfItems}",
"common.pagerInfoNoTotal": "{itemFirst}-{itemLast} of total?",

1
backend/i18n/source/frontend_en.json

@ -350,6 +350,7 @@
"common.notSupported": "Not Supported",
"common.noValue": "- No value -",
"common.openAPI": "Open API",
"common.options": "Options",
"common.or": "or",
"common.pagerInfo": "{itemFirst}-{itemLast} of {numberOfItems}",
"common.pagerInfoNoTotal": "{itemFirst}-{itemLast} of total?",

2
backend/src/Squidex/Areas/Api/Controllers/Apps/AppAssetsController.cs

@ -47,7 +47,7 @@ public sealed class AppAssetsController : ApiController
}
/// <summary>
/// Update the app asset scripts.
/// Update the asset scripts.
/// </summary>
/// <param name="app">The name of the app to update.</param>
/// <param name="request">The values to update.</param>

2
backend/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs

@ -52,7 +52,7 @@ public sealed class AppLanguagesController : ApiController
}
/// <summary>
/// Attaches an app language.
/// Add an app language.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="request">The language to add to the app.</param>

2
backend/src/Squidex/Areas/Api/Controllers/Apps/AppSettingsController.cs

@ -47,7 +47,7 @@ public sealed class AppSettingsController : ApiController
}
/// <summary>
/// Update the app settings.
/// Update the settings.
/// </summary>
/// <param name="app">The name of the app to update.</param>
/// <param name="request">The values to update.</param>

4
backend/src/Squidex/Areas/Api/Controllers/History/HistoryController.cs

@ -29,7 +29,7 @@ public sealed class HistoryController : ApiController
}
/// <summary>
/// Get historical events.
/// Get the app history.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="channel">The name of the channel.</param>
@ -50,7 +50,7 @@ public sealed class HistoryController : ApiController
}
/// <summary>
/// Get historical events for a team.
/// Get the team history.
/// </summary>
/// <param name="team">The ID of the team.</param>
/// <param name="channel">The name of the channel.</param>

8
backend/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs

@ -98,7 +98,7 @@ public sealed class UsagesController : ApiController
}
/// <summary>
/// Get api calls in date range.
/// Get api calls in date range for app.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="fromDate">The from date.</param>
@ -148,7 +148,7 @@ public sealed class UsagesController : ApiController
}
/// <summary>
/// Get total asset size.
/// Get total asset size for app.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <response code="200">Storage usage returned.</response>
@ -171,7 +171,7 @@ public sealed class UsagesController : ApiController
}
/// <summary>
/// Get total asset size by team.
/// Get total asset size for team.
/// </summary>
/// <param name="team">The ID of the team.</param>
/// <response code="200">Storage usage returned.</response>
@ -194,7 +194,7 @@ public sealed class UsagesController : ApiController
}
/// <summary>
/// Get asset usage by date.
/// Get asset usage by date for app.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="fromDate">The from date.</param>

101
backend/src/Squidex/Areas/IdentityServer/Config/CreateAdminInitializer.cs

@ -20,79 +20,82 @@ namespace Squidex.Areas.IdentityServer.Config;
public sealed class CreateAdminInitializer : IInitializable
{
private readonly IServiceProvider serviceProvider;
private readonly MyIdentityOptions identityOptions;
public int Order => int.MaxValue;
public CreateAdminInitializer(IServiceProvider serviceProvider, IOptions<MyIdentityOptions> identityOptions)
public CreateAdminInitializer(IServiceProvider serviceProvider)
{
this.serviceProvider = serviceProvider;
this.identityOptions = identityOptions.Value;
}
public async Task InitializeAsync(
CancellationToken ct)
{
await using var scope = serviceProvider.CreateAsyncScope();
var identityOptions = serviceProvider.GetRequiredService<IOptions<MyIdentityOptions>>().Value;
IdentityModelEventSource.ShowPII = identityOptions.ShowPII;
if (identityOptions.IsAdminConfigured())
if (!identityOptions.IsAdminConfigured())
{
await using (var scope = serviceProvider.CreateAsyncScope())
{
var userService = scope.ServiceProvider.GetRequiredService<IUserService>();
return;
}
var userService = scope.ServiceProvider.GetRequiredService<IUserService>();
var adminEmail = identityOptions.AdminEmail;
var adminPass = identityOptions.AdminPassword;
var adminEmail = identityOptions.AdminEmail;
var adminPass = identityOptions.AdminPassword;
var isEmpty = await IsEmptyAsync(userService);
var isEmpty = await IsEmptyAsync(userService);
if (isEmpty || identityOptions.AdminRecreate)
if (!isEmpty && !identityOptions.AdminRecreate)
{
return;
}
try
{
var user = await userService.FindByEmailAsync(adminEmail, ct);
if (user != null)
{
if (identityOptions.AdminRecreate)
{
try
{
var user = await userService.FindByEmailAsync(adminEmail, ct);
if (user != null)
{
if (identityOptions.AdminRecreate)
{
var permissions = CreatePermissions(user.Claims.Permissions());
var values = new UserValues
{
Password = adminPass,
Permissions = permissions
};
await userService.UpdateAsync(user.Id, values, ct: ct);
}
}
else
{
var permissions = CreatePermissions(PermissionSet.Empty);
var values = new UserValues
{
Password = adminPass,
Permissions = permissions,
DisplayName = adminEmail
};
await userService.CreateAsync(adminEmail, values, ct: ct);
}
}
catch (Exception ex)
var permissions = CreatePermissions(user.Claims.Permissions(), identityOptions);
var values = new UserValues
{
var log = serviceProvider.GetRequiredService<ILogger<CreateAdminInitializer>>();
Password = adminPass,
Permissions = permissions
};
log.LogError(ex, "Failed to create administrator.");
}
await userService.UpdateAsync(user.Id, values, ct: ct);
}
}
else
{
var permissions = CreatePermissions(PermissionSet.Empty, identityOptions);
var values = new UserValues
{
Password = adminPass,
Permissions = permissions,
DisplayName = adminEmail
};
await userService.CreateAsync(adminEmail, values, ct: ct);
}
}
catch (Exception ex)
{
var log = serviceProvider.GetRequiredService<ILogger<CreateAdminInitializer>>();
log.LogError(ex, "Failed to create administrator.");
}
}
private PermissionSet CreatePermissions(PermissionSet permissions)
private static PermissionSet CreatePermissions(PermissionSet permissions, MyIdentityOptions identityOptions)
{
permissions = permissions.Add(PermissionIds.Admin);

10
frontend/package-lock.json

@ -30,7 +30,7 @@
"ace-builds": "^1.31.2",
"angular-gridster2": "17.0.0",
"angular-mentions": "1.5.0",
"bootstrap": "5.3.2",
"bootstrap": "5.2.3",
"copy-webpack-plugin": "^11.0.0",
"core-js": "3.33.3",
"cropperjs": "2.0.0-alpha.1",
@ -13059,9 +13059,9 @@
"license": "ISC"
},
"node_modules/bootstrap": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.2.tgz",
"integrity": "sha512-D32nmNWiQHo94BKHLmOrdjlL05q1c8oxbtBphQFb9Z5to6eGRDCm0QgeaZ4zFBHzfg2++rqa2JkqCcxDy0sH0g==",
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.2.3.tgz",
"integrity": "sha512-cEKPM+fwb3cT8NzQZYEu4HilJ3anCrWqh3CHAok1p9jXqMPsPTBhU25fBckEJHJ/p+tTxTFTsFQGM+gaHpi3QQ==",
"funding": [
{
"type": "github",
@ -13073,7 +13073,7 @@
}
],
"peerDependencies": {
"@popperjs/core": "^2.11.8"
"@popperjs/core": "^2.11.6"
}
},
"node_modules/bplist-parser": {

2
frontend/package.json

@ -37,7 +37,7 @@
"ace-builds": "^1.31.2",
"angular-gridster2": "17.0.0",
"angular-mentions": "1.5.0",
"bootstrap": "5.3.2",
"bootstrap": "5.2.3",
"copy-webpack-plugin": "^11.0.0",
"core-js": "3.33.3",
"cropperjs": "2.0.0-alpha.1",

1
frontend/src/app/features/apps/pages/app.component.html

@ -26,6 +26,7 @@
<ng-container *ngIf="app.canLeave">
<button type="button" class="btn btn-sm btn-text-secondary" (click)="dropdown.toggle()" sqxStopClick #buttonOptions>
<span class="hidden">{{ 'common.options' | sqxTranslate }}</span>
<i class="icon-dots"></i>
</button>

1
frontend/src/app/features/apps/pages/apps-page.component.ts

@ -97,6 +97,7 @@ export class AppsPageComponent implements OnInit {
this.appsState.apps.pipe(take(1))
.subscribe(apps => {
if (apps.length === 0 &&
this.uiOptions.value.hideOnboarding !== true &&
this.tourState.snapshot.status !== 'Completed' &&
this.tourState.snapshot.status !== 'Started') {
this.onboardingDialog.show();

1
frontend/src/app/features/apps/pages/team.component.html

@ -8,6 +8,7 @@
</div>
<div class="col-auto">
<button type="button" class="btn btn-sm btn-text-secondary" (click)="dropdown.toggle()" sqxStopClick #buttonOptions>
<span class="hidden">{{ 'common.options' | sqxTranslate }}</span>
<i class="icon-dots"></i>
</button>

2
frontend/src/app/features/content/pages/content/content-page.component.html

@ -5,6 +5,7 @@
<ng-container title>
<div class="d-flex align-items-center">
<a class="btn btn-text-secondary" (click)="back()" *ngIf="schema.type !== 'Singleton'">
<span class="hidden">{{ 'common.back' | sqxTranslate }}</span>
<i class="icon-angle-left"></i>
</a>
@ -64,6 +65,7 @@
<ng-container *ngIf="content?.canDelete">
<button type="button" class="btn btn-outline-secondary ms-2" (click)="dropdown.toggle()" #buttonOptions>
<span class="hidden">{{ 'common.options' | sqxTranslate }}</span>
<i class="icon-dots"></i>
</button>

1
frontend/src/app/features/content/pages/contents/contents-page.component.html

@ -65,6 +65,7 @@
<div class="settings-container">
<button type="button" class="btn btn-sm settings-button" (click)="tableViewModal.toggle()" #buttonSettings>
<span class="hidden">{{ 'common.settings' | sqxTranslate }}</span>
<i class="icon-settings"></i>
</button>

8
frontend/src/app/features/content/shared/forms/array-editor.component.scss

@ -19,6 +19,14 @@ virtual-scroller {
}
}
.disabled {
pointer-events: none;
& {
opacity: .5;
}
}
.static {
padding-bottom: .375rem;
padding-top: .375rem;

1
frontend/src/app/features/content/shared/list/content.component.html

@ -23,6 +23,7 @@
<td class="cell-actions cell-actions-left" sqxStopClick>
<button type="button" class="btn btn-text-secondary" (click)="dropdown.toggle()" #buttonOptions>
<span class="hidden">{{ 'common.options' | sqxTranslate }}</span>
<i class="icon-dots"></i>
</button>

1
frontend/src/app/features/content/shared/references/reference-item.component.html

@ -41,6 +41,7 @@
<td class="cell-actions">
<div class="reference-edit">
<button type="button" class="btn btn-text-secondary">
<span class="hidden">{{ 'common.options' | sqxTranslate }}</span>
<i class="icon-dots"></i>
</button>

1
frontend/src/app/features/dashboard/pages/dashboard-config.component.html

@ -1,5 +1,6 @@
<ng-container *ngIf="config">
<button type="button" class="btn settings-button" [class.focused]="needsAttention" [class.btn-primary]="needsAttention" (click)="dropdownModal.toggle()" #buttonSettings>
<span class="hidden">{{ 'common.settings' | sqxTranslate }}</span>
<i class="icon-settings"></i>
</button>

1
frontend/src/app/features/rules/pages/events/rule-event.component.html

@ -13,6 +13,7 @@
</td>
<td class="cell-actions">
<button type="button" class="btn btn-outline-secondary btn-expand" [class.expanded]="expanded" (click)="expandedChange.emit()">
<span class="hidden">{{ 'common.settings' | sqxTranslate }}</span>
<i class="icon-settings"></i>
</button>
</td>

3
frontend/src/app/features/rules/pages/rule/rule-page.component.html

@ -5,7 +5,8 @@
<sqx-layout layout="main" innerWidth="54">
<ng-container title>
<div class="d-flex align-items-center">
<a class="btn btn-text-secondary" (click)="back()">
<a class="btn btn-text-secondary" data-testid="back" (click)="back()">
<span class="hidden">{{ 'common.back' | sqxTranslate }}</span>
<i class="icon-angle-left"></i>
</a>

3
frontend/src/app/features/rules/pages/rules/rule.component.html

@ -12,7 +12,8 @@
</sqx-editable-title>
</div>
<div class="col-auto" [class.invisible]="!rule.canDelete && !rule.canRun">
<button type="button" class="btn btn-text-secondary" (click)="dropdown.toggle()" #buttonOptions>
<button type="button" class="btn btn-text-secondary" data-testid="options" (click)="dropdown.toggle()" #buttonOptions>
<span class="hidden">{{ 'common.options' | sqxTranslate }}</span>
<i class="icon-dots"></i>
</button>

1
frontend/src/app/features/rules/pages/simulator/simulated-rule-event.component.html

@ -10,6 +10,7 @@
</td>
<td class="cell-actions">
<button type="button" class="btn btn-outline-secondary btn-expand" [class.expanded]="expanded" (click)="expandedChange.emit()">
<span class="hidden">{{ 'common.settings' | sqxTranslate }}</span>
<i class="icon-settings"></i>
</button>
</td>

4
frontend/src/app/features/schemas/pages/schema/fields/field.component.html

@ -37,10 +37,12 @@
<div class="col col-options flex-nowrap">
<div class="float-end">
<button type="button" class="btn btn-outline-secondary btn-expand" [class.expanded]="isEditing" (click)="toggleEditing()">
<span class="hidden">{{ 'common.settings' | sqxTranslate }}</span>
<i class="icon-settings"></i>
</button>
<button type="button" class="btn btn-text-secondary ms-1" (click)="dropdown.toggle()" [disabled]="!field.properties.isContentField && field.isLocked" #buttonOptions>
<button type="button" class="btn btn-text-secondary ms-1" (click)="dropdown.toggle()" data-testid="options" [disabled]="!field.properties.isContentField && field.isLocked" #buttonOptions>
<span class="hidden">{{ 'common.options' | sqxTranslate }}</span>
<i class="icon-dots"></i>
</button>

3
frontend/src/app/features/schemas/pages/schema/schema-page.component.html

@ -41,7 +41,8 @@
</button>
</div>
<button type="button" class="btn btn-text-secondary me-2" (click)="editOptionsDropdown.toggle()" #buttonOptions>
<button type="button" class="btn btn-text-secondary me-2" data-testid="options" (click)="editOptionsDropdown.toggle()" #buttonOptions>
<span class="hidden">{{ 'common.options' | sqxTranslate }}</span>
<i class="icon-dots"></i>
</button>

7
frontend/src/app/features/schemas/pages/schemas/schemas-page.component.html

@ -4,7 +4,12 @@
<ng-container menu>
<div class="row g-0">
<div class="col-auto" *ngIf="schemasState.canCreate | async">
<button type="button" class="btn btn-success me-2" (click)="createSchema()" title="i18n:schemas.createSchemaTooltip" titlePosition="top-start" shortcut="CTRL + U" sqxTourStep="addSchema">
<button type="button" class="btn btn-success me-2" (click)="createSchema()"
data-testid="new-schema"
title="i18n:schemas.createSchemaTooltip"
titlePosition="top-start"
shortcut="CTRL + U"
sqxTourStep="addSchema">
<i class="icon-plus"></i>
</button>
</div>

1
frontend/src/app/features/settings/pages/languages/language.component.html

@ -9,6 +9,7 @@
<div class="col-auto">
<div class="float-end">
<button type="button" class="btn btn-outline-secondary btn-expand me-1" [class.expanded]="isEditing" (click)="toggleEditing()" *ngIf="!language.isMaster">
<span class="hidden">{{ 'common.settings' | sqxTranslate }}</span>
<i class="icon-settings"></i>
</button>

1
frontend/src/app/features/settings/pages/roles/role.component.html

@ -16,6 +16,7 @@
<div class="col-auto">
<div class="float-end">
<button type="button" class="btn btn-outline-secondary btn-expand me-1" [class.expanded]="isEditing" (click)="toggleEditing()">
<span class="hidden">{{ 'common.settings' | sqxTranslate }}</span>
<i class="icon-settings"></i>
</button>

1
frontend/src/app/features/settings/pages/workflows/workflow.component.html

@ -15,6 +15,7 @@
<div class="col-options">
<div class="float-end">
<button type="button" class="btn btn-outline-secondary btn-expand me-1" [class.expanded]="isEditing" (click)="toggleEditing()">
<span class="hidden">{{ 'common.settings' | sqxTranslate }}</span>
<i class="icon-settings"></i>
</button>

6
frontend/src/app/framework/angular/forms/editable-title.component.html

@ -8,12 +8,14 @@
</div>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-primary btn-{{size}}" [disabled]="!renameForm.valid || !renameForm.dirty">
<button type="submit" class="btn btn-primary btn-{{size}}" data-testid="save" [disabled]="!renameForm.valid || !renameForm.dirty">
<span class="hidden">{{ 'common.save' | sqxTranslate }}</span>
<i class="icon-checkmark"></i>
</button>
</div>
<div class="col-auto" *ngIf="closeButton">
<button type="button" class="btn btn-text-secondary btn-{{size}} btn-cancel" (click)="toggleRename()">
<button type="button" class="btn btn-text-secondary btn-{{size}} btn-cancel" data-testid="cancel" (click)="toggleRename()">
<span class="hidden">{{ 'common.cancel' | sqxTranslate }}</span>
<i class="icon-close"></i>
</button>
</div>

2
frontend/src/app/framework/angular/forms/editable-title.component.ts

@ -9,6 +9,7 @@ import { NgIf } from '@angular/common';
import { booleanAttribute, Component, EventEmitter, Input, numberAttribute, Output } from '@angular/core';
import { FormControl, FormsModule, ReactiveFormsModule, ValidatorFn, Validators } from '@angular/forms';
import { Keys } from '@app/framework/internal';
import { TranslatePipe } from '../pipes/translate.pipe';
import { ControlErrorsComponent } from './control-errors.component';
import { FocusOnInitDirective } from './focus-on-init.directive';
@ -23,6 +24,7 @@ import { FocusOnInitDirective } from './focus-on-init.directive';
FormsModule,
NgIf,
ReactiveFormsModule,
TranslatePipe,
],
})
export class EditableTitleComponent {

1
frontend/src/app/framework/angular/forms/editors/toggle.component.html

@ -1,4 +1,5 @@
<div class="toggle-container" (click)="changeState()"
[attr.data-state]="snapshot.isChecked ? 'checked' : snapshot.isChecked === false ? 'unchecked' : 'indeterminate'"
[class.disabled]="snapshot.isDisabled"
[class.checked]="snapshot.isChecked === true"
[class.unchecked]="snapshot.isChecked === false">

3
frontend/src/app/framework/angular/layout.component.html

@ -49,7 +49,8 @@
</div>
</div>
<button class="btn panel2-collapse" (click)="toggle()" sqxStopClick>
<button class="btn panel2-collapse" data-testid="back" (click)="toggle()" sqxStopClick>
<span class="hidden">{{ 'common.back' | sqxTranslate }}</span>
<i class="icon-angle-left"></i>
</button>

2
frontend/src/app/framework/angular/modals/modal-dialog.component.html

@ -1,4 +1,4 @@
<div class="modal" @fade>
<div class="modal" data-testid="dialog" @fade>
<div class="modal-dialog modal-{{size}}" [class.modal-fh]="fullHeight">
<div class="modal-content" [sqxTourStep]="tourId">
<div class="modal-header" [class.with-tabs]="hasTabs" *ngIf="showHeader">

1
frontend/src/app/shared/components/assets/asset-folder.component.html

@ -10,6 +10,7 @@
<div class="col-auto">
<ng-container *ngIf="(canDelete || canUpdate) && !isDisabled">
<button type="button" class="btn btn-sm btn-text-secondary" (click)="editDropdown.toggle()" #buttonOptions>
<span class="hidden">{{ 'common.options' | sqxTranslate }}</span>
<i class="icon-dots"></i>
</button>

14
frontend/src/app/shell/pages/app/left-menu.component.html

@ -1,31 +1,31 @@
<ul class="nav flex-column">
<li class="nav-item" sqxTourStep="dashboard">
<a class="nav-link" routerLink="./" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">
<a class="nav-link" routerLink="./" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }" data-testid="dashboard">
<i class="nav-icon icon-dashboard"></i> <div class="nav-text">{{ 'common.dashboard' | sqxTranslate }}</div>
</a>
</li>
<li class="nav-item" *ngIf="!isSchemasHidden(app) && app.canReadSchemas" sqxTourStep="schemas">
<a class="nav-link" routerLink="schemas" routerLinkActive="active">
<a class="nav-link" routerLink="schemas" routerLinkActive="active" data-testid="schemas">
<i class="nav-icon icon-schemas"></i> <div class="nav-text">{{ 'common.schemas' | sqxTranslate }}</div>
</a>
</li>
<li class="nav-item" *ngIf="app.canAccessContent" sqxTourStep="content">
<a class="nav-link" routerLink="content" routerLinkActive="active">
<a class="nav-link" routerLink="content" routerLinkActive="active" data-testid="content">
<i class="nav-icon icon-contents"></i> <div class="nav-text">{{ 'common.content' | sqxTranslate }}</div>
</a>
</li>
<li class="nav-item" *ngIf="!isAssetsHidden(app) && app.canReadAssets" sqxTourStep="assets">
<a class="nav-link" routerLink="assets" routerLinkActive="active">
<a class="nav-link" routerLink="assets" routerLinkActive="active" data-testid="assets">
<i class="nav-icon icon-assets"></i> <div class="nav-text">{{ 'common.assets' | sqxTranslate }}</div>
</a>
</li>
<li class="nav-item" *ngIf="app.canReadRules" sqxTourStep="rules">
<a class="nav-link" routerLink="rules" routerLinkActive="active">
<a class="nav-link" routerLink="rules" routerLinkActive="active" data-testid="rules">
<i class="nav-icon icon-rules"></i> <div class="nav-text">{{ 'common.rules' | sqxTranslate }}</div>
</a>
</li>
<li class="nav-item" *ngIf="!isApiHidden(app)" sqxTourStep="api">
<a class="nav-link" routerLink="api" routerLinkActive="active">
<a class="nav-link" routerLink="api" routerLinkActive="active" data-testid="api">
<i class="nav-icon icon-api"></i> <div class="nav-text">{{ 'common.api' | sqxTranslate }}</div>
</a>
</li>
@ -38,7 +38,7 @@
</a>
</li>
<li class="nav-item" *ngIf="!isSettingsHidden(app)" sqxTourStep="settings">
<a class="nav-link" routerLink="settings" routerLinkActive="active">
<a class="nav-link" routerLink="settings" routerLinkActive="active" data-testid="settings">
<i class="nav-icon icon-settings"></i>
</a>
</li>

8
frontend/src/app/theme/_common.scss

@ -281,14 +281,6 @@ hr {
display: none !important;
}
.disabled {
pointer-events: none;
& {
opacity: .5;
}
}
// Hidden helper (fast *ngIf replacement)
.invisible {
visibility: hidden;

116
tools/TestSuite/TestSuite.ApiTests/AssetFoldersTests.cs

@ -26,15 +26,12 @@ public class AssetFoldersTests : IClassFixture<CreatedAppFixture>
[Fact]
public async Task Should_create_folder()
{
// STEP 1: Create folder.
var createRequest = new CreateAssetFolderDto
{
FolderName = Guid.NewGuid().ToString()
};
var name = Guid.NewGuid().ToString();
var folder = await _.Client.Assets.PostAssetFolderAsync(createRequest);
// STEP 1: Create folder.
var folder = await CreateFolderAsync(name, null);
Assert.Equal(createRequest.FolderName, folder.FolderName);
Assert.Equal(name, folder.FolderName);
await Verify(folder);
}
@ -43,12 +40,7 @@ public class AssetFoldersTests : IClassFixture<CreatedAppFixture>
public async Task Should_update_folder()
{
// STEP 1: Create folder.
var createRequest = new CreateAssetFolderDto
{
FolderName = Guid.NewGuid().ToString()
};
var folder_0 = await _.Client.Assets.PostAssetFolderAsync(createRequest);
var folder_0 = await CreateFolderAsync(Guid.NewGuid().ToString());
// STEP 2: Update folder
@ -65,50 +57,88 @@ public class AssetFoldersTests : IClassFixture<CreatedAppFixture>
}
[Fact]
public async Task Should_delete_folder()
public async Task Should_move_folder()
{
// STEP 1: Create folder.
var createRequest = new CreateAssetFolderDto
{
FolderName = Guid.NewGuid().ToString()
};
var folder_0 = await _.Client.Assets.PostAssetFolderAsync(createRequest);
// STEP 1: Create folders.
var folder1 = await CreateFolderAsync(Guid.NewGuid().ToString());
var folder2 = await CreateFolderAsync(Guid.NewGuid().ToString());
// STEP 2: Update folder
await _.Client.Assets.DeleteAssetFolderAsync(folder_0.Id);
var moveRequest = new MoveAssetFolderDto
{
ParentId = folder1.Id
};
// Should not return deleted folder.
var folders = await _.Client.Assets.GetAssetFoldersAsync(folder_0.Id);
var folder2_1 = await _.Client.Assets.PutAssetFolderParentAsync(folder2.Id, moveRequest);
Assert.DoesNotContain(folders.Items, x => x.Id == folder_0.Id);
Assert.Equal(folder1.Id, folder2_1.ParentId);
}
[Fact]
public async Task Should_create_and_query_nested_folders()
public async Task Should_move_asset()
{
// STEP 1: Create folder.
var createRequest = new CreateAssetFolderDto
var folder1 = await CreateFolderAsync(Guid.NewGuid().ToString());
// STEP 2: Create asset.
var asset_0 = await _.Client.Assets.UploadFileAsync("Assets/logo-squared.png", "image/png");
// STEP 2: Update folder
var moveRequest = new MoveAssetDto
{
FolderName = Guid.NewGuid().ToString()
ParentId = folder1.Id
};
var folder1 = await _.Client.Assets.PostAssetFolderAsync(createRequest);
var asset_1 = await _.Client.Assets.PutAssetParentAsync(asset_0.Id, moveRequest);
Assert.Equal(folder1.Id, asset_1.ParentId);
}
// STEP 2: Create nested folder.
var createRequest2 = new CreateAssetFolderDto
[Fact]
public async Task Should_not_move_folder_to_own_child()
{
// STEP 1: Create folders.
var folder1 = await CreateFolderAsync(Guid.NewGuid().ToString());
var folder2 = await CreateFolderAsync(Guid.NewGuid().ToString(), folder1.Id);
// STEP 2: Update folder
var moveRequest = new MoveAssetFolderDto
{
FolderName = Guid.NewGuid().ToString(),
// Create a nested asset folder.
ParentId = folder1.Id
ParentId = folder2.Id
};
var folder2 = await _.Client.Assets.PostAssetFolderAsync(createRequest2);
await Assert.ThrowsAnyAsync<SquidexException>(() => _.Client.Assets.PutAssetFolderParentAsync(folder1.Id, moveRequest));
}
[Fact]
public async Task Should_delete_folder()
{
// STEP 1: Create folder.
var folder_0 = await CreateFolderAsync(Guid.NewGuid().ToString(), null);
// STEP 2: Update folder
await _.Client.Assets.DeleteAssetFolderAsync(folder_0.Id);
// Should not return deleted folder.
var folders = await _.Client.Assets.GetAssetFoldersAsync(folder_0.Id);
Assert.DoesNotContain(folders.Items, x => x.Id == folder_0.Id);
}
[Fact]
public async Task Should_create_and_query_nested_folders()
{
// STEP 0: Create folders.
var folder1 = await CreateFolderAsync(Guid.NewGuid().ToString());
var folder2 = await CreateFolderAsync(Guid.NewGuid().ToString(), folder1.Id);
// STEP 3: Query by root id.
// STEP 1: Query by root id.
var folders1 = await _.Client.Assets.GetAssetFoldersAsync(folder1.ParentId);
Assert.Contains(folder1.Id, folders1.Items.Select(x => x.Id));
@ -126,4 +156,18 @@ public class AssetFoldersTests : IClassFixture<CreatedAppFixture>
Assert.Contains(folder1.Id, folders3.Items.Select(x => x.Id));
Assert.Contains(folder2.Id, folders3.Items.Select(x => x.Id));
}
private async Task<AssetFolderDto> CreateFolderAsync(string name, string parentId = null)
{
var createRequest = new CreateAssetFolderDto
{
FolderName = name,
// Create a nested asset folder.
ParentId = parentId
};
var folder = await _.Client.Assets.PostAssetFolderAsync(createRequest);
return folder;
}
}

27
tools/TestSuite/TestSuite.ApiTests/AssetTests.cs

@ -782,4 +782,31 @@ public class AssetTests : IClassFixture<CreatedAppFixture>
Assert.NotNull(asset_2);
}
[Fact]
public async Task Should_rename_tag()
{
// STEP 0: Create app.
var (client, _) = await _.PostAppAsync();
// STEP 1: Create asset.
await client.Assets.UploadFileAsync("Assets/logo-squared.png", "image/png");
// STEP 2: Rename tag.
var renameRequest = new RenameTagDto
{
TagName = "pngs"
};
await client.Assets.PutTagAsync("type/png", renameRequest);
// STEP 2: Create asset.
var asset2 = await client.Assets.UploadFileAsync("Assets/logo-squared.png", "image/png");
Assert.Contains("pngs", asset2.Tags);
Assert.DoesNotContain("type/png", asset2.Tags);
}
}

73
tools/TestSuite/TestSuite.ApiTests/StatisticsTests.cs

@ -0,0 +1,73 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Net;
using TestSuite.Fixtures;
#pragma warning disable SA1300 // Element should begin with upper-case letter
#pragma warning disable SA1507 // Code should not contain multiple blank lines in a row
namespace TestSuite.ApiTests;
public class StatisticsTests : IClassFixture<CreatedAppFixture>
{
public CreatedAppFixture _ { get; }
public StatisticsTests(CreatedAppFixture fixture)
{
_ = fixture;
}
[Fact]
public async Task Should_get_logs()
{
// STEP 1: Get initial log response.
var log = await _.Client.Statistics.GetLogAsync();
// STEP 2: Download log.
var httpClient = _.Client.CreateHttpClient();
var response = await httpClient.GetAsync(log.DownloadUrl);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("text/csv", response.Content.Headers.GetValues("Content-Type").First());
}
[Fact]
public async Task Should_get_api_calls()
{
// STEP 1: Get statistics.
var dateFrom = DateTimeOffset.UtcNow.AddDays(-30);
var dateTo = DateTimeOffset.UtcNow;
var result = await _.Client.Statistics.GetUsagesAsync(dateFrom, dateTo);
Assert.NotNull(result);
}
[Fact]
public async Task Should_get_storage_size()
{
// STEP 1: Get statistics.
var dateFrom = DateTimeOffset.UtcNow.AddDays(-30);
var dateTo = DateTimeOffset.UtcNow;
var result = await _.Client.Statistics.GetStorageSizesAsync(dateFrom, dateTo);
Assert.NotNull(result);
}
[Fact]
public async Task Should_get_current_storage_size()
{
// STEP 1: Get statistics.
var result = await _.Client.Statistics.GetCurrentStorageSizeAsync();
Assert.NotNull(result);
}
}

54
tools/TestSuite/TestSuite.ApiTests/TeamCreationTests.cs

@ -0,0 +1,54 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.ClientLibrary;
using TestSuite.Fixtures;
#pragma warning disable SA1300 // Element should begin with upper-case letter
namespace TestSuite.ApiTests;
public class TeamCreationTests : IClassFixture<ClientFixture>
{
private readonly string teamName = Guid.NewGuid().ToString();
public ClientFixture _ { get; }
public TeamCreationTests(ClientFixture fixture)
{
_ = fixture;
}
[Fact]
public async Task Should_create_team()
{
var request = new CreateTeamDto
{
Name = teamName
};
var team = await _.Client.Teams.PostTeamAsync(request);
Assert.Equal(teamName, team.Name);
}
[Fact]
public async Task Should_create_team_with_duplicate_name()
{
var request = new CreateTeamDto
{
Name = teamName
};
var team1 = await _.Client.Teams.PostTeamAsync(request);
var team2 = await _.Client.Teams.PostTeamAsync(request);
Assert.Equal(teamName, team1.Name);
Assert.Equal(teamName, team2.Name);
Assert.NotEqual(team1.Id, team2.Id);
}
}

55
tools/TestSuite/TestSuite.ApiTests/TeamStatisticsTests.cs

@ -0,0 +1,55 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using TestSuite.Fixtures;
#pragma warning disable SA1300 // Element should begin with upper-case letter
namespace TestSuite.ApiTests;
public class TeamStatisticsTests : IClassFixture<CreatedTeamFixture>
{
public CreatedTeamFixture _ { get; }
public TeamStatisticsTests(CreatedTeamFixture fixture)
{
_ = fixture;
}
[Fact]
public async Task Should_get_api_calls()
{
// STEP 1: Get statistics.
var dateFrom = DateTimeOffset.UtcNow.AddDays(-30);
var dateTo = DateTimeOffset.UtcNow;
var result = await _.Client.Statistics.GetUsagesForTeamAsync(_.TeamId, dateFrom, dateTo);
Assert.NotNull(result);
}
[Fact]
public async Task Should_get_storage_size()
{
// STEP 1: Get statistics.
var dateFrom = DateTimeOffset.UtcNow.AddDays(-30);
var dateTo = DateTimeOffset.UtcNow;
var result = await _.Client.Statistics.GetStorageSizesForTeamAsync(_.TeamId, dateFrom, dateTo);
Assert.NotNull(result);
}
[Fact]
public async Task Should_get_current_storage_size_for()
{
// STEP 1: Get statistics.
var result = await _.Client.Statistics.GetTeamCurrentStorageSizeForTeamAsync(_.TeamId);
Assert.NotNull(result);
}
}

6
tools/TestSuite/TestSuite.Shared/Fixtures/CreatedAppFixture.cs

@ -39,10 +39,12 @@ public class CreatedAppFixture : ClientFixture
{
try
{
await Client.Apps.PostAppAsync(new CreateAppDto
var createRequest = new CreateAppDto
{
Name = AppName
});
};
await Client.Apps.PostAppAsync(createRequest);
}
catch (SquidexException ex)
{

46
tools/TestSuite/TestSuite.Shared/Fixtures/CreatedTeamFixture.cs

@ -0,0 +1,46 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.ClientLibrary;
namespace TestSuite.Fixtures;
public class CreatedTeamFixture : ClientFixture
{
public string TeamName { get; } = $"my-team-{Guid.NewGuid()}";
public string TeamId => Team.Id;
public TeamDto Team { get; private set; }
public override async Task InitializeAsync()
{
await base.InitializeAsync();
await Factories.CreateAsync(TeamName, async () =>
{
try
{
var request = new CreateTeamDto
{
Name = TeamName
};
Team = await Client.Teams.PostTeamAsync(request);
}
catch (SquidexException ex)
{
if (ex.StatusCode != 400)
{
throw;
}
}
return true;
});
}
}

33
tools/TestSuite/docker-compose.yml

@ -10,23 +10,26 @@ services:
squidex1:
image: squidex-local
environment:
- URLS__BASEURL=http://localhost:8080
- ASPNETCORE_URLS=http://+:5000
- ASSETS__RESIZERURL=http://resizer
- EVENTSTORE__MONGODB__CONFIGURATION=mongodb://mongo
- EVENTSTORE__MONGODB__DATABASE=squidex1
- GRAPHQL__CACHEDURATION=00:00:00
- IDENTITY__ADMINCLIENTID=root
- IDENTITY__ADMINCLIENTSECRET=xeLd6jFxqbXJrfmNLlO2j1apagGGGSyZJhFnIuHp4I0=
- IDENTITY__ADMINEMAIL=hello@squidex.io
- IDENTITY__ADMINPASSWORD=1q2w3e$$R
- IDENTITY__MULTIPLEDOMAINS=true
- RULES__RULESCACHEDURATION=00:00:00
- SCRIPTING__TIMEOUTEXECUTION=00:00:10
- SCRIPTING__TIMEOUTSCRIPT=00:00:10
- STORE__MONGODB__CONFIGURATION=mongodb://mongo
- STORE__MONGODB__DATABASE=squidex1
- STORE__MONGODB__CONTENTDATABASE=squidex1_content
- STORE__TYPE=MongoDB
- STORE__MONGODB__DATABASE=squidex1
- TEMPLATES__LOCALURL=http://localhost:5000
- ASPNETCORE_URLS=http://+:5000
- UI__HIDENEWS=true
- UI__HIDEONBOARDING=true
- URLS__BASEURL=http://localhost:8080
networks:
- internal
depends_on:
@ -35,24 +38,28 @@ services:
squidex2:
image: squidex-local
environment:
- URLS__BASEURL=http://localhost:8081/squidex/
- URLS__BASEPATH=squidex/
- ASPNETCORE_URLS=http://+:5000
- ASSETS__RESIZERURL=http://resizer
- EVENTSTORE__MONGODB__CONFIGURATION=mongodb://mongo
- EVENTSTORE__MONGODB__DATABASE=squidex2
- GRAPHQL__CACHEDURATION=00:00:00
- IDENTITY__ADMINCLIENTID=root
- IDENTITY__ADMINCLIENTSECRET=xeLd6jFxqbXJrfmNLlO2j1apagGGGSyZJhFnIuHp4I0=
- IDENTITY__ADMINEMAIL=hello@squidex.io
- IDENTITY__ADMINPASSWORD=1q2w3e$$R
- IDENTITY__MULTIPLEDOMAINS=true
- RULES__RULESCACHEDURATION=00:00:00
- SCRIPTING__TIMEOUTEXECUTION=00:00:10
- SCRIPTING__TIMEOUTSCRIPT=00:00:10
- STORE__MONGODB__CONFIGURATION=mongodb://mongo
- STORE__MONGODB__DATABASE=squidex2
- STORE__MONGODB__CONTENTDATABASE=squidex2_content
- STORE__MONGODB__DATABASE=squidex2
- STORE__TYPE=MongoDB
- TEMPLATES__LOCALURL=http://localhost:5000
- ASPNETCORE_URLS=http://+:5000
- UI__HIDENEWS=true
- UI__HIDEONBOARDING=true
- URLS__BASEURL=http://localhost:8081/squidex/
- URLS__BASEPATH=squidex/
networks:
- internal
depends_on:
@ -61,7 +68,7 @@ services:
squidex3:
image: squidex-local
environment:
- URLS__BASEURL=http://localhost:8082
- ASPNETCORE_URLS=http://+:5000
- ASSETS__RESIZERURL=http://resizer
- CONTENTS__OPTIMIZEFORSELFHOSTING=true
- EVENTSTORE__MONGODB__CONFIGURATION=mongodb://mongo
@ -69,16 +76,20 @@ services:
- GRAPHQL__CACHEDURATION=00:00:00
- IDENTITY__ADMINCLIENTID=root
- IDENTITY__ADMINCLIENTSECRET=xeLd6jFxqbXJrfmNLlO2j1apagGGGSyZJhFnIuHp4I0=
- IDENTITY__ADMINEMAIL=hello@squidex.io
- IDENTITY__ADMINPASSWORD=1q2w3e$$R
- IDENTITY__MULTIPLEDOMAINS=true
- RULES__RULESCACHEDURATION=00:00:00
- SCRIPTING__TIMEOUTEXECUTION=00:00:10
- SCRIPTING__TIMEOUTSCRIPT=00:00:10
- STORE__MONGODB__CONFIGURATION=mongodb://mongo
- STORE__MONGODB__DATABASE=squidex3
- STORE__MONGODB__CONTENTDATABASE=squidex3_content
- STORE__MONGODB__DATABASE=squidex3
- STORE__TYPE=MongoDB
- TEMPLATES__LOCALURL=http://localhost:5000
- ASPNETCORE_URLS=http://+:5000
- UI__HIDENEWS=true
- UI__HIDEONBOARDING=true
- URLS__BASEURL=http://localhost:8082
networks:
- internal
depends_on:

135
tools/e2e/.eslintrc.js

@ -0,0 +1,135 @@
/* eslint-disable */
module.exports = {
"env": {
"browser": true,
"node": true
},
"extends": [
"airbnb-typescript/base"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "tsconfig.json"
},
"plugins": [
"deprecation",
"eslint-plugin-import",
"@typescript-eslint",
],
"rules": {
"deprecation/deprecation": "warn",
"@typescript-eslint/dot-notation": "off",
"@typescript-eslint/indent": "off",
"@typescript-eslint/lines-between-class-members": "off",
"@typescript-eslint/member-delimiter-style": [
"error",
{
"multiline": {
"delimiter": "semi",
"requireLast": true
},
"singleline": {
"delimiter": "semi",
"requireLast": false
}
}
],
"@typescript-eslint/naming-convention": [
"error",
{
"selector": "variable",
"format": [
"camelCase",
"PascalCase",
"UPPER_CASE",
],
"leadingUnderscore": "allow",
"trailingUnderscore": "allow",
},
{
"selector": "typeLike",
"format": [
"PascalCase"
],
}
],
"@typescript-eslint/no-this-alias": "error",
"@typescript-eslint/no-unnecessary-boolean-literal-compare": "error",
"@typescript-eslint/no-unused-expressions": "off",
"@typescript-eslint/no-use-before-define": "off",
"@typescript-eslint/no-shadow": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}
],
"@typescript-eslint/return-await": "off",
"@typescript-eslint/quotes": [
"error",
"single"
],
"@typescript-eslint/semi": [
"error",
"always"
],
"arrow-body-style": "off",
"arrow-parens": "off",
"class-methods-use-this": "off",
"default-case": "off",
"function-paren-newline": "off",
"implicit-arrow-linebreak": "off",
"import/extensions": "off",
"import/no-extraneous-dependencies": "off",
"import/no-useless-path-segments": "off",
"import/order": ["error", {
"pathGroupsExcludedImportTypes": ["builtin"],
"pathGroups": [{
"pattern": "@app/**",
"group": "external",
"position": "after"
}],
"alphabetize": {
"order": "asc"
}
}],
"import/prefer-default-export": "off",
"linebreak-style": "off",
"max-classes-per-file": "off",
"max-len": "off",
"newline-per-chained-call": "off",
"no-else-return": "off",
"no-mixed-operators": "off",
"no-nested-ternary": "off",
"no-param-reassign": "off",
"no-plusplus": "off",
"no-prototype-builtins": "off",
"no-restricted-syntax": "off",
"no-trailing-spaces": "error",
"no-underscore-dangle": "off",
"object-curly-newline": [
"error",
{
"ObjectExpression": {
"consistent": true
},
"ObjectPattern": {
"consistent": true
},
"ImportDeclaration": "never",
"ExportDeclaration": "never"
}
],
"operator-linebreak": "off",
"prefer-destructuring": "off",
"sort-imports": [
"error",
{
"ignoreCase": true,
"ignoreDeclarationSort": true
}
],
}
};

10
tools/e2e/.gitignore

@ -0,0 +1,10 @@
/blob-report/
/playwright/
/playwright-report/
/playwright/.cache/
/test-results/
/tests-examples/
node_modules/

2917
tools/e2e/package-lock.json

File diff suppressed because it is too large

24
tools/e2e/package.json

@ -0,0 +1,24 @@
{
"name": "e2e",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"lint": "eslint tests/**/*.ts",
"test": "playwright test --ui",
"test:ci": "playwright test"
},
"keywords": [],
"author": "Sebastian",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.40.0",
"@types/node": "^20.9.3",
"@typescript-eslint/eslint-plugin": "^6.12.0",
"@typescript-eslint/parser": "^6.12.0",
"eslint": "^8.54.0",
"eslint-config-airbnb-typescript": "17.1.0",
"eslint-plugin-deprecation": "^2.0.0",
"eslint-plugin-import": "2.29.0"
}
}

78
tools/e2e/playwright.config.ts

@ -0,0 +1,78 @@
import path from 'path';
import { defineConfig, devices } from '@playwright/test';
export const TEMPORARY_PATH = path.join(__dirname, 'playwright/.temp');
export const STORAGE_STATE = path.join(__dirname, 'playwright/.auth/user.json');
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './tests',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Use a dedicated folder for snapshots. */
snapshotDir: './snapshots',
/* Retry on CI only */
retries: process.env.CI ? 0 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: process.env.BASE__URL || 'https://localhost:5001',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{
name: 'login',
testMatch: 'tests/given-login/_setup.ts',
},
{
name: 'app',
testMatch: 'tests/given-app/_setup.ts',
dependencies: ['login'],
use: {
storageState: STORAGE_STATE,
},
},
{
name: 'given login',
testMatch: 'tests/given-login/*.spec.ts',
dependencies: ['login'],
use: {
...devices['Desktop Chrome'],
storageState: STORAGE_STATE,
},
},
{
name: 'given app',
testMatch: 'tests/given-app/*.spec.ts',
dependencies: ['app'],
use: {
...devices['Desktop Chrome'],
storageState: STORAGE_STATE,
},
},
{
name: 'logged out',
testMatch: 'tests/*.spec.ts',
use: {
...devices['Desktop Chrome'],
},
},
],
});

BIN
tools/e2e/snapshots/given-app/dashboard_page.spec.ts-snapshots/visual-test-1-given-app-linux.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

BIN
tools/e2e/snapshots/given-app/dashboard_page.spec.ts-snapshots/visual-test-1-given-app-win32.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

BIN
tools/e2e/snapshots/login.spec.ts-snapshots/visual-test-1-logged-out-linux.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
tools/e2e/snapshots/login.spec.ts-snapshots/visual-test-1-logged-out-win32.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
tools/e2e/snapshots/start-page.spec.ts-snapshots/visual-test-1-logged-out-linux.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
tools/e2e/snapshots/start-page.spec.ts-snapshots/visual-test-1-logged-out-win32.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

40
tools/e2e/tests/_fixture.ts

@ -0,0 +1,40 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { test as base, Page } from '@playwright/test';
type BaseFixture = {
dropdown: Dropdown;
};
class Dropdown {
constructor(
private readonly page: Page,
) {
}
public async delete() {
await this.page.getByText('Delete').click();
await this.page.getByRole('button', { name: /Yes/ }).click();
}
public async action(name: string) {
await this.page.getByText(name).click();
await this.page.locator('sqx-dropdown-menu').waitFor({ state: 'hidden' });
}
}
export const test = base.extend<BaseFixture>({
dropdown: async ({ page }, use) => {
const dropdown = new Dropdown(page);
await use(dropdown);
},
});
export { expect } from '@playwright/test';

24
tools/e2e/tests/given-app/_fixture.ts

@ -0,0 +1,24 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { readJsonAsync } from '../utils';
import { test as base } from './../given-login/_fixture';
export { expect } from '@playwright/test';
type AppFixture = {
appName: string;
};
export const test = base.extend<{}, AppFixture>({
appName: [async ({}, use) => {
const config = await readJsonAsync<AppFixture>('app', null!);
await use(config.appName);
}, { scope: 'worker', auto: true }],
});

24
tools/e2e/tests/given-app/_setup.ts

@ -0,0 +1,24 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { test as setup } from '@playwright/test';
import { getRandomId, writeJsonAsync } from '../utils';
setup('prepare app', async ({ page }) => {
const appName = `my-app-${getRandomId()}`;
await page.goto('/app');
await page.getByTestId('new-app').click();
await page.locator('#name').fill(appName);
await page.getByRole('button', { name: 'Create' }).click();
await page.getByRole('heading', { name: appName }).click();
await writeJsonAsync('app', { appName });
});

16
tools/e2e/tests/given-app/dashboard_page.spec.ts

@ -0,0 +1,16 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { expect, test } from './_fixture';
test('visual test', async ({ page, appName }) => {
await page.goto(`/app/${appName}`);
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot({ maxDiffPixelRatio: 0.05 });
});

89
tools/e2e/tests/given-app/rules.spec.ts

@ -0,0 +1,89 @@
import { expect, Page } from '@playwright/test';
import { escapeRegex, getRandomId } from '../utils';
import { test } from './_fixture';
test.beforeEach(async ({ page, appName }) => {
await page.goto(`/app/${appName}`);
});
test('create rule', async ({ page }) => {
const ruleName = await createRandomRule(page);
const ruleCard = page.locator('div.card', { hasText: new RegExp(escapeRegex(ruleName)) });
await expect(ruleCard).toBeVisible();
});
test('delete rule', async ({ dropdown, page }) => {
const ruleName = await createRandomRule(page);
const ruleCard = page.locator('div.card', { hasText: new RegExp(escapeRegex(ruleName)) });
await ruleCard.getByTestId('options').click();
await dropdown.delete();
await expect(ruleCard).not.toBeVisible();
});
test('disable rule', async ({ dropdown, page }) => {
const ruleName = await createRandomRule(page);
const ruleCard = page.locator('div.card', { hasText: new RegExp(escapeRegex(ruleName)) });
await ruleCard.getByTestId('options').click();
await dropdown.action('Disable');
await expect(ruleCard.locator('sqx-toggle .toggle-container')).toHaveAttribute('data-state', 'unchecked');
});
test('enable rule', async ({ dropdown, page }) => {
const ruleName = await createRandomRule(page);
const ruleCard = page.locator('div.card', { hasText: new RegExp(escapeRegex(ruleName)) });
const disableRequest = page.waitForResponse(/rules/);
await ruleCard.getByTestId('options').click();
await dropdown.action('Disable');
await disableRequest;
await ruleCard.getByTestId('options').click();
await dropdown.action('Enable');
await expect(ruleCard.locator('sqx-toggle .toggle-container')).toHaveAttribute('data-state', 'checked');
});
test('edit rule', async ({ dropdown, page }) => {
const ruleName = await createRandomRule(page);
const ruleCard = page.locator('div.card', { hasText: new RegExp(escapeRegex(ruleName)) });
await ruleCard.getByTestId('options').click();
await dropdown.action('Edit');
await expect(page.getByText('Enabled')).toBeVisible();
});
async function createRandomRule(page: Page) {
const ruleName = `rule-${getRandomId()}`;
await page.getByTestId('rules').click();
await page.getByRole('link', { name: /New Rule/ }).click();
// Setup rule action
await page.getByText('Content changed').click();
// Setup rule trigger
await page.getByText('Webhook').click();
await page.locator('sqx-formattable-input').first().getByRole('textbox').fill('https:/squidex.io');
await page.getByRole('button', { name: 'Save' }).click();
await page.getByText('Enabled').waitFor({ state: 'visible' });
// Go back
await page.getByTestId('back').click();
// Setup name.
await page.locator('div.card', { hasText: /Unnamed Rule/ }).getByRole('heading').first().dblclick();
await page.locator('form').getByRole('textbox').fill(ruleName);
await page.locator('form').getByTestId('save').click();
return ruleName;
}

90
tools/e2e/tests/given-app/schemas.spec.ts

@ -0,0 +1,90 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Page } from '@playwright/test';
import { escapeRegex, getRandomId } from '../utils';
import { expect, test } from './_fixture';
test.beforeEach(async ({ page, appName }) => {
await page.goto(`/app/${appName}`);
});
test('create schema', async ({ page }) => {
const schemaName = await createRandomSchema(page);
const schemaLink = page.locator('a.nav-link', { hasText: new RegExp(escapeRegex(schemaName)) });
await expect(schemaLink).toBeVisible();
});
test('delete schema', async ({ dropdown, page }) => {
const schemaName = await createRandomSchema(page);
const schemaLink = page.locator('a.nav-link', { hasText: new RegExp(escapeRegex(schemaName)) });
await page.getByTestId('options').click();
await dropdown.delete();
await expect(schemaLink).not.toBeVisible();
});
test('publish schema', async ({ page }) => {
await createRandomSchema(page);
await page.getByRole('button', { name: 'Published', exact: true }).click();
await expect(page.getByRole('button', { name: 'Published', exact: true })).toBeDisabled();
});
test('add field', async ({ page }) => {
await createRandomSchema(page);
const fieldName = await createRandomField(page);
const fieldRow = page.getByText(fieldName);
await expect(fieldRow).toBeVisible();
});
test('delete field', async ({ dropdown, page }) => {
await createRandomSchema(page);
const fieldName = await createRandomField(page);
const fieldRow = page.locator('div.table-items-row-summary', { hasText: new RegExp(escapeRegex(fieldName)) });
await fieldRow.getByTestId('options').click();
await dropdown.delete();
await expect(fieldRow).not.toBeVisible();
});
async function createRandomField(page: Page) {
const fieldName = `field-${getRandomId()}`;
await page.locator('button').filter({ hasText: /^Add Field$/ }).click();
// Setup name.
await page.getByPlaceholder('Enter field name').fill(fieldName);
// Save
await page.getByRole('button', { name: 'Create and close' }).click();
return fieldName;
}
async function createRandomSchema(page: Page) {
const schemaName = `schema-${getRandomId()}`;
await page.getByTestId('schemas').click();
await page.getByTestId('new-schema').click();
// Setup name.
await page.getByLabel('Name (required)').fill(schemaName);
// Save
await page.getByRole('button', { name: 'Create' }).click();
return schemaName;
}

27
tools/e2e/tests/given-login/_fixture.ts

@ -0,0 +1,27 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { test as base } from '../_fixture';
type LoginFixture = {
userEmail: string;
userPassword: string;
};
export const test = base.extend<LoginFixture>({
userEmail: [
'hello@squidex.io',
{ option: true },
],
userPassword: [
'1q2w3e$R',
{ option: true },
],
});
export { expect } from '@playwright/test';

31
tools/e2e/tests/given-login/_setup.ts

@ -0,0 +1,31 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { STORAGE_STATE } from '../../playwright.config';
import { test as setup } from './_fixture';
setup('prepare login', async ({ page, userEmail, userPassword }) => {
await page.goto('/');
// Start waiting for popup before clicking.
const popupPromise = page.waitForEvent('popup');
await page.getByTestId('login').click();
const popup = await popupPromise;
await popup.waitForLoadState();
await popup.getByTestId('login-button').waitFor();
await popup.getByPlaceholder('Enter Email').fill(userEmail);
await popup.getByPlaceholder('Enter Password').fill(userPassword);
await popup.getByTestId('login-button').click();
await page.waitForURL(/app/);
await page.context().storageState({ path: STORAGE_STATE });
});

14
tools/e2e/tests/given-login/apps-page.spec.ts

@ -0,0 +1,14 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { expect, test } from '@playwright/test';
test('has title', async ({ page }) => {
await page.goto('/app');
await expect(page).toHaveTitle(/Apps/);
});

24
tools/e2e/tests/given-login/apps.spec.ts

@ -0,0 +1,24 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { expect, test } from '@playwright/test';
import { getRandomId } from '../utils';
test('should create app', async ({ page }) => {
const appName = `my-app-${getRandomId()}`;
await page.goto('/app');
await page.getByTestId('new-app').click();
await page.locator('#name').fill(appName);
await page.getByRole('button', { name: 'Create' }).click();
const newApp = page.getByRole('heading', { name: appName });
await expect(newApp).toBeVisible();
});

42
tools/e2e/tests/login.spec.ts

@ -0,0 +1,42 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { expect, test } from '@playwright/test';
test('login', async ({ page }) => {
await page.goto('/');
// Start waiting for popup before clicking.
const popupPromise = page.waitForEvent('popup');
await page.getByTestId('login').click();
const popup = await popupPromise;
await popup.waitForLoadState();
await popup.getByTestId('login-button').waitFor();
await popup.getByPlaceholder('Enter Email').fill('hello@squidex.io');
await popup.getByPlaceholder('Enter Password').fill('1q2w3e$R');
await popup.getByTestId('login-button').click();
await page.waitForURL(/app/);
await expect(page).toHaveTitle(/Apps/);
});
test('visual test', async ({ page }) => {
await page.goto('/');
// Start waiting for popup before clicking.
const popupPromise = page.waitForEvent('popup');
await page.getByTestId('login').click();
const popup = await popupPromise;
await expect(popup).toHaveScreenshot({ fullPage: true });
});

18
tools/e2e/tests/start-page.spec.ts

@ -0,0 +1,18 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { expect, test } from '@playwright/test';
test('has title', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/Squidex/);
});
test('visual test', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot();
});

48
tools/e2e/tests/utils.ts

@ -0,0 +1,48 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import fs from 'fs/promises';
import path from 'path';
import { TEMPORARY_PATH } from '../playwright.config';
export function getRandomId() {
const result = new Date().getTime().toString();
return result;
}
export function escapeRegex(string: string) {
const result = string.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&');
return result;
}
export async function writeJsonAsync(name: string, json: any) {
const fullPath = await getPath(name);
await fs.writeFile(fullPath, JSON.stringify(json), { encoding: 'utf8' });
}
export async function readJsonAsync<T>(name: string, defaultValue: T) {
const fullPath = await getPath(name);
const json = await fs.readFile(fullPath, 'utf8');
if (json) {
return JSON.parse(json);
} else {
return defaultValue;
}
}
async function getPath(name: string) {
const fullPath = path.join(TEMPORARY_PATH, `${name}.json`);
await fs.mkdir(TEMPORARY_PATH, { recursive: true });
return fullPath;
}

109
tools/e2e/tsconfig.json

@ -0,0 +1,109 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "commonjs", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
// "outDir": "./", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}
Loading…
Cancel
Save