hako/e2e/e2eutil/helper.go
Felipe M. d4457cc4f6
All checks were successful
CI / goreleaser-lint (pull_request) Successful in 8s
CI / format (pull_request) Successful in 1m33s
CI / lint (pull_request) Successful in 3m23s
CI / build (pull_request) Successful in 3m5s
CI / test (pull_request) Successful in 4m38s
CI / e2e (pull_request) Successful in 26m24s
fix(e2e): move off the retired Playwright driver CDN
playwright-go v0.5200.1 fetches its driver from playwright.azureedge.net
and two sibling mirrors. Microsoft retired that CDN, so the e2e job died
on a 404 before it ever reached a test. The replacement host serves no
builds/driver zip for any version — 1.52 through 1.62 all answer 400 —
so there is nothing to pin or mirror to.

v0.6100.0 onwards pulls playwright-core from npm and Node from
nodejs.org instead, and moves the module path back to
github.com/mxschmitt/playwright-go. Take v0.6201.1, the latest. The
Playwright 1.52 to 1.62 jump needs no changes beyond the import path.

The CLI must match the module the tests link against, and the three
places that ran it had already drifted apart: the workflow pinned
v0.5200.1, the Makefile and scripts/e2e.sh asked for @latest. Both now
read the version from e2e/go.mod, and the workflow calls make e2e-install.
2026-09-22 16:38:00 +02:00

466 lines
13 KiB
Go

