auth-ui: rewrite ui
Change-Id: I6f00867015ec77aa7e336e89da4dc1b081e330c6
diff --git a/core/auth/ui/e2e/README.md b/core/auth/ui/e2e/README.md
index 710061d..fb105bd 100644
--- a/core/auth/ui/e2e/README.md
+++ b/core/auth/ui/e2e/README.md
@@ -59,10 +59,13 @@
## Commands
-Fast, untagged tests and vet (no Ory or browser process startup):
+Fast, untagged source gates (no Ory or browser process startup):
```sh
make test
+make vet
+# or run the complete source-only aggregate:
+make check
```
Install/check the pinned browser, then run the complete tagged suite:
@@ -173,4 +176,4 @@
## Intentional non-goals
-This suite does not cover or introduce CI, Docker/Compose, PostgreSQL, Windows, system browsers, Firefox/WebKit, parallel stacks, pixel baselines, accessibility audits, recovery, verification, settings UI, MFA, social login, registration-disabled mode, PKCE, refresh tokens, revocation, introspection, consent rejection, Hydra logout, device/client-credentials flows, or a public password-change API. It does not change product handlers, templates, selectors, styles, or static assets.
+This suite does not cover or introduce CI, Docker/Compose, PostgreSQL, Windows, system browsers, Firefox/WebKit, parallel stacks, pixel baselines, accessibility audits, recovery, verification, MFA, social login, registration-disabled mode, PKCE, refresh tokens, revocation, introspection, consent rejection, Hydra logout, device/client-credentials flows, or a public password-change API. It does not change product handlers, templates, selectors, styles, or static assets.
diff --git a/core/auth/ui/e2e/api_password_test.go b/core/auth/ui/e2e/api_password_test.go
index e62c041..1fbf3df 100644
--- a/core/auth/ui/e2e/api_password_test.go
+++ b/core/auth/ui/e2e/api_password_test.go
@@ -94,7 +94,7 @@
openKratosForm(t, session.Page, testStack.UIURL+"/login", "/login")
checkpoint(t, session, "api-created-identity-login-form")
fillCredentials(t, session.Page, username, password)
- clickButton(t, session.Page, "login")
+ clickButton(t, session.Page, "Sign in")
assertGreeting(t, session.Page, username)
checkpoint(t, session, "api-created-identity-greeting")
whoami := assertAcceptedKratosSession(t, client, session)
@@ -114,7 +114,8 @@
t.Fatal(err)
}
assertKratosForm(t, session.Page, "/login")
- if count, err := session.Page.GetByRole("button", playwright.PageGetByRoleOptions{Name: "change password", Exact: playwright.Bool(true)}).Count(); err != nil || count != 0 {
+ assertAuthStateSemantics(t, session.Page, false)
+ if count, err := session.Page.GetByRole("button", playwright.PageGetByRoleOptions{Name: "Change password", Exact: playwright.Bool(true)}).Count(); err != nil || count != 0 {
t.Fatalf("unauthenticated change-password guard exposed its form: count=%d err=%v", count, err)
}
checkpoint(t, session, "unauthenticated-change-password-guard")
@@ -122,45 +123,99 @@
registerThroughBrowser(t, session, username, oldPassword, "password-change-registration")
original := assertAcceptedKratosSession(t, client, session)
openChangePasswordForm(t, session.Page, username)
+ settingsFlow := currentFlowID(t, session.Page)
- if err := session.Page.Locator(`input[name="password"]`).Fill("short"); err != nil {
+ newPasswordField := session.Page.GetByLabel("New password", playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)})
+ if err := newPasswordField.Focus(); err != nil {
+ t.Fatal("focus new-password field")
+ }
+ assertActiveFocusVisible(t, session.Page)
+ if err := session.Page.Keyboard().Press("Tab"); err != nil {
+ t.Fatal("tab from new password to submit")
+ }
+ assertFocusedElementID(t, session.Page, "change-password-submit")
+ assertActiveFocusVisible(t, session.Page)
+ if err := session.Page.Keyboard().Press("Tab"); err != nil {
+ t.Fatal("tab from password submit to account link")
+ }
+ assertFocusedElementText(t, session.Page, "Back to account")
+ if err := session.Page.Keyboard().Press("Shift+Tab"); err != nil {
+ t.Fatal("shift-tab from account link to password submit")
+ }
+ assertFocusedElementID(t, session.Page, "change-password-submit")
+ if err := session.Page.Keyboard().Press("Shift+Tab"); err != nil {
+ t.Fatal("shift-tab from password submit to new password")
+ }
+ assertFocusedElementID(t, session.Page, "change-password")
+ if err := newPasswordField.Fill("short"); err != nil {
t.Fatal("fill invalid replacement password")
}
- clickButton(t, session.Page, "change password")
+ if err := newPasswordField.Press("Enter"); err != nil {
+ t.Fatal("submit invalid password change with Enter")
+ }
assertChangePasswordForm(t, session.Page, username)
+ assertAuthStateSemantics(t, session.Page, true, "change-password")
+ if got := currentFlowID(t, session.Page); got != settingsFlow {
+ t.Fatalf("local settings validation replaced flow %q with %q", settingsFlow, got)
+ }
+ assertLatestUIResponseStatus(t, session, http.MethodPost, "/settings", http.StatusUnprocessableEntity)
assertVisibleExactText(t, session.Page, passwordLengthMessage)
assertVisibleExactText(t, session.Page, passwordCompositionMessage)
+ assertInvalidFieldDescriptions(t, session.Page, "change-password", passwordLengthMessage, passwordCompositionMessage)
+ if value, err := session.Page.GetByLabel("New password", playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)}).InputValue(); err != nil || value != "" {
+ t.Fatalf("locally rejected settings password was retained: length=%d err=%v", len(value), err)
+ }
checkpoint(t, session, "invalid-password-change-validation")
- logoutThroughBrowser(t, session)
- checkpoint(t, session, "old-password-login-after-invalid-update")
- fillCredentials(t, session.Page, username, oldPassword)
- clickButton(t, session.Page, "login")
- assertGreeting(t, session.Page, username)
- oldLogin := assertAcceptedKratosSession(t, client, session)
- if oldLogin.Identity.ID != original.Identity.ID || oldLogin.Identity.Traits.Username != username {
- t.Fatal("old password after invalid update did not resolve to the original identity")
- }
- checkpoint(t, session, "old-password-accepted-after-invalid-update")
+ t.Run("old password remains valid after local rejection", func(t *testing.T) {
+ verificationSession := newKratosTestSession(t)
+ openKratosForm(t, verificationSession.Page, testStack.UIURL+"/login", "/login")
+ fillCredentials(t, verificationSession.Page, username, oldPassword)
+ clickButton(t, verificationSession.Page, "Sign in")
+ assertGreeting(t, verificationSession.Page, username)
+ oldLogin := assertAcceptedKratosSession(t, client, verificationSession)
+ if oldLogin.Identity.ID != original.Identity.ID || oldLogin.Identity.Traits.Username != username {
+ t.Fatal("old password after invalid update did not resolve to the original identity")
+ }
+ checkpoint(t, verificationSession, "old-password-accepted-after-invalid-update")
+ })
- openChangePasswordForm(t, session.Page, username)
- if err := session.Page.Locator(`input[name="password"]`).Fill(newPassword); err != nil {
+ assertChangePasswordForm(t, session.Page, username)
+ assertAuthStateSemantics(t, session.Page, true, "change-password")
+ if got := currentFlowID(t, session.Page); got != settingsFlow {
+ t.Fatalf("rejected settings form flow changed before correction: got %q, want %q", got, settingsFlow)
+ }
+ newPasswordField = session.Page.GetByLabel("New password", playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)})
+ if err := newPasswordField.Fill(newPassword); err != nil {
t.Fatal("fill valid replacement password")
}
- clickButton(t, session.Page, "change password")
+ if err := newPasswordField.Press("Enter"); err != nil {
+ t.Fatal("submit password change with Enter")
+ }
assertVisibleExactText(t, session.Page, passwordChangedMessage)
+ assertLatestUIResponseStatus(t, session, http.MethodPost, "/settings", http.StatusOK)
+ if count, err := session.Page.GetByRole("status").Count(); err != nil || count != 1 {
+ t.Fatalf("password-change success status count=%d err=%v", count, err)
+ }
+ if count, err := session.Page.GetByRole("heading", playwright.PageGetByRoleOptions{Name: "Password changed", Exact: playwright.Bool(true)}).Count(); err != nil || count != 1 {
+ t.Fatalf("password-change success heading count=%d err=%v", count, err)
+ }
+ assertAuthDocumentSemantics(t, session.Page)
+ assertNoFormOrPasswordControls(t, session.Page, "password-change success")
checkpoint(t, session, "password-change-success")
+ clickLink(t, session.Page, "Back to account")
+ assertGreeting(t, session.Page, username)
logoutThroughBrowser(t, session)
checkpoint(t, session, "old-password-login-after-replacement")
fillCredentials(t, session.Page, username, oldPassword)
- clickButton(t, session.Page, "login")
- assertKratosForm(t, session.Page, "/login")
+ clickButton(t, session.Page, "Sign in")
+ assertInvalidLoginFeedback(t, session)
assertNoAcceptedKratosSession(t, client, session)
checkpoint(t, session, "old-password-rejected")
fillCredentials(t, session.Page, username, newPassword)
- clickButton(t, session.Page, "login")
+ clickButton(t, session.Page, "Sign in")
assertGreeting(t, session.Page, username)
accepted := assertAcceptedKratosSession(t, client, session)
if accepted.Identity.ID != original.Identity.ID || accepted.Identity.Traits.Username != username {
@@ -216,11 +271,11 @@
func openChangePasswordForm(t *testing.T, page playwright.Page, username string) {
t.Helper()
- link := page.Locator(`a[href="/settings"]`)
- if err := link.Click(); err != nil {
+ if err := page.GetByRole("link", playwright.PageGetByRoleOptions{Name: "Change password", Exact: playwright.Bool(true)}).Click(); err != nil {
t.Fatal("open change-password flow from the logged-in user page")
}
assertChangePasswordForm(t, page, username)
+ assertAuthStateSemantics(t, page, false)
}
func assertChangePasswordForm(t *testing.T, page playwright.Page, username string) {
@@ -229,30 +284,40 @@
if err != nil || u.Scheme+"://"+u.Host != testStack.UIURL || u.Path != "/settings" || u.Query().Get("flow") == "" {
t.Fatal("authenticated password form was not rendered through a Kratos settings flow")
}
- password := page.Locator(`input[name="password"]`)
+ assertAuthDocumentSemantics(t, page)
+ assertCurrentAuthFormState(t, page, "/settings")
+ assertFieldContract(t, page, "change-password", "password", "password", "new-password", "New password")
+ password := page.GetByLabel("New password", playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)})
if err := password.WaitFor(playwright.LocatorWaitForOptions{State: playwright.WaitForSelectorStateVisible, Timeout: playwright.Float(10_000)}); err != nil {
t.Fatalf("change-password field did not become visible: %v", err)
}
- usernameInput := page.Locator(`input[name="username"]`)
- if count, err := usernameInput.Count(); err != nil || count != 1 {
- t.Fatalf("change-password form username field count=%d err=%v", count, err)
+ if count, err := password.Count(); err != nil || count != 1 {
+ t.Fatalf("change-password field count=%d err=%v", count, err)
}
- if value, err := usernameInput.InputValue(); err != nil || value != username {
- t.Fatal("change-password form did not retain the authenticated username")
+ if count, err := page.Locator("form").Count(); err != nil || count != 1 {
+ t.Fatalf("change-password form count=%d err=%v, want 1", count, err)
}
+ if count, err := page.Locator(`input[type="password"]`).Count(); err != nil || count != 1 {
+ t.Fatalf("change-password password input count=%d err=%v, want 1", count, err)
+ }
+ for _, name := range []string{"username", "method"} {
+ if count, err := page.Locator(`input[name="` + name + `"]`).Count(); err != nil || count != 0 {
+ t.Fatalf("change-password form input %q count=%d err=%v, want 0", name, count, err)
+ }
+ }
+ assertVisibleExactText(t, page, username)
}
func assertVisibleExactText(t *testing.T, page playwright.Page, text string) {
t.Helper()
- if err := page.GetByText(text, playwright.PageGetByTextOptions{Exact: playwright.Bool(true)}).WaitFor(playwright.LocatorWaitForOptions{State: playwright.WaitForSelectorStateVisible, Timeout: playwright.Float(10_000)}); err != nil {
+ if err := page.GetByText(text, playwright.PageGetByTextOptions{Exact: playwright.Bool(true)}).First().WaitFor(playwright.LocatorWaitForOptions{State: playwright.WaitForSelectorStateVisible, Timeout: playwright.Float(10_000)}); err != nil {
t.Fatalf("expected product message was not visible: %v", err)
}
}
func logoutThroughBrowser(t *testing.T, session *browserSession) {
t.Helper()
- if _, err := session.Page.Goto(testStack.UIURL + "/logout"); err != nil {
- t.Fatal(err)
- }
+ clickLink(t, session.Page, "Log out")
assertKratosForm(t, session.Page, "/login")
+ assertAuthStateSemantics(t, session.Page, false)
}
diff --git a/core/auth/ui/e2e/browser.go b/core/auth/ui/e2e/browser.go
index 19949f5..a21753a 100644
--- a/core/auth/ui/e2e/browser.go
+++ b/core/auth/ui/e2e/browser.go
@@ -99,6 +99,34 @@
Cleanup(func())
}
+type browserSize struct {
+ Width int `json:"width"`
+ Height int `json:"height"`
+}
+
+type browserSessionOptions struct {
+ Viewport browserSize
+ VideoSize browserSize
+ ReducedMotion bool
+}
+
+func defaultBrowserSessionOptions() browserSessionOptions {
+ return browserSessionOptions{
+ Viewport: browserSize{Width: 1280, Height: 720},
+ VideoSize: browserSize{Width: 1280, Height: 720},
+ }
+}
+
+func validateBrowserSessionOptions(options browserSessionOptions) error {
+ if options.Viewport.Width <= 0 || options.Viewport.Height <= 0 {
+ return fmt.Errorf("browser viewport must have positive dimensions")
+ }
+ if options.VideoSize.Width <= 0 || options.VideoSize.Height <= 0 {
+ return fmt.Errorf("browser video must have positive dimensions")
+ }
+ return nil
+}
+
type browserSession struct {
Page playwright.Page
Context playwright.BrowserContext
@@ -106,6 +134,8 @@
dir string
video playwright.Video
started time.Time
+ viewport browserSize
+ videoSize browserSize
screenshots []string
checkpoint int
finalize sync.Once
@@ -117,6 +147,8 @@
blocked []string
requestsMu sync.Mutex
requests []requestMetadata
+ diagnosticsMu sync.Mutex
+ diagnostics []string
tracingStarted bool
screenshotOp func(string) error
stopTraceOp func(string) error
@@ -126,10 +158,18 @@
}
func newBrowserSession(t testReporter, browser playwright.Browser, root string, allowedOrigins []string) (*browserSession, error) {
+ return newBrowserSessionWithOptions(t, browser, root, allowedOrigins, defaultBrowserSessionOptions())
+}
+
+func newBrowserSessionWithOptions(t testReporter, browser playwright.Browser, root string, allowedOrigins []string, options browserSessionOptions) (*browserSession, error) {
+ if err := validateBrowserSessionOptions(options); err != nil {
+ return nil, err
+ }
session, err := newBrowserSessionOwner(t, root, browser.Version())
if err != nil {
return nil, err
}
+ assignBrowserSessionMetadata(session, options)
// Register while holding the construction/finalization lock. A watchdog
// either snapshots this ownership and waits here, or rejects construction
// before any external Playwright context exists.
@@ -144,11 +184,15 @@
}
defer session.lifecycleMu.Unlock()
videoDir := filepath.Join(session.dir, ".video")
- context, err := browser.NewContext(playwright.BrowserNewContextOptions{
- Viewport: &playwright.Size{Width: 1280, Height: 720},
- RecordVideo: &playwright.RecordVideo{Dir: playwright.String(videoDir), Size: &playwright.Size{Width: 1280, Height: 720}},
+ contextOptions := playwright.BrowserNewContextOptions{
+ Viewport: &playwright.Size{Width: options.Viewport.Width, Height: options.Viewport.Height},
+ RecordVideo: &playwright.RecordVideo{Dir: playwright.String(videoDir), Size: &playwright.Size{Width: options.VideoSize.Width, Height: options.VideoSize.Height}},
ServiceWorkers: playwright.ServiceWorkerPolicyBlock,
- })
+ }
+ if options.ReducedMotion {
+ contextOptions.ReducedMotion = playwright.ReducedMotionReduce
+ }
+ context, err := browser.NewContext(contextOptions)
if err != nil {
return nil, err
}
@@ -169,6 +213,14 @@
page.OnResponse(func(response playwright.Response) {
session.recordRequest(response.Request().Method(), response.Status(), response.Request().URL())
})
+ page.OnConsole(func(message playwright.ConsoleMessage) {
+ if message.Type() == "error" && !isExpectedFormStatusConsoleError(message.Text()) {
+ session.recordDiagnostic("console error: " + message.Text())
+ }
+ })
+ page.OnPageError(func(err error) {
+ session.recordDiagnostic("page error: " + err.Error())
+ })
s := session
s.Page = page
s.video = page.Video()
@@ -189,6 +241,11 @@
return s, nil
}
+func assignBrowserSessionMetadata(session *browserSession, options browserSessionOptions) {
+ session.viewport = options.Viewport
+ session.videoSize = options.VideoSize
+}
+
func newBrowserSessionOwner(t testReporter, root, browserVersion string) (*browserSession, error) {
dir := filepath.Join(root, sanitizeName(t.Name()))
if err := os.MkdirAll(filepath.Join(dir, "screenshots"), 0o700); err != nil {
@@ -197,7 +254,8 @@
if err := os.MkdirAll(filepath.Join(dir, ".video"), 0o700); err != nil {
return nil, err
}
- session := &browserSession{t: t, dir: dir, started: time.Now().UTC(), browserVer: browserVersion}
+ defaults := defaultBrowserSessionOptions()
+ session := &browserSession{t: t, dir: dir, started: time.Now().UTC(), browserVer: browserVersion, viewport: defaults.Viewport, videoSize: defaults.VideoSize}
session.installDefaultArtifactOps()
// Own metadata and all available partial artifacts before Playwright context
// construction. If Playwright cannot create a context/page/trace, cleanup
@@ -275,6 +333,28 @@
return sortedStrings(s.blocked)
}
+func isExpectedFormStatusConsoleError(message string) bool {
+ switch message {
+ case "Failed to load resource: the server responded with a status of 409 (Conflict)",
+ "Failed to load resource: the server responded with a status of 422 (Unprocessable Entity)":
+ return true
+ default:
+ return false
+ }
+}
+
+func (s *browserSession) recordDiagnostic(message string) {
+ s.diagnosticsMu.Lock()
+ s.diagnostics = append(s.diagnostics, message)
+ s.diagnosticsMu.Unlock()
+}
+
+func (s *browserSession) BrowserDiagnostics() []string {
+ s.diagnosticsMu.Lock()
+ defer s.diagnosticsMu.Unlock()
+ return append([]string(nil), s.diagnostics...)
+}
+
func (s *browserSession) Checkpoint(name string) error {
s.lifecycleMu.Lock()
defer s.lifecycleMu.Unlock()
@@ -369,7 +449,7 @@
if s.t.Failed() || s.forcedFailure || len(errs) > 0 {
outcome = "failed"
}
- metadata := sessionMetadata{TestName: s.t.Name(), StartedAt: s.started, FinishedAt: time.Now().UTC(), Outcome: outcome, BindingVersion: playwrightVersion, CLIVersion: playwrightCLIVersion, ChromiumRevision: chromiumRevision, BrowserVersion: s.browserVer, Screenshots: s.screenshots, FinalURL: finalURL}
+ metadata := sessionMetadata{TestName: s.t.Name(), StartedAt: s.started, FinishedAt: time.Now().UTC(), Outcome: outcome, BindingVersion: playwrightVersion, CLIVersion: playwrightCLIVersion, ChromiumRevision: chromiumRevision, BrowserVersion: s.browserVer, Viewport: s.viewport, VideoSize: s.videoSize, Screenshots: s.screenshots, FinalURL: finalURL}
data, err := json.MarshalIndent(metadata, "", " ")
if err == nil {
err = os.WriteFile(filepath.Join(s.dir, "session.json"), append(data, '\n'), 0o600)
@@ -394,23 +474,19 @@
return "passed", false
}
-const expectedExternalFontRequest = "https://cdnjs.cloudflare.com/ajax/libs/hack-font/3.3.0/web/hack.min.css"
-
-func isExpectedBlockedBrowserRequest(request string) bool {
- return request == expectedExternalFontRequest
-}
-
type sessionMetadata struct {
- TestName string `json:"test_name"`
- StartedAt time.Time `json:"started_at"`
- FinishedAt time.Time `json:"finished_at"`
- Outcome string `json:"outcome"`
- BindingVersion string `json:"binding_version"`
- CLIVersion string `json:"playwright_cli_version"`
- ChromiumRevision string `json:"chromium_revision"`
- BrowserVersion string `json:"browser_version"`
- Screenshots []string `json:"screenshots"`
- FinalURL string `json:"final_url"`
+ TestName string `json:"test_name"`
+ StartedAt time.Time `json:"started_at"`
+ FinishedAt time.Time `json:"finished_at"`
+ Outcome string `json:"outcome"`
+ BindingVersion string `json:"binding_version"`
+ CLIVersion string `json:"playwright_cli_version"`
+ ChromiumRevision string `json:"chromium_revision"`
+ BrowserVersion string `json:"browser_version"`
+ Viewport browserSize `json:"viewport"`
+ VideoSize browserSize `json:"video_size"`
+ Screenshots []string `json:"screenshots"`
+ FinalURL string `json:"final_url"`
}
var unsafeName = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
diff --git a/core/auth/ui/e2e/browser_artifacts_test.go b/core/auth/ui/e2e/browser_artifacts_test.go
index c51766b..12ef1ec 100644
--- a/core/auth/ui/e2e/browser_artifacts_test.go
+++ b/core/auth/ui/e2e/browser_artifacts_test.go
@@ -72,15 +72,11 @@
if err := session.screenshot("00-login.png"); err != nil {
t.Fatal(err)
}
- blocked := session.BlockedRequests()
- foundCDN := false
- for _, request := range blocked {
- if strings.HasPrefix(request, "https://cdnjs.cloudflare.com/") {
- foundCDN = true
- }
+ if blocked := session.BlockedRequests(); len(blocked) != 0 {
+ t.Fatalf("self-contained login page made blocked requests: %v", blocked)
}
- if !foundCDN {
- t.Fatalf("expected external CDN font request to be aborted, blocked=%v", blocked)
+ if diagnostics := session.BrowserDiagnostics(); len(diagnostics) != 0 {
+ t.Fatalf("login page emitted browser diagnostics: %v", diagnostics)
}
policy := newRoutePolicy(testStack.allowedOrigins())
for _, request := range session.RequestMetadata() {
@@ -109,6 +105,11 @@
func assertArtifactSet(t *testing.T, dir string, failedBeforeFinalize bool) {
t.Helper()
+ assertArtifactSetWithOptions(t, dir, failedBeforeFinalize, defaultBrowserSessionOptions())
+}
+
+func assertArtifactSetWithOptions(t *testing.T, dir string, failedBeforeFinalize bool, expected browserSessionOptions) {
+ t.Helper()
wantOutcome, requireFailureScreenshot := artifactOutcomeExpectation(failedBeforeFinalize, t.Failed())
required := []string{"screenshots/00-initial.png", "screenshots/99-final.png", "video.webm", "trace.zip", "session.json"}
if requireFailureScreenshot {
@@ -140,4 +141,7 @@
if metadata.BindingVersion != playwrightVersion || metadata.CLIVersion != playwrightCLIVersion || metadata.ChromiumRevision != chromiumRevision || metadata.BrowserVersion != chromiumVersion {
t.Fatalf("unexpected Playwright metadata: %+v", metadata)
}
+ if metadata.Viewport != expected.Viewport || metadata.VideoSize != expected.VideoSize {
+ t.Fatalf("session dimensions viewport=%+v video=%+v, want viewport=%+v video=%+v", metadata.Viewport, metadata.VideoSize, expected.Viewport, expected.VideoSize)
+ }
}
diff --git a/core/auth/ui/e2e/browser_test.go b/core/auth/ui/e2e/browser_test.go
index 9422096..4397a48 100644
--- a/core/auth/ui/e2e/browser_test.go
+++ b/core/auth/ui/e2e/browser_test.go
@@ -149,18 +149,45 @@
}
}
-func TestExpectedBlockedBrowserRequest(t *testing.T) {
- if !isExpectedBlockedBrowserRequest(expectedExternalFontRequest) {
- t.Fatal("known external font request was not recognized")
+func TestBrowserSessionOptions(t *testing.T) {
+ defaults := defaultBrowserSessionOptions()
+ if defaults.Viewport != (browserSize{Width: 1280, Height: 720}) || defaults.VideoSize != defaults.Viewport || defaults.ReducedMotion {
+ t.Fatalf("default browser options=%+v", defaults)
}
- for _, request := range []string{
- "https://cdnjs.cloudflare.com/other.css",
- "https://example.test/unexpected.js",
- "http://127.0.0.1:1234/unowned",
- expectedExternalFontRequest + "?token=secret",
+ if err := validateBrowserSessionOptions(defaults); err != nil {
+ t.Fatalf("default browser options rejected: %v", err)
+ }
+ for _, options := range []browserSessionOptions{
+ {Viewport: browserSize{Width: 0, Height: 720}, VideoSize: defaults.VideoSize},
+ {Viewport: defaults.Viewport, VideoSize: browserSize{Width: 1280, Height: -1}},
} {
- if isExpectedBlockedBrowserRequest(request) {
- t.Fatalf("unexpected blocked request was accepted: %s", sanitizeFinalURL(request))
+ if err := validateBrowserSessionOptions(options); err == nil {
+ t.Fatalf("invalid browser options accepted: %+v", options)
+ }
+ }
+}
+
+func TestExpectedFormStatusConsoleError(t *testing.T) {
+ canonical409 := "Failed to load resource: the server responded with a status of 409 (Conflict)"
+ canonical422 := "Failed to load resource: the server responded with a status of 422 (Unprocessable Entity)"
+ for _, message := range []string{canonical409, canonical422} {
+ if !isExpectedFormStatusConsoleError(message) {
+ t.Fatalf("expected form-status diagnostic was not recognized: %q", message)
+ }
+ }
+ for _, message := range []string{
+ "product console error",
+ "prefix " + canonical409,
+ canonical409 + " suffix",
+ " " + canonical422,
+ canonical422 + " ",
+ "Failed to load resource: the server responded with a status of 404 (Not Found)",
+ "Failed to load resource: the server responded with a status of 409 (Unprocessable Entity)",
+ "Failed to load resource: the server responded with a status of 422 (Conflict)",
+ "Failed to load resource: the server responded with a status of 500 (Internal Server Error)",
+ } {
+ if isExpectedFormStatusConsoleError(message) {
+ t.Fatalf("unexpected diagnostic was suppressed: %q", message)
}
}
}
@@ -189,13 +216,13 @@
if got := sanitizeFinalURL("http://127.0.0.1:1234/login?flow=sensitive#fragment"); got != "http://127.0.0.1:1234/login" {
t.Fatalf("sanitized URL=%q", got)
}
- metadata := sessionMetadata{TestName: "TestFailure", StartedAt: time.Unix(1, 0).UTC(), FinishedAt: time.Unix(2, 0).UTC(), Outcome: "failed", BindingVersion: playwrightVersion, CLIVersion: playwrightCLIVersion, ChromiumRevision: chromiumRevision, BrowserVersion: chromiumVersion, Screenshots: []string{"00-initial.png", "99-final.png", "failure.png"}, FinalURL: "http://127.0.0.1:1/login"}
+ metadata := sessionMetadata{TestName: "TestFailure", StartedAt: time.Unix(1, 0).UTC(), FinishedAt: time.Unix(2, 0).UTC(), Outcome: "failed", BindingVersion: playwrightVersion, CLIVersion: playwrightCLIVersion, ChromiumRevision: chromiumRevision, BrowserVersion: chromiumVersion, Viewport: browserSize{Width: 1280, Height: 720}, VideoSize: browserSize{Width: 1280, Height: 720}, Screenshots: []string{"00-initial.png", "99-final.png", "failure.png"}, FinalURL: "http://127.0.0.1:1/login"}
data, err := json.Marshal(metadata)
if err != nil {
t.Fatal(err)
}
text := string(data)
- for _, required := range []string{`"outcome":"failed"`, `"failure.png"`, `"binding_version":"v0.6100.0"`, `"playwright_cli_version":"1.61.1"`, `"chromium_revision":"1228"`, `"browser_version":"149.0.7827.55"`} {
+ for _, required := range []string{`"outcome":"failed"`, `"failure.png"`, `"binding_version":"v0.6100.0"`, `"playwright_cli_version":"1.61.1"`, `"chromium_revision":"1228"`, `"browser_version":"149.0.7827.55"`, `"viewport":{"width":1280,"height":720}`, `"video_size":{"width":1280,"height":720}`} {
if !strings.Contains(text, required) {
t.Errorf("metadata missing %s: %s", required, text)
}
@@ -294,13 +321,18 @@
}
}
-func TestPartialBrowserOwnerRecordsMetadataAndCleanupError(t *testing.T) {
+func TestPartialBrowserOwnerRecordsRequestedMetadataAndCleanupError(t *testing.T) {
reporter := &fakeReporter{name: "TestContextConstructionFailure", failed: true}
root := t.TempDir()
s, err := newBrowserSessionOwner(reporter, root, chromiumVersion)
if err != nil {
t.Fatal(err)
}
+ requested := browserSessionOptions{
+ Viewport: browserSize{Width: 390, Height: 844},
+ VideoSize: browserSize{Width: 640, Height: 360},
+ }
+ assignBrowserSessionMetadata(s, requested)
raw := filepath.Join(s.dir, ".video", "partial.data")
if err := os.WriteFile(raw, []byte("partial Playwright data"), 0o600); err != nil {
t.Fatal(err)
@@ -323,6 +355,9 @@
if metadata.Outcome != "failed" {
t.Fatalf("metadata outcome=%q", metadata.Outcome)
}
+ if metadata.Viewport != requested.Viewport || metadata.VideoSize != requested.VideoSize {
+ t.Fatalf("partial metadata viewport=%+v video=%+v, want viewport=%+v video=%+v", metadata.Viewport, metadata.VideoSize, requested.Viewport, requested.VideoSize)
+ }
for _, unavailable := range []string{"screenshots/99-final.png", "screenshots/failure.png", "trace.zip", "video.webm"} {
if _, err := os.Stat(filepath.Join(s.dir, unavailable)); !os.IsNotExist(err) {
t.Fatalf("unavailable artifact %s was fabricated: %v", unavailable, err)
diff --git a/core/auth/ui/e2e/hydra_test.go b/core/auth/ui/e2e/hydra_test.go
index 1b78c8c..012bdf2 100644
--- a/core/auth/ui/e2e/hydra_test.go
+++ b/core/auth/ui/e2e/hydra_test.go
@@ -11,6 +11,8 @@
"strings"
"testing"
"time"
+
+ playwright "github.com/mxschmitt/playwright-go"
)
type workflowStep struct {
@@ -37,7 +39,7 @@
assertKratosForm(t, session.Page, "/login")
checkpoint(t, session, "authorization-login-form")
fillCredentials(t, session.Page, username, password)
- clickButton(t, session.Page, "login")
+ clickButton(t, session.Page, "Sign in")
checkpoint(t, session, "post-login-automatic-consent")
firstCallback, err := firstAttempt.Wait(context.Background())
@@ -49,6 +51,7 @@
t.Fatal(err)
}
assertCallbackPage(t, session, callbacks)
+ assertConsentUIAbsent(t, session)
checkpoint(t, session, "callback-completion")
if err := firstAttempt.Finish(context.Background()); err != nil {
t.Fatal(err)
@@ -88,7 +91,7 @@
t.Fatal(err)
}
assertCallbackPage(t, session, callbacks)
- if count, err := session.Page.Locator(`input[name="password"]`).Count(); err != nil || count != 0 {
+ if count, err := session.Page.GetByLabel("Password").Count(); err != nil || count != 0 {
t.Fatalf("second authorization rendered another password form: count=%d", count)
}
checkpoint(t, session, "second-authenticated-callback")
@@ -99,6 +102,74 @@
_ = exchangeAndValidateHydraCode(t, client, clientID, clientSecret, callbacks.URI(), secondCode, username, secondNonce)
}
+func TestHydraFailedLoginCorrection(t *testing.T) {
+ tests := []struct {
+ name string
+ credential func(t *testing.T, username, password string) (string, string)
+ }{
+ {
+ name: "wrong password",
+ credential: func(t *testing.T, username, _ string) (string, string) {
+ _, wrongPassword := uniqueKratosCredentials(t)
+ return username, wrongPassword
+ },
+ },
+ {
+ name: "unknown username",
+ credential: func(t *testing.T, _ string, password string) (string, string) {
+ unknownUsername, _ := uniqueKratosCredentials(t)
+ return unknownUsername, password
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ session, callbacks := newHydraTestSession(t)
+ client := newDirectAPIClient(testStack.KratosURL, testStack.KratosAdmin)
+ defer client.close()
+ username, password, clientID, clientSecret := createHydraBrowserUserAndClient(t, client, callbacks.URI())
+ state := uniqueOAuthValue(t, "state")
+ nonce := uniqueOAuthValue(t, "nonce")
+ attempt, err := callbacks.Begin()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := session.Page.Goto(hydraAuthorizationURL(testStack.HydraURL, clientID, callbacks.URI(), state, nonce)); err != nil {
+ t.Fatal("navigate to Hydra authorization request")
+ }
+ assertKratosForm(t, session.Page, "/login")
+ attemptedUsername, attemptedPassword := tt.credential(t, username, password)
+ fillCredentials(t, session.Page, attemptedUsername, attemptedPassword)
+ clickButton(t, session.Page, "Sign in")
+ assertInvalidLoginFeedback(t, session)
+ assertNoAcceptedKratosSession(t, client, session)
+ assertNoOAuthCallbackYet(t, attempt)
+ assertHydraLoginChallengePending(t, client, session)
+ checkpoint(t, session, "challenged-login-rejection")
+
+ fillCredentials(t, session.Page, username, password)
+ clickButton(t, session.Page, "Sign in")
+ callback, err := attempt.Wait(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ code, err := validateOAuthCallback(callback, state)
+ if err != nil {
+ t.Fatal(err)
+ }
+ assertCallbackPage(t, session, callbacks)
+ if err := attempt.Finish(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ assertAcceptedKratosSession(t, client, session)
+ assertLoginChallengeCookieCleared(t, session)
+ assertUIRequestCount(t, session, http.MethodPost, "/login", 2)
+ _ = exchangeAndValidateHydraCode(t, client, clientID, clientSecret, callbacks.URI(), code, username, nonce)
+ checkpoint(t, session, "challenged-login-corrected-callback")
+ })
+ }
+}
+
func TestHydraStaleLoginChallengeDoesNotBreakDirectLogin(t *testing.T) {
session, callbacks := newHydraTestSession(t)
client := newDirectAPIClient(testStack.KratosURL, testStack.KratosAdmin)
@@ -116,7 +187,7 @@
}
assertKratosForm(t, session.Page, "/login")
fillCredentials(t, session.Page, username, password)
- clickButton(t, session.Page, "login")
+ clickButton(t, session.Page, "Sign in")
callback, err := authAttempt.Wait(context.Background())
if err != nil {
t.Fatal(err)
@@ -139,7 +210,7 @@
assertNoAcceptedKratosSession(t, client, session)
fillCredentials(t, session.Page, username, password)
- clickButton(t, session.Page, "login")
+ clickButton(t, session.Page, "Sign in")
assertAcceptedKratosSession(t, client, session)
current, err := url.Parse(session.Page.URL())
@@ -153,6 +224,67 @@
assertGreeting(t, session.Page, username)
}
+func assertNoOAuthCallbackYet(t *testing.T, attempt *callbackAttempt) {
+ t.Helper()
+ attempt.capture.mu.Lock()
+ defer attempt.capture.mu.Unlock()
+ if attempt.count != 0 || attempt.done {
+ t.Fatalf("failed login produced callback count=%d done=%v", attempt.count, attempt.done)
+ }
+}
+
+func loginChallengeCookie(t *testing.T, session *browserSession) string {
+ t.Helper()
+ cookies, err := session.Context.Cookies(testStack.UIURL)
+ if err != nil {
+ t.Fatal("read auth-ui cookies")
+ }
+ for _, cookie := range cookies {
+ if cookie.Name == "login_challenge" && cookie.Value != "" {
+ return cookie.Value
+ }
+ }
+ return ""
+}
+
+func assertHydraLoginChallengePending(t *testing.T, client *directAPIClient, session *browserSession) {
+ t.Helper()
+ challenge := loginChallengeCookie(t, session)
+ if challenge == "" {
+ t.Fatal("failed challenged login did not preserve its challenge cookie")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, testStack.HydraAdmin+"/admin/oauth2/auth/requests/login?login_challenge="+url.QueryEscape(challenge), nil)
+ if err != nil {
+ t.Fatal("construct pending Hydra login request check")
+ }
+ status, _, err := client.do(ctx, request)
+ if err != nil || status != http.StatusOK {
+ t.Fatalf("Hydra login challenge was not pending: status=%d err=%v", status, err)
+ }
+}
+
+func assertLoginChallengeCookieCleared(t *testing.T, session *browserSession) {
+ t.Helper()
+ if challenge := loginChallengeCookie(t, session); challenge != "" {
+ t.Fatal("successful challenged retry did not clear the challenge cookie")
+ }
+}
+
+func assertUIRequestCount(t *testing.T, session *browserSession, method, path string, want int) {
+ t.Helper()
+ count := 0
+ for _, request := range session.RequestMetadata() {
+ if request.Status != 0 && request.Origin == testStack.UIURL && request.Method == method && request.Path == path {
+ count++
+ }
+ }
+ if count != want {
+ t.Fatalf("completed UI %s %s request count=%d, want %d", method, path, count, want)
+ }
+}
+
func createHydraBrowserUserAndClient(t *testing.T, client *directAPIClient, callbackURI string) (username, password, clientID, clientSecret string) {
t.Helper()
username, password = uniqueKratosCredentials(t)
@@ -320,6 +452,34 @@
}
}
+func assertConsentUIAbsent(t *testing.T, session *browserSession) {
+ t.Helper()
+ consentRedirects := 0
+ for _, request := range session.RequestMetadata() {
+ if request.Origin != testStack.UIURL || request.Path != "/consent" || request.Status == 0 {
+ continue
+ }
+ if request.Method != http.MethodGet || request.Status < http.StatusMultipleChoices || request.Status >= http.StatusBadRequest {
+ t.Fatalf("automatic consent produced an unexpected browser response: %+v", request)
+ }
+ consentRedirects++
+ }
+ if consentRedirects != 1 {
+ t.Fatalf("automatic consent redirect count=%d, want 1", consentRedirects)
+ }
+ for name, locator := range map[string]playwright.Locator{
+ "consent form": session.Page.Locator(`form[action*="consent"]`),
+ "scope control": session.Page.Locator(`[name="scope"]`),
+ "allow action": session.Page.GetByRole("button", playwright.PageGetByRoleOptions{Name: "Allow", Exact: playwright.Bool(true)}),
+ "reject action": session.Page.GetByRole("button", playwright.PageGetByRoleOptions{Name: "Reject", Exact: playwright.Bool(true)}),
+ "consent heading": session.Page.GetByRole("heading", playwright.PageGetByRoleOptions{Name: "Consent", Exact: playwright.Bool(true)}),
+ } {
+ if count, err := locator.Count(); err != nil || count != 0 {
+ t.Fatalf("%s count=%d err=%v, want 0", name, count, err)
+ }
+ }
+}
+
func assertSecondAuthorizationUsesExistingSession(t *testing.T, metadata []requestMetadata) {
t.Helper()
observedUILoginGET := false
@@ -360,9 +520,10 @@
t.Errorf("browser did not receive an expected successful OAuth workflow response from %s", origin)
}
}
- for _, request := range session.BlockedRequests() {
- if !isExpectedBlockedBrowserRequest(request) {
- t.Errorf("browser blocked an unexpected query-free target %s", request)
- }
+ if blocked := session.BlockedRequests(); len(blocked) != 0 {
+ t.Errorf("browser blocked requests during self-contained OAuth UI: %v", blocked)
+ }
+ if diagnostics := session.BrowserDiagnostics(); len(diagnostics) != 0 {
+ t.Errorf("browser reported page/console errors during OAuth: %v", diagnostics)
}
}
diff --git a/core/auth/ui/e2e/kratos_test.go b/core/auth/ui/e2e/kratos_test.go
index d909b1d..b7d739d 100644
--- a/core/auth/ui/e2e/kratos_test.go
+++ b/core/auth/ui/e2e/kratos_test.go
@@ -46,6 +46,7 @@
client := newDirectAPIClient(testStack.KratosURL, testStack.KratosAdmin)
defer client.close()
username, password := uniqueKratosCredentials(t)
+ correctedUsername, _ := uniqueKratosCredentials(t)
registerThroughBrowser(t, session, username, password, "initial-registration")
first := assertAcceptedKratosSession(t, client, session)
@@ -57,20 +58,93 @@
openKratosForm(t, session.Page, testStack.UIURL+"/register", "/register")
checkpoint(t, session, "duplicate-registration-form")
fillCredentials(t, session.Page, username, password)
- clickButton(t, session.Page, "Create Account")
- assertKratosForm(t, session.Page, "/login")
- checkpoint(t, session, "duplicate-registration-return-login")
+ clickButton(t, session.Page, "Create account")
+ assertKratosForm(t, session.Page, "/register")
+ assertAuthStateSemantics(t, session.Page, true)
+ assertLatestUIResponseStatus(t, session, http.MethodPost, "/register", http.StatusConflict)
+ assertVisibleExactText(t, session.Page, usernameUnavailableMessage)
+ assertCredentialValues(t, session.Page, username, "")
+ checkpoint(t, session, "duplicate-registration-feedback")
assertNoAcceptedKratosSession(t, client, session)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
identities, err := client.kratosIdentitiesByUsername(ctx, username)
+ cancel()
if err != nil {
t.Fatal(err)
}
if len(identities) != 1 || identities[0].ID != first.Identity.ID {
t.Fatalf("duplicate registration identity count=%d, want exactly one unchanged identity", len(identities))
}
+
+ fillCredentials(t, session.Page, correctedUsername, password)
+ clickButton(t, session.Page, "Create account")
+ assertGreeting(t, session.Page, correctedUsername)
+ checkpoint(t, session, "corrected-registration-greeting")
+ corrected := assertAcceptedKratosSession(t, client, session)
+ ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second)
+ identities, err = client.kratosIdentitiesByUsername(ctx, correctedUsername)
+ cancel()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(identities) != 1 || identities[0].ID != corrected.Identity.ID {
+ t.Fatalf("corrected registration identity count=%d, want exactly one new identity", len(identities))
+ }
+}
+
+func TestKratosLocalRegistrationValidationAndCorrection(t *testing.T) {
+ session := newKratosTestSession(t)
+ client := newDirectAPIClient(testStack.KratosURL, testStack.KratosAdmin)
+ defer client.close()
+ username, password := uniqueKratosCredentials(t)
+
+ openKratosForm(t, session.Page, testStack.UIURL+"/register", "/register")
+ originalFlow := currentFlowID(t, session.Page)
+ fillCredentials(t, session.Page, "ab", "short-secret")
+ usernameField := session.Page.GetByLabel("Username", playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)})
+ if err := usernameField.Focus(); err != nil {
+ t.Fatal("focus registration username")
+ }
+ assertActiveFocusVisible(t, session.Page)
+ if err := session.Page.Keyboard().Press("Tab"); err != nil {
+ t.Fatal("tab from registration username to password")
+ }
+ assertFocusedElementID(t, session.Page, "register-password")
+ assertActiveFocusVisible(t, session.Page)
+ if err := session.Page.Keyboard().Press("Tab"); err != nil {
+ t.Fatal("tab from registration password to submit")
+ }
+ assertFocusedElementID(t, session.Page, "register-submit")
+ assertActiveFocusVisible(t, session.Page)
+ if err := session.Page.Keyboard().Press("Shift+Tab"); err != nil {
+ t.Fatal("shift-tab from registration submit to password")
+ }
+ assertFocusedElementID(t, session.Page, "register-password")
+ if err := session.Page.Keyboard().Press("Enter"); err != nil {
+ t.Fatal("submit registration with Enter from password")
+ }
+ assertVisibleExactText(t, session.Page, passwordLengthMessage)
+ assertKratosForm(t, session.Page, "/register")
+ assertAuthStateSemantics(t, session.Page, true, "register-username", "register-password")
+ if got := currentFlowID(t, session.Page); got != originalFlow {
+ t.Fatal("local validation replaced the current registration flow")
+ }
+ assertLatestUIResponseStatus(t, session, http.MethodPost, "/register", http.StatusUnprocessableEntity)
+ assertVisibleExactText(t, session.Page, usernameLengthMessage)
+ assertVisibleExactText(t, session.Page, passwordLengthMessage)
+ assertVisibleExactText(t, session.Page, passwordCompositionMessage)
+ assertInvalidFieldDescriptions(t, session.Page, "register-username", usernameLengthMessage)
+ assertInvalidFieldDescriptions(t, session.Page, "register-password", passwordLengthMessage, passwordCompositionMessage)
+ assertCredentialValues(t, session.Page, "ab", "")
+ assertNoAcceptedKratosSession(t, client, session)
+ checkpoint(t, session, "local-registration-validation")
+
+ fillCredentials(t, session.Page, username, password)
+ clickButton(t, session.Page, "Create account")
+ assertGreeting(t, session.Page, username)
+ assertAcceptedKratosSession(t, client, session)
+ checkpoint(t, session, "corrected-registration-success")
}
func TestKratosLogoutInvalidatesSession(t *testing.T) {
@@ -84,8 +158,23 @@
if _, accepted, err := client.kratosWhoAmI(context.Background(), acceptedCookies); err != nil || !accepted {
t.Fatalf("registered Kratos session was not accepted before logout: accepted=%v err=%v", accepted, err)
}
- clickLink(t, session.Page, "logout")
+ if err := session.Page.Keyboard().Press("Tab"); err != nil {
+ t.Fatal("tab to first account action")
+ }
+ assertFocusedElementText(t, session.Page, "Change password")
+ assertActiveFocusVisible(t, session.Page)
+ if err := session.Page.Keyboard().Press("Tab"); err != nil {
+ t.Fatal("tab to logout action")
+ }
+ assertFocusedElementText(t, session.Page, "Log out")
+ assertActiveFocusVisible(t, session.Page)
+ if err := session.Page.Keyboard().Press("Shift+Tab"); err != nil {
+ t.Fatal("shift-tab to change-password action")
+ }
+ assertFocusedElementText(t, session.Page, "Change password")
+ clickLink(t, session.Page, "Log out")
assertKratosForm(t, session.Page, "/login")
+ assertAuthStateSemantics(t, session.Page, false)
checkpoint(t, session, "logout-return-login")
if _, accepted, err := client.kratosWhoAmI(context.Background(), acceptedCookies); err != nil {
@@ -104,11 +193,38 @@
registerThroughBrowser(t, session, username, password, "registration-before-login")
registered := assertAcceptedKratosSession(t, client, session)
- clickLink(t, session.Page, "logout")
+ clickLink(t, session.Page, "Log out")
assertKratosForm(t, session.Page, "/login")
checkpoint(t, session, "later-login-form")
- fillCredentials(t, session.Page, username, password)
- clickButton(t, session.Page, "login")
+ usernameField := session.Page.GetByLabel("Username", playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)})
+ passwordField := session.Page.GetByLabel("Password", playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)})
+ if err := usernameField.Focus(); err != nil {
+ t.Fatal("focus username for keyboard login")
+ }
+ if err := usernameField.Fill(username); err != nil {
+ t.Fatal("fill username for keyboard login")
+ }
+ if err := session.Page.Keyboard().Press("Tab"); err != nil {
+ t.Fatal("tab from username to password")
+ }
+ assertFocusedElementID(t, session.Page, "login-password")
+ if err := passwordField.Fill(password); err != nil {
+ t.Fatal("fill password for keyboard login")
+ }
+ if err := session.Page.Keyboard().Press("Tab"); err != nil {
+ t.Fatal("tab from password to submit")
+ }
+ assertFocusedElementID(t, session.Page, "login-submit")
+ if err := session.Page.Keyboard().Press("Shift+Tab"); err != nil {
+ t.Fatal("shift-tab from submit to password")
+ }
+ assertFocusedElementID(t, session.Page, "login-password")
+ if err := session.Page.Keyboard().Press("Enter"); err != nil {
+ t.Fatal("submit login with Enter from password")
+ }
+ if err := session.Page.WaitForURL(testStack.UIURL + "/"); err != nil {
+ t.Fatalf("wait for keyboard login navigation: %v", err)
+ }
assertGreeting(t, session.Page, username)
checkpoint(t, session, "later-login-greeting")
loggedIn := assertAcceptedKratosSession(t, client, session)
@@ -125,12 +241,12 @@
_, wrongPassword := uniqueKratosCredentials(t)
registerThroughBrowser(t, session, username, password, "registration-before-wrong-password")
- clickLink(t, session.Page, "logout")
+ clickLink(t, session.Page, "Log out")
assertKratosForm(t, session.Page, "/login")
checkpoint(t, session, "wrong-password-login-form")
fillCredentials(t, session.Page, username, wrongPassword)
- clickButton(t, session.Page, "login")
- assertKratosForm(t, session.Page, "/login")
+ clickButton(t, session.Page, "Sign in")
+ assertInvalidLoginFeedback(t, session)
checkpoint(t, session, "wrong-password-return-login")
assertNoAcceptedKratosSession(t, client, session)
}
@@ -144,8 +260,8 @@
openKratosForm(t, session.Page, testStack.UIURL+"/login", "/login")
checkpoint(t, session, "unknown-username-login-form")
fillCredentials(t, session.Page, username, password)
- clickButton(t, session.Page, "login")
- assertKratosForm(t, session.Page, "/login")
+ clickButton(t, session.Page, "Sign in")
+ assertInvalidLoginFeedback(t, session)
checkpoint(t, session, "unknown-username-return-login")
assertNoAcceptedKratosSession(t, client, session)
@@ -162,10 +278,15 @@
func newKratosTestSession(t *testing.T) *browserSession {
t.Helper()
+ return newKratosTestSessionWithOptions(t, defaultBrowserSessionOptions())
+}
+
+func newKratosTestSessionWithOptions(t *testing.T, options browserSessionOptions) *browserSession {
+ t.Helper()
dir := filepath.Join(testStack.ArtifactDir, sanitizeName(t.Name()))
failedBeforeFinalize := false
- t.Cleanup(func() { assertArtifactSet(t, dir, failedBeforeFinalize) })
- session, err := newBrowserSession(t, testBrowser.Browser, testStack.ArtifactDir, []string{testStack.UIURL, testStack.KratosURL})
+ t.Cleanup(func() { assertArtifactSetWithOptions(t, dir, failedBeforeFinalize, options) })
+ session, err := newBrowserSessionWithOptions(t, testBrowser.Browser, testStack.ArtifactDir, []string{testStack.UIURL, testStack.KratosURL}, options)
if err != nil {
t.Fatal(err)
}
@@ -192,7 +313,7 @@
openKratosForm(t, session.Page, testStack.UIURL+"/register", "/register")
checkpoint(t, session, checkpointPrefix+"-form")
fillCredentials(t, session.Page, username, password)
- clickButton(t, session.Page, "Create Account")
+ clickButton(t, session.Page, "Create account")
assertGreeting(t, session.Page, username)
checkpoint(t, session, checkpointPrefix+"-greeting")
}
@@ -203,6 +324,7 @@
t.Fatal(err)
}
assertKratosForm(t, page, route)
+ assertAuthStateSemantics(t, page, false)
}
func assertKratosForm(t *testing.T, page playwright.Page, route string) {
@@ -211,10 +333,28 @@
if err != nil || u.Scheme+"://"+u.Host != testStack.UIURL || u.Path != route || u.Query().Get("flow") == "" {
t.Fatalf("expected rendered %s flow at the UI origin", route)
}
- for _, selector := range []string{`input[name="username"]`, `input[name="password"]`} {
- if err := page.Locator(selector).WaitFor(playwright.LocatorWaitForOptions{State: playwright.WaitForSelectorStateVisible, Timeout: playwright.Float(10_000)}); err != nil {
- t.Fatalf("required credential field %s did not become visible: %v", selector, err)
+ assertAuthDocumentSemantics(t, page)
+ assertCurrentAuthFormState(t, page, route)
+ prefix, passwordAutocomplete := "login", "current-password"
+ if route == "/register" {
+ prefix, passwordAutocomplete = "register", "new-password"
+ }
+ assertFieldContract(t, page, prefix+"-username", "username", "text", "username", "Username")
+ assertFieldContract(t, page, prefix+"-password", "password", "password", passwordAutocomplete, "Password")
+ for _, label := range []string{"Username", "Password"} {
+ field := page.GetByLabel(label, playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)})
+ if err := field.WaitFor(playwright.LocatorWaitForOptions{State: playwright.WaitForSelectorStateVisible, Timeout: playwright.Float(10_000)}); err != nil {
+ t.Fatalf("required credential field %q did not become visible: %v", label, err)
}
+ if count, err := field.Count(); err != nil || count != 1 {
+ t.Fatalf("credential field %q count=%d err=%v", label, count, err)
+ }
+ }
+ if count, err := page.Locator("form").Count(); err != nil || count != 1 {
+ t.Fatalf("credential form count=%d err=%v, want 1", count, err)
+ }
+ if count, err := page.Locator(`input[type="password"]`).Count(); err != nil || count != 1 {
+ t.Fatalf("password input count=%d err=%v, want 1", count, err)
}
csrf := page.Locator(`input[name="csrf_token"]`)
if err := csrf.WaitFor(playwright.LocatorWaitForOptions{State: playwright.WaitForSelectorStateAttached, Timeout: playwright.Float(10_000)}); err != nil {
@@ -226,12 +366,80 @@
}
}
+func currentFlowID(t *testing.T, page playwright.Page) string {
+ t.Helper()
+ current, err := url.Parse(page.URL())
+ if err != nil || current.Query().Get("flow") == "" {
+ t.Fatal("current browser URL does not contain a flow id")
+ }
+ return current.Query().Get("flow")
+}
+
+func assertCredentialValues(t *testing.T, page playwright.Page, username, password string) {
+ t.Helper()
+ gotUsername, err := page.GetByLabel("Username", playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)}).InputValue()
+ if err != nil || gotUsername != username {
+ t.Fatalf("username value=%q err=%v, want %q", gotUsername, err, username)
+ }
+ gotPassword, err := page.GetByLabel("Password", playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)}).InputValue()
+ if err != nil || gotPassword != password {
+ t.Fatalf("password value length=%d err=%v, want length=%d", len(gotPassword), err, len(password))
+ }
+}
+
+func assertLatestUIResponseStatus(t *testing.T, session *browserSession, method, path string, want int) {
+ t.Helper()
+ metadata := session.RequestMetadata()
+ for i := len(metadata) - 1; i >= 0; i-- {
+ request := metadata[i]
+ if request.Method == method && request.Origin == testStack.UIURL && request.Path == path && request.Status != 0 {
+ if request.Status != want {
+ t.Fatalf("latest %s %s status=%d, want %d", method, path, request.Status, want)
+ }
+ return
+ }
+ }
+ t.Fatalf("no completed UI response for %s %s", method, path)
+}
+
+func assertInvalidLoginFeedback(t *testing.T, session *browserSession) {
+ t.Helper()
+ assertKratosForm(t, session.Page, "/login")
+ assertAuthStateSemantics(t, session.Page, true)
+ assertLatestUIResponseStatus(t, session, http.MethodGet, "/login", http.StatusOK)
+ assertVisibleExactText(t, session.Page, "Username or password is incorrect.")
+ form := session.Page.Locator("form")
+ if count, err := form.Count(); err != nil || count != 1 {
+ t.Fatalf("invalid-login form count=%d err=%v", count, err)
+ }
+ if count, err := form.Locator(`[role="alert"]`).Count(); err != nil || count != 1 {
+ t.Fatalf("invalid-login in-form alert count=%d err=%v", count, err)
+ }
+ formHTML, err := form.InnerHTML()
+ if err != nil {
+ t.Fatalf("read invalid-login form source: %v", err)
+ }
+ alertIndex := strings.Index(formHTML, `role="alert"`)
+ usernameIndex := strings.Index(formHTML, `name="username"`)
+ passwordIndex := strings.Index(formHTML, `name="password"`)
+ if alertIndex < 0 || usernameIndex < 0 || passwordIndex < 0 || !(alertIndex < usernameIndex && usernameIndex < passwordIndex) {
+ t.Fatalf("invalid-login source order alert=%d username=%d password=%d", alertIndex, usernameIndex, passwordIndex)
+ }
+ assertCredentialValues(t, session.Page, "", "")
+ if count, err := session.Page.GetByRole("button", playwright.PageGetByRoleOptions{Name: "Sign in", Exact: playwright.Bool(true)}).Count(); err != nil || count != 1 {
+ t.Fatalf("invalid-login submit action count=%d err=%v", count, err)
+ }
+ if count, err := session.Page.GetByRole("link", playwright.PageGetByRoleOptions{Name: "Create account", Exact: playwright.Bool(true)}).Count(); err != nil || count != 1 {
+ t.Fatalf("invalid-login exact registration link count=%d err=%v", count, err)
+ }
+}
+
func fillCredentials(t *testing.T, page playwright.Page, username, password string) {
t.Helper()
- if err := page.Locator(`input[name="username"]`).Fill(username); err != nil {
+ if err := page.GetByLabel("Username", playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)}).Fill(username); err != nil {
t.Fatal("fill username field")
}
- if err := page.Locator(`input[name="password"]`).Fill(password); err != nil {
+ if err := page.GetByLabel("Password", playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)}).Fill(password); err != nil {
t.Fatal("fill password field")
}
}
@@ -243,9 +451,33 @@
}
}
+func assertFocusedElementID(t *testing.T, page playwright.Page, want string) {
+ t.Helper()
+ value, err := page.Evaluate(`document.activeElement && document.activeElement.id`)
+ if err != nil {
+ t.Fatalf("read focused element: %v", err)
+ }
+ got, _ := value.(string)
+ if got != want {
+ t.Fatalf("focused element id=%q, want %q", got, want)
+ }
+}
+
+func assertFocusedElementText(t *testing.T, page playwright.Page, want string) {
+ t.Helper()
+ value, err := page.Evaluate(`document.activeElement && document.activeElement.textContent.trim()`)
+ if err != nil {
+ t.Fatalf("read focused element text: %v", err)
+ }
+ got, _ := value.(string)
+ if got != want {
+ t.Fatalf("focused element text=%q, want %q", got, want)
+ }
+}
+
func clickLink(t *testing.T, page playwright.Page, name string) {
t.Helper()
- if err := page.GetByText(name, playwright.PageGetByTextOptions{Exact: playwright.Bool(true)}).Click(); err != nil {
+ if err := page.GetByRole("link", playwright.PageGetByRoleOptions{Name: name, Exact: playwright.Bool(true)}).Click(); err != nil {
t.Fatalf("click %s link: %v", name, err)
}
}
@@ -256,10 +488,30 @@
if err != nil || u.Scheme+"://"+u.Host != testStack.UIURL || u.Path != "/" {
t.Fatal("successful authentication did not return to the UI landing route")
}
+ assertAuthDocumentSemantics(t, page)
+ if count, err := page.GetByRole("heading", playwright.PageGetByRoleOptions{Name: "Account", Exact: playwright.Bool(true)}).Count(); err != nil || count != 1 {
+ t.Fatalf("account heading count=%d err=%v", count, err)
+ }
body, err := page.Locator("body").InnerText()
if err != nil || !strings.Contains(body, fmt.Sprintf("Hello %s!", username)) {
t.Fatalf("authenticated greeting is not visible: %v", err)
}
+ for _, name := range []string{"Change password", "Log out"} {
+ if count, err := page.GetByRole("link", playwright.PageGetByRoleOptions{Name: name, Exact: playwright.Bool(true)}).Count(); err != nil || count != 1 {
+ t.Fatalf("account link %q count=%d err=%v", name, count, err)
+ }
+ }
+ assertNoFormOrPasswordControls(t, page, "account")
+}
+
+func assertNoFormOrPasswordControls(t *testing.T, page playwright.Page, context string) {
+ t.Helper()
+ if count, err := page.Locator("form").Count(); err != nil || count != 0 {
+ t.Fatalf("%s form count=%d err=%v, want 0", context, count, err)
+ }
+ if count, err := page.Locator(`input[type="password"]`).Count(); err != nil || count != 0 {
+ t.Fatalf("%s password input count=%d err=%v, want 0", context, count, err)
+ }
}
func checkpoint(t *testing.T, session *browserSession, name string) {
@@ -327,13 +579,10 @@
t.Errorf("browser did not receive an expected successful workflow response from %s", origin)
}
}
- blocked := session.BlockedRequests()
- if len(blocked) == 0 {
- t.Error("browser did not block the expected external CDN font request")
+ if blocked := session.BlockedRequests(); len(blocked) != 0 {
+ t.Errorf("browser blocked requests from self-contained UI: %v", blocked)
}
- for _, request := range blocked {
- if !isExpectedBlockedBrowserRequest(request) {
- t.Errorf("browser blocked an unexpected query-free target %s", request)
- }
+ if diagnostics := session.BrowserDiagnostics(); len(diagnostics) != 0 {
+ t.Errorf("browser reported page/console errors: %v", diagnostics)
}
}
diff --git a/core/auth/ui/e2e/oauth_helpers_test.go b/core/auth/ui/e2e/oauth_helpers_test.go
index 5f62b6f..df64cc0 100644
--- a/core/auth/ui/e2e/oauth_helpers_test.go
+++ b/core/auth/ui/e2e/oauth_helpers_test.go
@@ -9,6 +9,7 @@
"net/http/httptest"
"net/url"
"os"
+ "os/exec"
"path/filepath"
"strings"
"sync"
@@ -362,6 +363,31 @@
}
}
+func TestFormatCheckPropagatesFormatterFailure(t *testing.T) {
+ repo, err := repositoryDir()
+ if err != nil {
+ t.Fatal(err)
+ }
+ failingFormatter := filepath.Join(t.TempDir(), "failing-gofmt")
+ if err := os.WriteFile(failingFormatter, []byte("#!/bin/sh\nexit 23\n"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ for _, formatter := range []string{
+ filepath.Join(t.TempDir(), "missing-gofmt"),
+ failingFormatter,
+ } {
+ command := exec.Command("make", "format-check", "GOFMT="+formatter)
+ command.Dir = repo
+ output, err := command.CombinedOutput()
+ if err == nil {
+ t.Fatalf("format-check false-passed for unavailable formatter %q: %s", formatter, output)
+ }
+ if !strings.Contains(string(output), "gofmt check failed") {
+ t.Fatalf("format-check failure for %q omitted diagnostic: %s", formatter, output)
+ }
+ }
+}
+
func TestFinalMakeTargetsRemainOptIn(t *testing.T) {
repo, err := repositoryDir()
if err != nil {
@@ -373,16 +399,22 @@
}
makefile := string(data)
for _, required := range []string{
- "test:\n\tgo test ./...\n\tgo vet ./...",
- "test-e2e:\n\tgo test -tags=e2e -count=1 -timeout=10m -v ./e2e",
- "test-e2e-offline:\n\tAUTH_UI_E2E_OFFLINE=1 go test -tags=e2e -count=1 -timeout=10m -v ./e2e",
+ "GO ?= go",
+ "GOFMT ?= gofmt",
+ "format:\n\t$(GOFMT) -w $(GO_FILES)",
+ "test:\n\t$(GO) test ./...",
+ "test-race:\n\t$(GO) test -race -count=1 ./...",
+ "vet:\n\t$(GO) vet ./...",
+ "check: format-check test test-race vet\n\t$(GO) build ./...",
+ "test-e2e:\n\t$(GO) test -tags=e2e -count=1 -timeout=10m -v ./e2e",
+ "test-e2e-offline:\n\tAUTH_UI_E2E_OFFLINE=1 $(GO) test -tags=e2e -count=1 -timeout=10m -v ./e2e",
"clean-e2e-artifacts:\n\trm -rf -- e2e/artifacts",
} {
if !strings.Contains(makefile, required) {
t.Fatalf("Makefile omitted required opt-in target contract %q", required)
}
}
- if strings.Contains(makefile, "test: test-e2e") || strings.Contains(makefile, "clean: clean-e2e-artifacts") {
+ if strings.Contains(makefile, "test-e2e-offline: install-e2e-browser") || strings.Contains(makefile, "test: test-e2e") || strings.Contains(makefile, "clean: clean-e2e-artifacts") {
t.Fatal("Makefile made an ordinary or offline target depend on an online/destructive E2E target")
}
}
diff --git a/core/auth/ui/e2e/ux_test.go b/core/auth/ui/e2e/ux_test.go
new file mode 100644
index 0000000..33c9b99
--- /dev/null
+++ b/core/auth/ui/e2e/ux_test.go
@@ -0,0 +1,511 @@
+//go:build e2e
+
+package e2e
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ playwright "github.com/mxschmitt/playwright-go"
+)
+
+type responsiveMetrics struct {
+ ViewportWidth int `json:"viewportWidth"`
+ ScrollWidth int `json:"scrollWidth"`
+ ScrollX float64 `json:"scrollX"`
+ ScrollY float64 `json:"scrollY"`
+ TerminalTop float64 `json:"terminalTop"`
+ TerminalRight float64 `json:"terminalRight"`
+ TargetCount int `json:"targetCount"`
+ SmallTargets []string `json:"smallTargets"`
+}
+
+type reachabilityMetrics struct {
+ FocusOutlineVisible bool `json:"focusOutlineVisible"`
+ CheckedCount int `json:"checkedCount"`
+ OutsideViewport []string `json:"outsideViewport"`
+}
+
+type semanticMetrics struct {
+ MainCount int `json:"mainCount"`
+ HeadingCount int `json:"headingCount"`
+ EmptyIDs []string `json:"emptyIds"`
+ DuplicateIDs []string `json:"duplicateIds"`
+ UnnamedControls []string `json:"unnamedControls"`
+ BrokenDescriptions []string `json:"brokenDescriptions"`
+ InvalidAriaValues []string `json:"invalidAriaValues"`
+ AlertCount int `json:"alertCount"`
+ AlertBeforeFirstControl bool `json:"alertBeforeFirstControl"`
+ ForbiddenAttributes []string `json:"forbiddenAttributes"`
+}
+
+type formStateMetrics struct {
+ Action string `json:"action"`
+ HiddenCount int `json:"hiddenCount"`
+ HiddenNames []string `json:"hiddenNames"`
+ EmptyHiddenValues []string `json:"emptyHiddenValues"`
+ PasswordCount int `json:"passwordCount"`
+ PasswordValueAttrs int `json:"passwordValueAttrs"`
+}
+
+type fieldContractMetrics struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Autocomplete string `json:"autocomplete"`
+ Autocapitalize string `json:"autocapitalize"`
+ Spellcheck string `json:"spellcheck"`
+ Required bool `json:"required"`
+ Label string `json:"label"`
+}
+
+type invalidFieldMetrics struct {
+ AriaInvalid string `json:"ariaInvalid"`
+ DescriptionIDs []string `json:"descriptionIds"`
+ DescriptionText []string `json:"descriptionText"`
+}
+
+type authStateMetrics struct {
+ AlertCount int `json:"alertCount"`
+ AlertBeforeFirstControl bool `json:"alertBeforeFirstControl"`
+ InvalidFieldIDs []string `json:"invalidFieldIds"`
+ InvalidWithoutDescription []string `json:"invalidWithoutDescription"`
+}
+
+func TestAuthResponsiveMatrix(t *testing.T) {
+ viewports := []browserSize{
+ {Width: 1280, Height: 720},
+ {Width: 390, Height: 844},
+ {Width: 320, Height: 568},
+ {Width: 844, Height: 390},
+ {Width: 320, Height: 240},
+ {Width: 640, Height: 360},
+ }
+ for _, viewport := range viewports {
+ viewport := viewport
+ t.Run(fmt.Sprintf("%dx%d", viewport.Width, viewport.Height), func(t *testing.T) {
+ options := browserSessionOptions{Viewport: viewport, VideoSize: viewport}
+ session := newKratosTestSessionWithOptions(t, options)
+ openKratosForm(t, session.Page, testStack.UIURL+"/login", "/login")
+ assertResponsiveLayout(t, session.Page)
+ assertFocusedFieldAndActionsReachable(t, session.Page, "Password")
+ assertLocalStylesLoaded(t, session)
+ checkpoint(t, session, fmt.Sprintf("responsive-%dx%d", viewport.Width, viewport.Height))
+ })
+ }
+}
+
+func TestAuthLongUsernameAndValidationWrapping(t *testing.T) {
+ viewport := browserSize{Width: 320, Height: 568}
+ session := newKratosTestSessionWithOptions(t, browserSessionOptions{Viewport: viewport, VideoSize: viewport})
+ client := newDirectAPIClient(testStack.KratosURL, testStack.KratosAdmin)
+ defer client.close()
+ _, password := uniqueKratosCredentials(t)
+ username := "ux-" + strings.Repeat("terminalwrap", 14)
+ status, body := postIdentityJSON(t, client, username, password)
+ if status != http.StatusOK {
+ t.Fatalf("create long-username identity status=%d: %s", status, sanitizedResponseDiagnostic(body))
+ }
+
+ openKratosForm(t, session.Page, testStack.UIURL+"/login", "/login")
+ fillCredentials(t, session.Page, username, password)
+ clickButton(t, session.Page, "Sign in")
+ assertGreeting(t, session.Page, username)
+ assertWrappedWithinTerminal(t, session.Page, "strong")
+ assertResponsiveLayout(t, session.Page)
+ checkpoint(t, session, "long-account-username-wrap")
+
+ if err := session.Context.ClearCookies(); err != nil {
+ t.Fatal(err)
+ }
+ openKratosForm(t, session.Page, testStack.UIURL+"/register", "/register")
+ fillCredentials(t, session.Page, "ab", "short")
+ clickButton(t, session.Page, "Create account")
+ assertVisibleExactText(t, session.Page, passwordCompositionMessage)
+ assertWrappedWithinTerminal(t, session.Page, `[role="alert"]`)
+ assertResponsiveLayout(t, session.Page)
+ checkpoint(t, session, "fixed-validation-message-wrap")
+}
+
+func TestAuthReducedMotionAndLocalAssets(t *testing.T) {
+ viewport := browserSize{Width: 390, Height: 844}
+ session := newKratosTestSessionWithOptions(t, browserSessionOptions{Viewport: viewport, VideoSize: viewport, ReducedMotion: true})
+ openKratosForm(t, session.Page, testStack.UIURL+"/login", "/login")
+ motion := evaluateJSON[struct {
+ Matches bool `json:"matches"`
+ ScrollBehavior string `json:"scrollBehavior"`
+ TransitionDuration string `json:"transitionDuration"`
+ AnimationDuration string `json:"animationDuration"`
+ }](t, session.Page, `() => {
+ const style = getComputedStyle(document.querySelector('input'));
+ return {
+ matches: matchMedia('(prefers-reduced-motion: reduce)').matches,
+ scrollBehavior: getComputedStyle(document.documentElement).scrollBehavior,
+ transitionDuration: style.transitionDuration,
+ animationDuration: style.animationDuration
+ };
+ }`)
+ if !motion.Matches || motion.ScrollBehavior != "auto" || motion.TransitionDuration != "0s" || motion.AnimationDuration != "0s" {
+ t.Fatalf("reduced-motion styles=%+v", motion)
+ }
+ assertLocalStylesLoaded(t, session)
+ if scripts, err := session.Page.Locator("script").Count(); err != nil || scripts != 0 {
+ t.Fatalf("product script count=%d err=%v", scripts, err)
+ }
+ checkpoint(t, session, "reduced-motion-local-assets")
+}
+
+func assertAuthDocumentSemantics(t *testing.T, page playwright.Page) {
+ t.Helper()
+ metrics := evaluateJSON[semanticMetrics](t, page, `() => {
+ const visible = (element) => {
+ const style = getComputedStyle(element);
+ const bounds = element.getBoundingClientRect();
+ return !element.hidden && style.display !== 'none' && style.visibility !== 'hidden' && bounds.width > 0 && bounds.height > 0;
+ };
+ const identify = (element) => element.id || element.getAttribute('name') || element.textContent.trim() || element.tagName.toLowerCase();
+ const ids = [...document.querySelectorAll('[id]')].map((element) => element.id);
+ const seen = new Set();
+ const duplicates = new Set();
+ for (const id of ids) {
+ if (seen.has(id)) duplicates.add(id);
+ seen.add(id);
+ }
+ const controls = [...document.querySelectorAll('input:not([type="hidden"]), button, a')].filter(visible);
+ const unnamedControls = controls.filter((element) => {
+ if (element.matches('input')) {
+ return !element.labels || element.labels.length === 0 || ![...element.labels].some((label) => label.textContent.trim());
+ }
+ return !(element.getAttribute('aria-label') || element.textContent).trim();
+ }).map(identify);
+ const brokenDescriptions = [];
+ for (const element of document.querySelectorAll('[aria-describedby]')) {
+ const references = element.getAttribute('aria-describedby').trim().split(/\s+/).filter(Boolean);
+ if (references.length === 0 || references.some((id) => {
+ const target = document.getElementById(id);
+ return !target || !target.textContent.trim();
+ })) brokenDescriptions.push(identify(element));
+ }
+ const invalidAriaValues = [...document.querySelectorAll('[aria-invalid]')]
+ .filter((element) => element.getAttribute('aria-invalid') !== 'true').map(identify);
+ const alerts = [...document.querySelectorAll('[role="alert"]')];
+ const form = document.querySelector('form');
+ const firstControl = form && form.querySelector('input:not([type="hidden"]), select, textarea, button');
+ const alertBeforeFirstControl = alerts.length === 0 || (alerts.length === 1 && firstControl && Boolean(alerts[0].compareDocumentPosition(firstControl) & Node.DOCUMENT_POSITION_FOLLOWING));
+ const forbiddenAttributes = [];
+ for (const element of document.querySelectorAll('input')) {
+ for (const name of ['autofocus', 'minlength', 'maxlength', 'pattern']) {
+ if (element.hasAttribute(name)) forbiddenAttributes.push(identify(element) + ':' + name);
+ }
+ }
+ return {
+ mainCount: document.querySelectorAll('main').length,
+ headingCount: document.querySelectorAll('h1').length,
+ emptyIds: ids.filter((id) => !id),
+ duplicateIds: [...duplicates],
+ unnamedControls,
+ brokenDescriptions,
+ invalidAriaValues,
+ alertCount: alerts.length,
+ alertBeforeFirstControl,
+ forbiddenAttributes
+ };
+ }`)
+ if metrics.MainCount != 1 || metrics.HeadingCount != 1 || len(metrics.EmptyIDs) != 0 || len(metrics.DuplicateIDs) != 0 || len(metrics.UnnamedControls) != 0 || len(metrics.BrokenDescriptions) != 0 || len(metrics.InvalidAriaValues) != 0 || metrics.AlertCount > 1 || !metrics.AlertBeforeFirstControl || len(metrics.ForbiddenAttributes) != 0 {
+ t.Fatalf("auth document semantic metrics=%+v", metrics)
+ }
+}
+
+func assertAuthStateSemantics(t *testing.T, page playwright.Page, expectAlert bool, invalidFieldIDs ...string) {
+ t.Helper()
+ metrics := evaluateJSON[authStateMetrics](t, page, `() => {
+ const alerts = [...document.querySelectorAll('[role="alert"]')];
+ const form = document.querySelector('form');
+ const firstControl = form && form.querySelector('input:not([type="hidden"]), select, textarea, button');
+ const invalidFields = [...document.querySelectorAll('[aria-invalid]')];
+ const invalidWithoutDescription = invalidFields.filter((element) => {
+ if (element.getAttribute('aria-invalid') !== 'true') return true;
+ const references = (element.getAttribute('aria-describedby') || '').trim().split(/\s+/).filter(Boolean);
+ return references.length === 0 || references.some((id) => {
+ const target = document.getElementById(id);
+ return !target || !target.textContent.trim();
+ });
+ });
+ return {
+ alertCount: alerts.length,
+ alertBeforeFirstControl: alerts.length === 1 && firstControl && Boolean(alerts[0].compareDocumentPosition(firstControl) & Node.DOCUMENT_POSITION_FOLLOWING),
+ invalidFieldIds: invalidFields.map((element) => element.id),
+ invalidWithoutDescription: invalidWithoutDescription.map((element) => element.id || element.getAttribute('name') || element.tagName.toLowerCase())
+ };
+ }`)
+ wantAlerts := 0
+ if expectAlert {
+ wantAlerts = 1
+ }
+ if metrics.AlertCount != wantAlerts || (expectAlert && !metrics.AlertBeforeFirstControl) {
+ t.Fatalf("auth alert state=%+v, want count=%d and source ordering", metrics, wantAlerts)
+ }
+ expectedInvalid := make(map[string]bool, len(invalidFieldIDs))
+ for _, id := range invalidFieldIDs {
+ expectedInvalid[id] = true
+ }
+ if len(metrics.InvalidFieldIDs) != len(expectedInvalid) {
+ t.Fatalf("invalid fields=%v, want exactly %v", metrics.InvalidFieldIDs, invalidFieldIDs)
+ }
+ for _, id := range metrics.InvalidFieldIDs {
+ if id == "" || !expectedInvalid[id] {
+ t.Fatalf("invalid fields=%v, want exactly %v", metrics.InvalidFieldIDs, invalidFieldIDs)
+ }
+ }
+ if len(metrics.InvalidWithoutDescription) != 0 {
+ t.Fatalf("invalid fields lack valid descriptions: %v", metrics.InvalidWithoutDescription)
+ }
+}
+
+func assertCurrentAuthFormState(t *testing.T, page playwright.Page, route string) {
+ t.Helper()
+ current, err := url.Parse(page.URL())
+ if err != nil || current.Scheme+"://"+current.Host != testStack.UIURL || current.Path != route || current.Query().Get("flow") == "" {
+ t.Fatalf("current auth form URL is not a valid %s flow", route)
+ }
+ metrics := evaluateJSON[formStateMetrics](t, page, `() => {
+ const form = document.querySelector('form');
+ const hidden = [...form.querySelectorAll('input[type="hidden"]')];
+ const passwords = [...form.querySelectorAll('input[type="password"]')];
+ return {
+ action: form.getAttribute('action'),
+ hiddenCount: hidden.length,
+ hiddenNames: hidden.map((element) => element.getAttribute('name') || ''),
+ emptyHiddenValues: hidden.filter((element) => !element.value).map((element) => element.getAttribute('name') || ''),
+ passwordCount: passwords.length,
+ passwordValueAttrs: passwords.filter((element) => element.hasAttribute('value')).length
+ };
+ }`)
+ action, err := url.Parse(metrics.Action)
+ if err != nil || action.IsAbs() || action.Path != route || action.Query().Get("flow") != current.Query().Get("flow") || len(action.Query()) != 1 {
+ t.Fatalf("auth form action=%q does not contain only the current %s flow", metrics.Action, route)
+ }
+ if metrics.HiddenCount != 1 || len(metrics.HiddenNames) != 1 || metrics.HiddenNames[0] != "csrf_token" || len(metrics.EmptyHiddenValues) != 0 || metrics.PasswordCount != 1 || metrics.PasswordValueAttrs != 0 {
+ t.Fatalf("auth form protocol/password metrics=%+v", metrics)
+ }
+}
+
+func assertFieldContract(t *testing.T, page playwright.Page, id, name, fieldType, autocomplete, label string) {
+ t.Helper()
+ metrics := evaluateJSON[fieldContractMetrics](t, page, `(id) => {
+ const element = document.getElementById(id);
+ return {
+ id: element && element.id,
+ name: element && element.getAttribute('name'),
+ type: element && element.getAttribute('type'),
+ autocomplete: element && element.getAttribute('autocomplete'),
+ autocapitalize: element && element.getAttribute('autocapitalize'),
+ spellcheck: element && element.getAttribute('spellcheck'),
+ required: Boolean(element && element.required),
+ label: element && element.labels ? [...element.labels].map((item) => item.textContent.trim()).join(' ') : ''
+ };
+ }`, id)
+ if metrics.ID != id || metrics.Name != name || metrics.Type != fieldType || metrics.Autocomplete != autocomplete || !metrics.Required || metrics.Label != label {
+ t.Fatalf("field %s semantic contract=%+v", id, metrics)
+ }
+ if name == "username" && (metrics.Autocapitalize != "none" || metrics.Spellcheck != "false") {
+ t.Fatalf("username field %s input-assistance contract=%+v", id, metrics)
+ }
+}
+
+func assertInvalidFieldDescriptions(t *testing.T, page playwright.Page, id string, expected ...string) {
+ t.Helper()
+ metrics := evaluateJSON[invalidFieldMetrics](t, page, `(id) => {
+ const element = document.getElementById(id);
+ const references = (element.getAttribute('aria-describedby') || '').trim().split(/\s+/).filter(Boolean);
+ return {
+ ariaInvalid: element.getAttribute('aria-invalid') || '',
+ descriptionIds: references,
+ descriptionText: references.map((reference) => document.getElementById(reference).textContent.trim())
+ };
+ }`, id)
+ if metrics.AriaInvalid != "true" || len(metrics.DescriptionIDs) == 0 {
+ t.Fatalf("invalid field %s association metrics=%+v", id, metrics)
+ }
+ for _, want := range expected {
+ found := false
+ for _, text := range metrics.DescriptionText {
+ if text == want {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Fatalf("invalid field %s descriptions=%q, want %q", id, metrics.DescriptionText, want)
+ }
+ }
+}
+
+func assertActiveFocusVisible(t *testing.T, page playwright.Page) {
+ t.Helper()
+ focus := evaluateJSON[struct {
+ ID string `json:"id"`
+ OutlineStyle string `json:"outlineStyle"`
+ OutlineWidth float64 `json:"outlineWidth"`
+ }](t, page, `() => {
+ const element = document.activeElement;
+ const style = getComputedStyle(element);
+ return {id: element && (element.id || element.textContent.trim()), outlineStyle: style.outlineStyle, outlineWidth: parseFloat(style.outlineWidth) || 0};
+ }`)
+ if focus.ID == "" || focus.OutlineStyle == "none" || focus.OutlineWidth < 2 {
+ t.Fatalf("active element does not have visible focus: %+v", focus)
+ }
+}
+
+func assertResponsiveLayout(t *testing.T, page playwright.Page) {
+ t.Helper()
+ metrics := evaluateJSON[responsiveMetrics](t, page, `() => {
+ window.scrollTo({left: 0, top: 0, behavior: 'instant'});
+ const terminal = document.querySelector('.terminal');
+ const rect = terminal.getBoundingClientRect();
+ const visible = (element) => {
+ const style = getComputedStyle(element);
+ const bounds = element.getBoundingClientRect();
+ return !element.hidden && style.display !== 'none' && style.visibility !== 'hidden' && bounds.width > 0 && bounds.height > 0;
+ };
+ const label = (element) => element.id || element.getAttribute('name') || element.textContent.trim() || element.tagName.toLowerCase();
+ const targets = [...terminal.querySelectorAll('input:not([type="hidden"]), select, textarea, button, a')].filter(visible);
+ return {
+ viewportWidth: document.documentElement.clientWidth,
+ scrollWidth: document.documentElement.scrollWidth,
+ scrollX,
+ scrollY,
+ terminalTop: rect.top,
+ terminalRight: rect.right,
+ targetCount: targets.length,
+ smallTargets: targets.filter((element) => {
+ const target = element.getBoundingClientRect();
+ return target.width < 43.5 || target.height < 43.5;
+ }).map(label)
+ };
+ }`)
+ if metrics.ScrollX < -1 || metrics.ScrollX > 1 || metrics.ScrollY < -1 || metrics.ScrollY > 1 {
+ t.Fatalf("page did not reset to the scroll origin: scrollX=%v scrollY=%v", metrics.ScrollX, metrics.ScrollY)
+ }
+ if metrics.ScrollWidth > metrics.ViewportWidth+1 {
+ t.Fatalf("page horizontal overflow: scrollWidth=%d viewport=%d", metrics.ScrollWidth, metrics.ViewportWidth)
+ }
+ if metrics.TerminalTop < -1 || metrics.TerminalRight > float64(metrics.ViewportWidth)+1 {
+ t.Fatalf("terminal outside document origin/viewport: %+v", metrics)
+ }
+ if metrics.TargetCount == 0 || len(metrics.SmallTargets) != 0 {
+ t.Fatalf("activation target metrics=%+v", metrics)
+ }
+}
+
+func assertFocusedFieldAndActionsReachable(t *testing.T, page playwright.Page, label string) {
+ t.Helper()
+ field := page.GetByLabel(label, playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)})
+ if err := field.Focus(); err != nil {
+ t.Fatalf("focus %s: %v", label, err)
+ }
+ metrics := evaluateJSON[reachabilityMetrics](t, page, `() => {
+ const field = document.activeElement;
+ const style = getComputedStyle(field);
+ const visible = (element) => {
+ const computed = getComputedStyle(element);
+ const bounds = element.getBoundingClientRect();
+ return !element.hidden && computed.display !== 'none' && computed.visibility !== 'hidden' && bounds.width > 0 && bounds.height > 0;
+ };
+ const label = (element) => element.id || element.getAttribute('name') || element.textContent.trim() || element.tagName.toLowerCase();
+ const targets = [...document.querySelectorAll('.terminal input:not([type="hidden"]), .terminal select, .terminal textarea, .terminal button, .terminal a')].filter(visible);
+ const candidates = [field, ...targets.filter((element) => element !== field)];
+ const outsideViewport = [];
+ for (const element of candidates) {
+ element.scrollIntoView({block: 'nearest', inline: 'nearest', behavior: 'instant'});
+ const bounds = element.getBoundingClientRect();
+ if (bounds.top < -1 || bounds.left < -1 || bounds.bottom > innerHeight + 1 || bounds.right > innerWidth + 1) {
+ outsideViewport.push(label(element));
+ }
+ }
+ return {
+ focusOutlineVisible: style.outlineStyle !== 'none' && parseFloat(style.outlineWidth) >= 2,
+ checkedCount: candidates.length,
+ outsideViewport
+ };
+ }`)
+ if !metrics.FocusOutlineVisible || metrics.CheckedCount == 0 || len(metrics.OutsideViewport) != 0 {
+ t.Fatalf("focus/reachability metrics=%+v", metrics)
+ }
+}
+
+func assertWrappedWithinTerminal(t *testing.T, page playwright.Page, selector string) {
+ t.Helper()
+ metrics := evaluateJSON[struct {
+ ElementRight float64 `json:"elementRight"`
+ TerminalRight float64 `json:"terminalRight"`
+ ScrollWidth int `json:"scrollWidth"`
+ ClientWidth int `json:"clientWidth"`
+ }](t, page, `(selector) => {
+ const element = document.querySelector(selector);
+ const terminal = document.querySelector('.terminal');
+ return {
+ elementRight: element.getBoundingClientRect().right,
+ terminalRight: terminal.getBoundingClientRect().right,
+ scrollWidth: document.documentElement.scrollWidth,
+ clientWidth: document.documentElement.clientWidth
+ };
+ }`, selector)
+ if metrics.ElementRight > metrics.TerminalRight+1 || metrics.ScrollWidth > metrics.ClientWidth+1 {
+ t.Fatalf("content did not wrap within terminal: %+v", metrics)
+ }
+}
+
+func assertLocalStylesLoaded(t *testing.T, session *browserSession) {
+ t.Helper()
+ styles := map[string]bool{"/static/base.css": false, "/static/main.css": false}
+ for _, request := range session.RequestMetadata() {
+ if request.Status == 0 {
+ continue
+ }
+ if request.Origin != testStack.UIURL && request.Origin != testStack.KratosURL {
+ t.Fatalf("browser received response from external origin: %+v", request)
+ }
+ if _, ok := styles[request.Path]; ok && request.Origin == testStack.UIURL && request.Method == http.MethodGet && request.Status == http.StatusOK {
+ styles[request.Path] = true
+ }
+ }
+ for path, loaded := range styles {
+ if !loaded {
+ t.Fatalf("local stylesheet did not load with 200: %s", path)
+ }
+ }
+ if blocked := session.BlockedRequests(); len(blocked) != 0 {
+ t.Fatalf("self-contained page made blocked requests: %v", blocked)
+ }
+ if diagnostics := session.BrowserDiagnostics(); len(diagnostics) != 0 {
+ t.Fatalf("page emitted browser diagnostics: %v", diagnostics)
+ }
+}
+
+func evaluateJSON[T any](t *testing.T, page playwright.Page, expression string, args ...any) T {
+ t.Helper()
+ var value any
+ var err error
+ if len(args) == 0 {
+ value, err = page.Evaluate(expression)
+ } else {
+ value, err = page.Evaluate(expression, args[0])
+ }
+ if err != nil {
+ t.Fatalf("evaluate browser metrics: %v", err)
+ }
+ encoded, err := json.Marshal(value)
+ if err != nil {
+ t.Fatalf("encode browser metrics: %v", err)
+ }
+ var result T
+ if err := json.Unmarshal(encoded, &result); err != nil {
+ t.Fatalf("decode browser metrics: %v", err)
+ }
+ return result
+}