From 3220ca41408bde2fae0f38aea9dbec4f17cb71f3 Mon Sep 17 00:00:00 2001 From: Daniel Moran Date: Sat, 17 Jan 2026 05:57:10 -0800 Subject: [PATCH] feat(chromium): add `waitForSelector` option to Chromium conversions (#1446) * Add `waitForSelector` option to Chromium conversions Closes #960 As an alternative to waiting on an expression, this allows users to wait for a specific node matching a selector to become visible in the HTML / at the remote URL before converting to PDF. * Fix style / prettify --- pkg/modules/chromium/browser.go | 2 ++ pkg/modules/chromium/chromium.go | 9 ++++++ pkg/modules/chromium/routes.go | 19 +++++++++++ pkg/modules/chromium/tasks.go | 16 ++++++++++ .../features/chromium_convert_html.feature | 30 +++++++++++++++++ .../features/chromium_convert_url.feature | 32 +++++++++++++++++++ .../feature-rich-html-remote/index.html | 8 ++++- .../testdata/feature-rich-html/index.html | 8 ++++- 8 files changed, 122 insertions(+), 2 deletions(-) diff --git a/pkg/modules/chromium/browser.go b/pkg/modules/chromium/browser.go index 1191c9c4..b4594830 100644 --- a/pkg/modules/chromium/browser.go +++ b/pkg/modules/chromium/browser.go @@ -290,6 +290,7 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outp forceExactColorsActionFunc(logger, options.PrintBackground), emulateMediaTypeActionFunc(logger, options.EmulatedMediaType), waitForExpressionBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitForExpression), + waitForSelectorVisibleBeforePrintActionFunc(logger, options.WaitForSelector), waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay), // PDF specific. printToPdfActionFunc(logger, outputPath, options), @@ -315,6 +316,7 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *zap.Logger, ur forceExactColorsActionFunc(logger, true), emulateMediaTypeActionFunc(logger, options.EmulatedMediaType), waitForExpressionBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitForExpression), + waitForSelectorVisibleBeforePrintActionFunc(logger, options.WaitForSelector), waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay), // Screenshot specific. setDeviceMetricsOverride(logger, options.Width, options.Height), diff --git a/pkg/modules/chromium/chromium.go b/pkg/modules/chromium/chromium.go index 6412923e..4e5a6a0f 100644 --- a/pkg/modules/chromium/chromium.go +++ b/pkg/modules/chromium/chromium.go @@ -33,6 +33,10 @@ var ( // returns an exception or undefined. ErrInvalidEvaluationExpression = errors.New("invalid evaluation expression") + // ErrInvalidSelectorQuery happens if a selector query returns an exception + // or undefined. + ErrInvalidSelectorQuery = errors.New("invalid selector query") + // ErrRpccMessageTooLarge happens when the messages received by // ChromeDevTools are larger than 100 MB. ErrRpccMessageTooLarge = errors.New("rpcc message too large") @@ -142,6 +146,10 @@ type Options struct { // converting an HTML document until it returns true WaitForExpression string + // WaitForSelector is the element query to wait until visible before + // converting an HTML document. + WaitForSelector string + // Cookies are the cookies to put in the Chromium cookies' jar. Cookies []Cookie @@ -173,6 +181,7 @@ func DefaultOptions() Options { WaitDelay: 0, WaitWindowStatus: "", WaitForExpression: "", + WaitForSelector: "", Cookies: nil, UserAgent: "", ExtraHttpHeaders: nil, diff --git a/pkg/modules/chromium/routes.go b/pkg/modules/chromium/routes.go index b503cadd..6398594b 100644 --- a/pkg/modules/chromium/routes.go +++ b/pkg/modules/chromium/routes.go @@ -55,6 +55,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) { waitDelay time.Duration waitWindowStatus string waitForExpression string + waitForSelector string cookies []Cookie userAgent string extraHttpHeaders []ExtraHttpHeader @@ -108,6 +109,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) { Duration("waitDelay", &waitDelay, defaultOptions.WaitDelay). String("waitWindowStatus", &waitWindowStatus, defaultOptions.WaitWindowStatus). String("waitForExpression", &waitForExpression, defaultOptions.WaitForExpression). + String("waitForSelector", &waitForSelector, defaultOptions.WaitForSelector). Custom("cookies", func(value string) error { if value == "" { cookies = defaultOptions.Cookies @@ -237,6 +239,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) { WaitDelay: waitDelay, WaitWindowStatus: waitWindowStatus, WaitForExpression: waitForExpression, + WaitForSelector: waitForSelector, Cookies: cookies, UserAgent: userAgent, ExtraHttpHeaders: extraHttpHeaders, @@ -809,6 +812,22 @@ func handleChromiumError(err error, options Options) error { ) } + if errors.Is(err, ErrInvalidSelectorQuery) { + if options.WaitForSelector == "" { + // We only expect to see this error if the user specified a selector. + // If they didn't and we still generated the error, return a 500. + return err + } + + return api.WrapError( + err, + api.NewSentinelHttpError( + http.StatusBadRequest, + fmt.Sprintf("The selector '%s' (waitForSelector) returned an exception or undefined", options.WaitForSelector), + ), + ) + } + if errors.Is(err, ErrInvalidHttpStatusCode) { return api.WrapError( err, diff --git a/pkg/modules/chromium/tasks.go b/pkg/modules/chromium/tasks.go index 13e00432..1169d9fa 100644 --- a/pkg/modules/chromium/tasks.go +++ b/pkg/modules/chromium/tasks.go @@ -512,3 +512,19 @@ func waitForExpressionBeforePrintActionFunc(logger *zap.Logger, disableJavaScrip } } } + +func waitForSelectorVisibleBeforePrintActionFunc(logger *zap.Logger, selector string) chromedp.ActionFunc { + return func(ctx context.Context) error { + if selector == "" { + logger.Debug("no wait selector") + return nil + } + + logger.Debug(fmt.Sprintf("wait until '%s' is visible before print", selector)) + err := chromedp.WaitVisible(selector, chromedp.ByQuery, chromedp.RetryInterval(time.Duration(100)*time.Millisecond)).Do(ctx) + if err != nil { + return fmt.Errorf("wait visible: %v: %w", err, ErrInvalidSelectorQuery) + } + return nil + } +} diff --git a/test/integration/features/chromium_convert_html.feature b/test/integration/features/chromium_convert_html.feature index 19d7550c..876446c8 100644 --- a/test/integration/features/chromium_convert_html.feature +++ b/test/integration/features/chromium_convert_html.feature @@ -195,6 +195,36 @@ Feature: /forms/chromium/convert/html Wait delay > 2 seconds or expression window globalVar === 'ready' returns true. """ + Scenario: POST /forms/chromium/convert/html (Wait For Selector) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/feature-rich-html/index.html | file | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should NOT have the following content at page 1: + """ + Wait on selector returns true. + """ + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): + | files | testdata/feature-rich-html/index.html | file | + | waitForSelector | #wait-selector | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Wait on selector returns true. + """ + Scenario: POST /forms/chromium/convert/html (Emulated Media Type) Given I have a default Gotenberg container When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): diff --git a/test/integration/features/chromium_convert_url.feature b/test/integration/features/chromium_convert_url.feature index d352ccc2..ae6fb88e 100644 --- a/test/integration/features/chromium_convert_url.feature +++ b/test/integration/features/chromium_convert_url.feature @@ -255,6 +255,38 @@ Feature: /forms/chromium/convert/url Wait delay > 2 seconds or expression window globalVar === 'ready' returns true. """ + Scenario: POST /forms/chromium/convert/url (Wait For Selector) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should NOT have the following content at page 1: + """ + Wait on selector returns true. + """ + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field | + | waitForSelector | #wait-selector | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then there should be the following file(s) in the response: + | foo.pdf | + Then the "foo.pdf" PDF should have 1 page(s) + Then the "foo.pdf" PDF should have the following content at page 1: + """ + Wait on selector returns true. + """ + Scenario: POST /forms/chromium/convert/url (Emulated Media Type) Given I have a default Gotenberg container Given I have a static server diff --git a/test/integration/testdata/feature-rich-html-remote/index.html b/test/integration/testdata/feature-rich-html-remote/index.html index edfb3077..f7183740 100644 --- a/test/integration/testdata/feature-rich-html-remote/index.html +++ b/test/integration/testdata/feature-rich-html-remote/index.html @@ -37,8 +37,14 @@ const delay = (ms) => new Promise((res) => setTimeout(res, ms)); delay(2000).then(() => { - document.getElementById("wait").style.display = "contents"; + const waitText = document.getElementById("wait"); + waitText.style.display = "contents"; window.globalVar = "ready"; + + const newText = document.createElement("p"); + newText.id = "wait-selector"; + newText.textContent = "Wait on selector returns true."; + waitText.parentNode.insertBefore(newText, waitText); }); diff --git a/test/integration/testdata/feature-rich-html/index.html b/test/integration/testdata/feature-rich-html/index.html index d68ad3fd..dc1537d4 100644 --- a/test/integration/testdata/feature-rich-html/index.html +++ b/test/integration/testdata/feature-rich-html/index.html @@ -46,8 +46,14 @@ const delay = (ms) => new Promise((res) => setTimeout(res, ms)); delay(2000).then(() => { - document.getElementById("wait").style.display = "contents"; + const waitText = document.getElementById("wait"); + waitText.style.display = "contents"; window.globalVar = "ready"; + + const newText = document.createElement("p"); + newText.id = "wait-selector"; + newText.textContent = "Wait on selector returns true."; + waitText.parentNode.insertBefore(newText, waitText); });