Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ func (r *RunCmd) Run(globals *Globals) error {
Browser: cmp.Or(parsed.GetMetaFieldString("Browsers"), globals.Browser),
Headless: headless,
Actions: actions,
Context: playwright.NewExecutionContext(),
}

} else if strings.HasSuffix(r.File, ".yaml") || strings.HasSuffix(r.File, ".yml") {
Expand Down
26 changes: 25 additions & 1 deletion lib.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ var actions = []string{
"ExpectId",
"ExpectJSProperty",
"ExpectToMatchAriaSnapshot",
// Property-based testing actions
"Always",
"Fuzz",
"Extract",
"Eventually",
"Next",
}

func lexerActionsFromMap() string {
Expand All @@ -95,6 +101,8 @@ var (
{`Action`, lexerActionsFromMap()},
{`MetaField`, `ID|Title|URL|Description|Headless|Browser`}, // TODO: support ScreenSizes, Extends
{`Ident`, `[a-zA-Z][a-zA-Z_\d]*`},
{`Variable`, `\$[a-zA-Z][a-zA-Z_\d]*`},
{`Number`, `\d+`},
{`String`, `"(?:\\.|[^"])*"`},
{`Selector`, `"(?:\\.|[^"])*"`},
{"comment", `[#;][^\n]*`},
Expand Down Expand Up @@ -145,11 +153,27 @@ type MetaField struct {
type Action struct {
// Pos lexer.Position
Action string `@Action`
Selector *Selector `@@`
Selector *Selector `[ @@ ]`
Arguments *String `[ @@ ]`
Variable *Variable `[ @@ ]`
Number *Number `[ @@ ]`
comment *string `@comment`
}

type Variable struct {
// Pos lexer.Position
Variable string `@Variable`
}

func (Variable) value() {}

type Number struct {
// Pos lexer.Position
Number int `@Number`
}

func (Number) value() {}

type String struct {
// Pos lexer.Position
String string `@String`
Expand Down
39 changes: 39 additions & 0 deletions playwright/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ var actionMap = map[string]ActionFunc{
"ExpectId": StubExpectAction,
"ExpectJSProperty": StubExpectAction,
"ExpectToMatchAriaSnapshot": StubExpectAction,
// Property-based testing actions
"Always": AlwaysAction,
"Fuzz": FuzzAction,
"Extract": ExtractAction,
"Eventually": EventuallyAction,
"Next": NextAction,
}

func castOptions[Dest any](action *ExecutorAction) (dest *Dest, err error) {
Expand Down Expand Up @@ -499,3 +505,36 @@ func UploadMultipleFilesAction(page playwrightgo.Page, action *ExecutorAction) e
}
return page.Locator(action.Selector).First().SetInputFiles(files, *opts)
}

// Property-based testing actions

func AlwaysAction(page playwrightgo.Page, action *ExecutorAction) error {
// This action registers an invariant - it doesn't execute anything
// The invariant will be checked after every mutating action
// Implementation is handled in the execution context
return nil
}

func FuzzAction(page playwrightgo.Page, action *ExecutorAction) error {
// This action performs random interactions within a scope
// Implementation is handled in the execution context with timeouts
return nil
}

func ExtractAction(page playwrightgo.Page, action *ExecutorAction) error {
// This action extracts a value from the page and stores it in a variable
// Implementation is handled in the execution context
return nil
}

func EventuallyAction(page playwrightgo.Page, action *ExecutorAction) error {
// This action polls until a condition is met
// Implementation is handled in the execution context with timeouts
return nil
}

func NextAction(page playwrightgo.Page, action *ExecutorAction) error {
// This action asserts the immediate next state
// Implementation is handled in the execution context
return nil
}
154 changes: 138 additions & 16 deletions playwright/playwright.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,31 +17,44 @@ import (
const Name = "playwright"

type Executor struct {
Name string `json:"name,omitempty" yaml:"name,omitempty"`
Description string `json:"description,omitempty" yaml:"description,omitempty"`
URL string `json:"url" yaml:"url"`
Browser string `json:"browser" yaml:"browser"`
Device string `json:"device" yaml:"device"`
Actions []ExecutorAction `json:"actions" yaml:"actions"`
Headless bool `json:"headless" yaml:"headless"`
Name string `json:"name,omitempty" yaml:"name,omitempty"`
Description string `json:"description,omitempty" yaml:"description,omitempty"`
URL string `json:"url" yaml:"url"`
Browser string `json:"browser" yaml:"browser"`
Device string `json:"device" yaml:"device"`
Actions []ExecutorAction `json:"actions" yaml:"actions"`
Headless bool `json:"headless" yaml:"headless"`
Context *ExecutionContext `json:"-" yaml:"-"` // Execution context for property-based testing
}

type ExecutorAction struct {
Action string `json:"action"` // The action to perform, must be a valid/supported action
Selector string `json:"selector" yaml:"selector"` // DOM selector or expression
Content string `json:"content,omitempty" yaml:"content"` // Content for actions that require it
Options any `json:"options,omitempty" yaml:"options"` // Options applicable to the given action
Action string `json:"action"` // The action to perform, must be a valid/supported action
Selector string `json:"selector" yaml:"selector"` // DOM selector or expression
Content string `json:"content,omitempty" yaml:"content"` // Content for actions that require it
Variable string `json:"variable,omitempty" yaml:"variable"` // Variable name for Extract
Number int `json:"number,omitempty" yaml:"number"` // Number for Fuzz step count
Options any `json:"options,omitempty" yaml:"options"` // Options applicable to the given action
}

func NewExecutorAction(act *basi.Action) *ExecutorAction {
args := ""
if act.Arguments != nil {
args = act.Arguments.String
}
variable := ""
if act.Variable != nil {
variable = act.Variable.Variable
}
number := 0
if act.Number != nil {
number = act.Number.Number
}
return &ExecutorAction{
Action: act.Action,
Selector: act.Selector.Selector,
Content: args,
Variable: variable,
Number: number,
Options: nil,
}
}
Expand Down Expand Up @@ -125,9 +138,18 @@ func (e *Executor) Run(ctx context.Context) (interface{}, error) {
if err != nil {
return nil, fmt.Errorf("could not goto: %w", err)
}
// Set current origin for fuzzing boundary checks
if e.Context == nil {
e.Context = NewExecutionContext()
}
if currentURL := page.URL(); currentURL != "" {
if u, err := url.Parse(currentURL); err == nil {
e.Context.CurrentOrigin = u.Scheme + "://" + u.Host
}
}
}

err = performActions(ctx, page, e.Actions)
err = performActions(ctx, page, e.Actions, e.Context)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -162,19 +184,111 @@ func (e *Executor) Run(ctx context.Context) (interface{}, error) {
}, nil
}

func performActions(ctx context.Context, page playwrightgo.Page, actions []ExecutorAction) error {
func performActions(ctx context.Context, page playwrightgo.Page, actions []ExecutorAction, execCtx *ExecutionContext) error {
if execCtx == nil {
execCtx = NewExecutionContext()
}
assertions := playwrightgo.NewPlaywrightAssertions()
var lastLocator playwrightgo.Locator
for i, action := range actions {
if action.Action == "" {
return fmt.Errorf("action cannot be empty, please specify an action")
}
if action.Selector == "" {
return fmt.Errorf("selector cannot be empty, please specify a selector")
}

actionName := action.Action

// Handle Always action - register invariant
if actionName == "Always" {
// The embedded expect action is in the Selector field
invariant := Invariant{
Action: action.Selector,
Selector: "",
Content: "",
}
execCtx.Invariants.Invariants = append(execCtx.Invariants.Invariants, invariant)
continue
}

// Handle Extract action
if actionName == "Extract" {
locator := page.Locator(action.Content)
textContent, err := locator.TextContent()
if err != nil {
return fmt.Errorf("failed to extract text from selector %s: %w", action.Content, err)
}
execCtx.Variables.Set(action.Selector, textContent)
continue
}

// Handle Fuzz action
if actionName == "Fuzz" {
// Parse the arguments: "stepCount scopeSelector ignoreSelector"
parts := strings.Fields(action.Selector)
if len(parts) < 1 {
return fmt.Errorf("Fuzz action requires at least a step count")
}
stepCount := 10 // default
if len(parts) >= 1 {
if parsedCount, err := strconv.Atoi(parts[0]); err == nil {
stepCount = parsedCount
}
}
scopeSelector := ".body" // default
if len(parts) >= 2 {
scopeSelector = parts[1]
}
ignoreSelector := "" // default
if len(parts) >= 3 {
ignoreSelector = parts[2]
}

fuzzAction := ExecutorAction{
Action: "Fuzz",
Selector: scopeSelector,
Content: ignoreSelector,
Number: stepCount,
}
err := performFuzz(ctx, page, &fuzzAction, execCtx)
if err != nil {
return fmt.Errorf("fuzz action failed: %w", err)
}
continue
}

// Handle Eventually action
if actionName == "Eventually" {
// The embedded expect action is in the Selector field
embeddedActionObj := ExecutorAction{
Action: action.Selector,
Selector: "",
Content: "",
}
err := performEventually(ctx, page, &embeddedActionObj, execCtx, assertions)
if err != nil {
return fmt.Errorf("eventually action failed: %w", err)
}
continue
}

// Handle Next action
if actionName == "Next" {
// The embedded expect action is in the Selector field
embeddedActionObj := ExecutorAction{
Action: action.Selector,
Selector: "",
Content: "",
}
err := performNext(page, &embeddedActionObj, execCtx, assertions)
if err != nil {
return fmt.Errorf("next action failed: %w", err)
}
continue
}

if action.Selector == "" && actionName != "Extract" && actionName != "Fuzz" {
return fmt.Errorf("selector cannot be empty for action %s, please specify a selector", actionName)
}

if strings.HasPrefix(actionName, "Find") {
if loc, err := tryFindLocator(page, action); err != nil {
return fmt.Errorf("failed to find a element on the page using: '%s'", cmp.Or(action.Selector, action.Content))
Expand Down Expand Up @@ -226,6 +340,14 @@ func performActions(ctx context.Context, page playwrightgo.Page, actions []Execu
if actErr != nil {
return actErr
}

// Check invariants after mutating actions
if IsMutatingAction(actionName) {
err := execCtx.CheckInvariants(page, assertions)
if err != nil {
return err
}
}
}
return nil
}
Expand Down
4 changes: 2 additions & 2 deletions playwright/playwright_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ func TestPerformActions(t *testing.T) {
t.Error("failed to set testPage content")
}

err = performActions(context.Background(), page, testActions)
err = performActions(context.Background(), page, testActions, NewExecutionContext())
if err != nil {
t.Errorf("failed to test actions %v", err)
}
Expand Down Expand Up @@ -132,7 +132,7 @@ func TestPerformFindOnEmptyElement(t *testing.T) {
t.Error("failed to set testPage content")
}

err = performActions(context.Background(), page, testActions)
err = performActions(context.Background(), page, testActions, NewExecutionContext())
if err == nil {
t.Errorf("expected error while performing actions")
}
Expand Down
Loading
Loading