Skip to content
Open
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
105 changes: 79 additions & 26 deletions cmd/mdl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
Expand All @@ -18,6 +20,7 @@ import (
"goa.design/model/mdl"
model "goa.design/model/pkg"

"github.com/chromedp/cdproto"
"github.com/chromedp/chromedp"
)

Expand All @@ -42,23 +45,22 @@ type (
browserAutomationResult struct {
Status string `json:"status"`
Error string `json:"error"`
URL string `json:"url"`
}

browserAutomationEvaluator func(context.Context, *browserAutomationResult) error

// SliceFlag implements flag.Value for repeated string flags.
SliceFlag []string
)

const (
browserAutomationReadyScript = `(() => {
const status = document.documentElement.dataset.mdlAutomationStatus;
return status === "complete" || status === "error";
})()`

browserAutomationResultScript = `(() => {
const root = document.documentElement;
return {
status: root.dataset.mdlAutomationStatus || "",
error: root.dataset.mdlAutomationError || "",
url: document.location.href,
};
})()`
)
Expand Down Expand Up @@ -403,21 +405,13 @@ func renderViewsHeadless(baseURL, outDir string, views []string, cfg config) err
return err
}
for _, key := range views {
// Build URL with automation params
q := fmt.Sprintf("?id=%s&auto=1&save=1", key)
if direction != "" {
q += "&direction=" + direction
}
if cfg.compact {
q += "&compact=1"
}
url := baseURL + "/" + q
renderURL := browserAutomationURL(baseURL, key, direction, cfg.compact)

// Remove any existing file to ensure fresh wait
svgPath := filepath.Join(outDir, key+".svg")
_ = os.Remove(svgPath)

if err := run(url, svgPath, cfg.timeout); err != nil {
if err := run(renderURL, svgPath, cfg.timeout); err != nil {
return fmt.Errorf("render %s: %w", key, err)
}
fmt.Println("Saved:", svgPath)
Expand All @@ -438,6 +432,22 @@ func normalizeLayoutDirection(direction string) (string, error) {
}
}

// browserAutomationURL builds an encoded editor URL for one headless render.
func browserAutomationURL(baseURL, key, direction string, compact bool) string {
query := url.Values{
"id": {key},
"auto": {"1"},
"save": {"1"},
}
if direction != "" {
query.Set("direction", direction)
}
if compact {
query.Set("compact", "1")
}
return baseURL + "/?" + query.Encode()
}

// navigateExec abstracts browser navigation and automation result handling.
type navigateExec func(url string, svgPath string, timeout time.Duration) error

Expand Down Expand Up @@ -474,17 +484,11 @@ func chromedpExec(timeout time.Duration, debug bool, fn func(exec navigateExec)

navCtx, navCancel := context.WithTimeout(tabCtx, timeout)
defer navCancel()
var result browserAutomationResult
if err := chromedp.Run(navCtx,
chromedp.Navigate(url),
chromedp.Poll(
browserAutomationReadyScript,
nil,
chromedp.WithPollingInterval(100*time.Millisecond),
chromedp.WithPollingTimeout(0),
),
chromedp.Evaluate(browserAutomationResultScript, &result),
); err != nil {
if err := chromedp.Run(navCtx, chromedp.Navigate(url)); err != nil {
return fmt.Errorf("navigate to browser automation page: %w", err)
}
result, err := waitForBrowserAutomation(navCtx, 100*time.Millisecond, evaluateBrowserAutomation)
if err != nil {
return fmt.Errorf("wait for browser automation status: %w", err)
}
if result.Status == "error" {
Expand All @@ -506,6 +510,55 @@ func chromedpExec(timeout time.Duration, debug bool, fn func(exec navigateExec)
return fn(exec)
}

// waitForBrowserAutomation polls from a fresh page execution context after
// navigation so a redirect cannot invalidate the complete wait operation.
func waitForBrowserAutomation(
ctx context.Context,
interval time.Duration,
evaluate browserAutomationEvaluator,
) (browserAutomationResult, error) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
var lastResult browserAutomationResult
for {
var result browserAutomationResult
err := evaluate(ctx, &result)
if err == nil {
lastResult = result
if result.Status == "complete" || result.Status == "error" {
return result, nil
}
} else if !isTargetNavigationError(err) {
return browserAutomationResult{}, err
}

select {
case <-ctx.Done():
return browserAutomationResult{}, fmt.Errorf(
"%w (last status %q at %q)",
ctx.Err(),
lastResult.Status,
lastResult.URL,
)
case <-ticker.C:
}
}
}

// evaluateBrowserAutomation reads the current page's render status.
func evaluateBrowserAutomation(ctx context.Context, result *browserAutomationResult) error {
return chromedp.Run(ctx, chromedp.Evaluate(browserAutomationResultScript, result))
}

// isTargetNavigationError reports the Chrome event emitted when a page
// navigation replaces the JavaScript execution context used for one poll.
func isTargetNavigationError(err error) bool {
var protocolError *cdproto.Error
return errors.As(err, &protocolError) &&
protocolError.Code == -32000 &&
protocolError.Message == "Inspected target navigated or closed"
}

func loadDesign(pkg string, debug bool) (*mdl.Design, error) {
b, err := codegen.JSON(pkg, debug)
if err != nil {
Expand Down
48 changes: 48 additions & 0 deletions cmd/mdl/main_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"context"
"fmt"
"net/http"
"net/http/httptest"
Expand All @@ -9,6 +10,8 @@ import (
"testing"
"time"

"github.com/chromedp/cdproto"

"goa.design/model/mdl"
)

Expand Down Expand Up @@ -87,6 +90,51 @@ func TestNormalizeLayoutDirection(t *testing.T) {
}
}

func TestBrowserAutomationURL(t *testing.T) {
actual := browserAutomationURL(
"http://127.0.0.1:8080",
"AURA Services & Runtime",
"RIGHT",
true,
)
expected := "http://127.0.0.1:8080/?auto=1&compact=1&direction=RIGHT&id=AURA+Services+%26+Runtime&save=1"
if actual != expected {
t.Fatalf("expected %q, got %q", expected, actual)
}
}

func TestWaitForBrowserAutomationRetriesNavigation(t *testing.T) {
attempts := 0
evaluate := func(_ context.Context, result *browserAutomationResult) error {
attempts++
switch attempts {
case 1:
return &cdproto.Error{
Code: -32000,
Message: "Inspected target navigated or closed",
}
case 2:
result.Status = "running"
default:
result.Status = "complete"
}
return nil
}

ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
result, err := waitForBrowserAutomation(ctx, time.Millisecond, evaluate)
if err != nil {
t.Fatalf("wait for browser automation: %v", err)
}
if result.Status != "complete" {
t.Fatalf("expected complete status, got %q", result.Status)
}
if attempts != 3 {
t.Fatalf("expected three status checks, got %d", attempts)
}
}

func TestChromedpExecReportsBrowserAutomationError(t *testing.T) {
if !hasChrome() {
t.Skip("skipping: Chrome/Chromium not available in PATH")
Expand Down
Loading