package e2eutil
import (
"fmt"
"path/filepath"
"strings"
"testing"
"time"
"github.com/mxschmitt/playwright-go"
"github.com/stretchr/testify/require"
)
var globalReporter *Reporter
// TestHelper provides utility methods for E2E testing.
type TestHelper struct {
t *testing.T
testName string
baseURL string
page playwright.Page
screenshotNum int
screenshotDir string
}
// NewTestHelper creates a new TestHelper instance.
func NewTestHelper(t *testing.T, testName string, baseURL string, page playwright.Page) *TestHelper {
t.Helper()
// Initialize reporter if not already set
if globalReporter == nil {
globalReporter = GetReporter()
}
// Get screenshot directory
screenshotDir := filepath.Join("..", "screenshots")
h := &TestHelper{
t: t,
testName: testName,
baseURL: baseURL,
page: page,
screenshotNum: 1,
screenshotDir: screenshotDir,
}
// Initialize test in reporter
h.recordResult("Test started", true, nil, "", 0)
// Automatically mark test as complete when it finishes
t.Cleanup(func() {
if globalReporter != nil {
globalReporter.TestFinished(t)
}
})
return h
}
// recordResult is a helper to record results to the reporter if available
func (h *TestHelper) recordResult(description string, passed bool, err error, screenshotPath string, duration time.Duration) {
if globalReporter != nil {
globalReporter.AddResult(h.testName, description, passed, err, screenshotPath, duration)
}
}
// Navigate navigates to the given path relative to the base URL.
func (h *TestHelper) Navigate(path string) error {
h.t.Helper()
url := h.baseURL + path
h.t.Logf("Navigating to %s", url)
_, err := h.page.Goto(url)
return err
}
// Fill fills an input field with the given value.
func (h *TestHelper) Fill(selector, value string) error {
h.t.Helper()
h.t.Logf("Filling %s with value", selector)
return h.page.Fill(selector, value)
}
// Click clicks on an element matching the given selector.
func (h *TestHelper) Click(selector string) error {
h.t.Helper()
h.t.Logf("Clicking %s", selector)
return h.page.Click(selector)
}
// Screenshot captures a screenshot with an auto-incrementing number.
// Returns the path to the saved screenshot.
func (h *TestHelper) Screenshot(name string) string {
h.t.Helper()
// Create filename with number prefix
filename := fmt.Sprintf("%02d-%s.png", h.screenshotNum, name)
h.screenshotNum++
// Full path
fullPath := filepath.Join(h.screenshotDir, filename)
// Capture screenshot
_, err := h.page.Screenshot(playwright.PageScreenshotOptions{
Path: playwright.String(fullPath),
})
if err != nil {
h.t.Logf("Failed to capture screenshot: %v", err)
return ""
}
h.t.Logf("Screenshot saved: %s", fullPath)
return fullPath
}
// WaitForSelector waits for an element matching the selector to appear.
func (h *TestHelper) WaitForSelector(selector string, timeout time.Duration) error {
h.t.Helper()
h.t.Logf("Waiting for selector %s", selector)
_, err := h.page.WaitForSelector(selector, playwright.PageWaitForSelectorOptions{
Timeout: playwright.Float(float64(timeout.Milliseconds())),
})
return err
}
// AssertTextContains asserts that an element contains the expected text.
func (h *TestHelper) AssertTextContains(selector, expected string) {
h.t.Helper()
start := time.Now()
text, err := h.page.Locator(selector).TextContent()
if err != nil {
duration := time.Since(start)
screenshot := h.Screenshot("assertion-failure-text-content")
h.recordResult(fmt.Sprintf("Assert text in %s contains %q", selector, expected), false,
fmt.Errorf("Failed to get text content of %s: %v", selector, err), screenshot, duration)
require.NoError(h.t, err, "Failed to get text content of %s", selector)
return
}
passed := strings.Contains(text, expected)
var screenshot string
var resultErr error
if !passed {
resultErr = fmt.Errorf("Element %s should contain text %q, got %q", selector, expected, text)
screenshot = h.Screenshot("assertion-failure-text-contains")
}
duration := time.Since(start)
h.recordResult(fmt.Sprintf("Assert text in %s contains %q", selector, expected), passed, resultErr, screenshot, duration)
require.Contains(h.t, text, expected, "Element %s should contain text %q", selector, expected)
}
// AssertURL asserts that the current URL contains the expected string.
func (h *TestHelper) AssertURL(expected string) {
h.t.Helper()
start := time.Now()
url := h.page.URL()
passed := strings.Contains(url, expected)
var err error
var screenshot string
if !passed {
err = fmt.Errorf("URL should contain %q, got %q", expected, url)
screenshot = h.Screenshot("assertion-failure-url")
}
duration := time.Since(start)
h.recordResult(fmt.Sprintf("Assert URL contains %q", expected), passed, err, screenshot, duration)
require.Contains(h.t, url, expected, "URL should contain %q, got %q", expected, url)
h.t.Logf("URL contains %q: %s", expected, url)
}
// GetURL returns the current page URL.
func (h *TestHelper) GetURL() string {
return h.page.URL()
}
// IsVisible checks if any element matching the selector is visible.
// Uses .First() to avoid Playwright strict-mode errors when the selector
// resolves to multiple elements.
func (h *TestHelper) IsVisible(selector string) bool {
h.t.Helper()
visible, err := h.page.Locator(selector).First().IsVisible()
if err != nil {
h.t.Logf("Error checking visibility of %s: %v", selector, err)
return false
}
return visible
}
// GetText returns the text content of an element.
func (h *TestHelper) GetText(selector string) (string, error) {
h.t.Helper()
return h.page.Locator(selector).TextContent()
}
// AssertNotVisible asserts that an element is not visible.
func (h *TestHelper) AssertNotVisible(selector string, message string) {
h.t.Helper()
start := time.Now()
visible := h.IsVisible(selector)
passed := !visible
if message == "" {
message = fmt.Sprintf("Element %s should not be visible", selector)
}
var err error
var screenshot string
if !passed {
err = fmt.Errorf("%s", message)
screenshot = h.Screenshot("assertion-failure-not-visible")
}
duration := time.Since(start)
h.recordResult(fmt.Sprintf("Assert %s not visible", selector), passed, err, screenshot, duration)
require.False(h.t, visible, message)
}
// AssertVisible asserts that an element is visible.
func (h *TestHelper) AssertVisible(selector string, message string) {
h.t.Helper()
start := time.Now()
visible := h.IsVisible(selector)
passed := visible
if message == "" {
message = fmt.Sprintf("Element %s should be visible", selector)
}
var err error
var screenshot string
if !passed {
err = fmt.Errorf("%s", message)
screenshot = h.Screenshot("assertion-failure-visible")
}
duration := time.Since(start)
h.recordResult(fmt.Sprintf("Assert %s visible", selector), passed, err, screenshot, duration)
require.True(h.t, visible, message)
}
// GetCurrentPath returns the path portion of the current URL (without baseURL).
func (h *TestHelper) GetCurrentPath() string {
h.t.Helper()
url := h.page.URL()
// Remove baseURL to get just the path
path := strings.TrimPrefix(url, h.baseURL)
return path
}
// LoginAsAdmin logs in as admin user with default credentials.
func (h *TestHelper) LoginAsAdmin() error {
h.t.Helper()
// Navigate to login page
if err := h.Navigate("/login"); err != nil {
return fmt.Errorf("failed to navigate to login page: %w", err)
}
// Wait for login form
if err := h.WaitForSelector("#email", 5*time.Second); err != nil {
return fmt.Errorf("email input not found: %w", err)
}
// Fill credentials
if err := h.Fill("#email", "hako@hako.com"); err != nil {
return fmt.Errorf("failed to fill email: %w", err)
}
if err := h.Fill("#password", "hako"); err != nil {
return fmt.Errorf("failed to fill password: %w", err)
}
// Submit form
if err := h.Click("button[type=\"submit\"]"); err != nil {
return fmt.Errorf("failed to click submit: %w", err)
}
// Wait for redirect to home
time.Sleep(2 * time.Second)
h.t.Log("Logged in as admin")
return nil
}
// Logout logs out the current user.
func (h *TestHelper) Logout() error {
h.t.Helper()
// Click logout button (adjust selector based on actual UI)
if err := h.Click("a[href=\"/logout\"]"); err != nil {
return fmt.Errorf("failed to click logout: %w", err)
}
time.Sleep(1 * time.Second)
h.t.Log("Logged out")
return nil
}
// CreateLink creates a new link via the Quick Add form.
// Navigates to /home first to ensure the form is available even when called
// repeatedly (after the first call the user is redirected to /links).
func (h *TestHelper) CreateLink(url string) error {
h.t.Helper()
// Ensure we're on the home page where the Quick Add form lives.
if !strings.HasSuffix(h.GetURL(), "/home") {
if err := h.Navigate("/home"); err != nil {
return fmt.Errorf("failed to navigate to home: %w", err)
}
}
// Wait for URL input on home page
if err := h.WaitForSelector("input[name=\"url\"]", 5*time.Second); err != nil {
return fmt.Errorf("URL input not found: %w", err)
}
// Fill URL
if err := h.Fill("input[name=\"url\"]", url); err != nil {
return fmt.Errorf("failed to fill URL: %w", err)
}
// Submit form
if err := h.Click("button[type=\"submit\"]"); err != nil {
return fmt.Errorf("failed to submit form: %w", err)
}
// Wait for redirect to /links page
start := time.Now()
for time.Since(start) < 5*time.Second {
currentURL := h.GetURL()
if strings.Contains(currentURL, "/links") {
h.t.Logf("Successfully redirected to links page")
break
}
time.Sleep(500 * time.Millisecond)
}
h.t.Logf("Created link: %s", url)
return nil
}
// SearchLinks performs a search for links.
func (h *TestHelper) SearchLinks(query string) error {
h.t.Helper()
// Wait for search input
if err := h.WaitForSelector("input[name=\"search\"]", 5*time.Second); err != nil {
return fmt.Errorf("search input not found: %w", err)
}
// Fill search query
if err := h.Fill("input[name=\"search\"]", query); err != nil {
return fmt.Errorf("failed to fill search query: %w", err)
}
// Submit search (might be automatic or require button click)
time.Sleep(1 * time.Second)
h.t.Logf("Searched for: %s", query)
return nil
}
// WaitForText waits for an element to contain specific text.
func (h *TestHelper) WaitForText(selector, text string, timeout time.Duration) error {
h.t.Helper()
start := time.Now()
for time.Since(start) < timeout {
content, err := h.GetText(selector)
if err == nil && strings.Contains(content, text) {
return nil
}
time.Sleep(500 * time.Millisecond)
}
return fmt.Errorf("timeout waiting for text %q in selector %s", text, selector)
}
// WaitForElementCount waits for a specific number of elements matching the selector.
func (h *TestHelper) WaitForElementCount(selector string, count int, timeout time.Duration) error {
h.t.Helper()
start := time.Now()
for time.Since(start) < timeout {
elements, err := h.page.Locator(selector).All()
if err == nil && len(elements) == count {
return nil
}
time.Sleep(500 * time.Millisecond)
}
return fmt.Errorf("timeout waiting for %d elements matching %s", count, selector)
}
// SelectDropdown selects an option from a dropdown.
func (h *TestHelper) SelectDropdown(selector, value string) error {
h.t.Helper()
h.t.Logf("Selecting %s from dropdown %s", value, selector)
_, err := h.page.SelectOption(selector, playwright.SelectOptionValues{Values: &[]string{value}})
return err
}
// WaitForArchiveComplete waits for an archive to complete processing.
func (h *TestHelper) WaitForArchiveComplete(timeout time.Duration) error {
h.t.Helper()
// Wait for status indicator to show "completed" or similar
// This is a generic implementation - adjust based on actual UI
return h.WaitForText(".archive-status", "completed", timeout)
}
// GetArchiveStatus returns the current archive status.
func (h *TestHelper) GetArchiveStatus() (string, error) {
h.t.Helper()
return h.GetText(".archive-status")
}
// FillForm fills multiple form fields at once.
func (h *TestHelper) FillForm(fields map[string]string) error {
h.t.Helper()
for selector, value := range fields {
if err := h.Fill(selector, value); err != nil {
return fmt.Errorf("failed to fill %s: %w", selector, err)
}
}
return nil
}
// GetElementCount returns the number of elements matching the selector.
func (h *TestHelper) GetElementCount(selector string) (int, error) {
h.t.Helper()
elements, err := h.page.Locator(selector).All()
if err != nil {
return 0, err
}
return len(elements), nil
}
// WaitForPath waits for the current path to contain the expected path.
func (h *TestHelper) WaitForPath(expectedPath string, timeout time.Duration) error {
h.t.Helper()
start := time.Now()
for time.Since(start) < timeout {
currentPath := h.GetCurrentPath()
if strings.Contains(currentPath, expectedPath) {
return nil
}
time.Sleep(100 * time.Millisecond)
}
return fmt.Errorf("timeout waiting for path containing %q", expectedPath)
}