mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-17 12:42:16 +01:00
feat(chromium): add failOnResourceLoadingFailed and failOnResourceHttpStatusCodes form fields
This commit is contained in:
@@ -299,13 +299,25 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
|
|||||||
})
|
})
|
||||||
|
|
||||||
var (
|
var (
|
||||||
invalidHttpStatusCode error
|
invalidHttpStatusCode error
|
||||||
invalidHttpStatusCodeMu sync.RWMutex
|
invalidHttpStatusCodeMu sync.RWMutex
|
||||||
|
invalidResourceHttpStatusCode error
|
||||||
|
invalidResourceHttpStatusCodeMu sync.RWMutex
|
||||||
)
|
)
|
||||||
|
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/613.
|
// See:
|
||||||
if len(options.FailOnHttpStatusCodes) != 0 {
|
// https://github.com/gotenberg/gotenberg/issues/613.
|
||||||
listenForEventResponseReceived(taskCtx, logger, url, options.FailOnHttpStatusCodes, &invalidHttpStatusCode, &invalidHttpStatusCodeMu)
|
// https://github.com/gotenberg/gotenberg/issues/1021.
|
||||||
|
if len(options.FailOnHttpStatusCodes) != 0 || len(options.FailOnResourceHttpStatusCodes) != 0 {
|
||||||
|
listenForEventResponseReceived(taskCtx, logger, eventResponseReceivedOptions{
|
||||||
|
mainPageUrl: url,
|
||||||
|
failOnHttpStatusCodes: options.FailOnHttpStatusCodes,
|
||||||
|
invalidHttpStatusCode: &invalidHttpStatusCode,
|
||||||
|
invalidHttpStatusCodeMu: &invalidHttpStatusCodeMu,
|
||||||
|
failOnResourceOnHttpStatusCode: options.FailOnResourceHttpStatusCodes,
|
||||||
|
invalidResourceHttpStatusCode: &invalidResourceHttpStatusCode,
|
||||||
|
invalidResourceHttpStatusCodeMu: &invalidResourceHttpStatusCodeMu,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -319,14 +331,22 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
loadingFailed error
|
loadingFailed error
|
||||||
loadingFailedMu sync.RWMutex
|
loadingFailedMu sync.RWMutex
|
||||||
|
resourceLoadingFailed error
|
||||||
|
resourceLoadingFailedMu sync.RWMutex
|
||||||
)
|
)
|
||||||
|
|
||||||
// See:
|
// See:
|
||||||
// https://github.com/gotenberg/gotenberg/issues/913
|
// https://github.com/gotenberg/gotenberg/issues/913.
|
||||||
// https://github.com/gotenberg/gotenberg/issues/959
|
// https://github.com/gotenberg/gotenberg/issues/959.
|
||||||
listenForEventLoadingFailed(taskCtx, logger, &loadingFailed, &loadingFailedMu)
|
// https://github.com/gotenberg/gotenberg/issues/1021.
|
||||||
|
listenForEventLoadingFailed(taskCtx, logger, eventLoadingFailedOptions{
|
||||||
|
loadingFailed: &loadingFailed,
|
||||||
|
loadingFailedMu: &loadingFailedMu,
|
||||||
|
resourceLoadingFailed: &resourceLoadingFailed,
|
||||||
|
resourceLoadingFailedMu: &resourceLoadingFailedMu,
|
||||||
|
})
|
||||||
|
|
||||||
err = chromedp.Run(taskCtx, tasks...)
|
err = chromedp.Run(taskCtx, tasks...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -355,6 +375,14 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
|
|||||||
return fmt.Errorf("%v: %w", invalidHttpStatusCode, ErrInvalidHttpStatusCode)
|
return fmt.Errorf("%v: %w", invalidHttpStatusCode, ErrInvalidHttpStatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1021.
|
||||||
|
invalidResourceHttpStatusCodeMu.RLock()
|
||||||
|
defer invalidResourceHttpStatusCodeMu.RUnlock()
|
||||||
|
|
||||||
|
if invalidResourceHttpStatusCode != nil {
|
||||||
|
return fmt.Errorf("%v: %w", invalidResourceHttpStatusCode, ErrInvalidResourceHttpStatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/262.
|
// See https://github.com/gotenberg/gotenberg/issues/262.
|
||||||
consoleExceptionsMu.RLock()
|
consoleExceptionsMu.RLock()
|
||||||
defer consoleExceptionsMu.RUnlock()
|
defer consoleExceptionsMu.RUnlock()
|
||||||
@@ -364,8 +392,8 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
|
|||||||
}
|
}
|
||||||
|
|
||||||
// See:
|
// See:
|
||||||
// https://github.com/gotenberg/gotenberg/issues/913
|
// https://github.com/gotenberg/gotenberg/issues/913.
|
||||||
// https://github.com/gotenberg/gotenberg/issues/959
|
// https://github.com/gotenberg/gotenberg/issues/959.
|
||||||
loadingFailedMu.RLock()
|
loadingFailedMu.RLock()
|
||||||
defer loadingFailedMu.RUnlock()
|
defer loadingFailedMu.RUnlock()
|
||||||
|
|
||||||
@@ -373,6 +401,13 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
|
|||||||
return fmt.Errorf("%v: %w", loadingFailed, ErrLoadingFailed)
|
return fmt.Errorf("%v: %w", loadingFailed, ErrLoadingFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1021.
|
||||||
|
if options.FailOnResourceLoadingFailed {
|
||||||
|
if resourceLoadingFailed != nil {
|
||||||
|
return fmt.Errorf("%v: %w", resourceLoadingFailed, ErrResourceLoadingFailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -446,6 +446,44 @@ func TestChromiumBrowser_pdf(t *testing.T) {
|
|||||||
expectError: true,
|
expectError: true,
|
||||||
expectedError: ErrInvalidHttpStatusCode,
|
expectedError: ErrInvalidHttpStatusCode,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
scenario: "ErrInvalidResourceHttpStatusCode",
|
||||||
|
browser: newChromiumBrowser(
|
||||||
|
browserArguments{
|
||||||
|
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||||
|
wsUrlReadTimeout: 5 * time.Second,
|
||||||
|
allowList: regexp2.MustCompile("", 0),
|
||||||
|
denyList: regexp2.MustCompile("", 0),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
fs: func() *gotenberg.FileSystem {
|
||||||
|
fs := gotenberg.NewFileSystem()
|
||||||
|
|
||||||
|
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.WriteFile(fmt.Sprintf("%s/style.css", fs.WorkingDirPath()), []byte("body{font-family: Arial, Helvetica, sans-serif;}"), 0o755)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<html><head><link rel=\"stylesheet\" type=\"text/css\" href=\"style.css\"></head><body><h1>ErrInvalidResourceHttpStatusCode</h1></body></html>"), 0o755)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fs
|
||||||
|
}(),
|
||||||
|
options: PdfOptions{
|
||||||
|
Options: Options{FailOnResourceHttpStatusCodes: []int64{200}},
|
||||||
|
},
|
||||||
|
noDeadline: false,
|
||||||
|
start: true,
|
||||||
|
expectError: true,
|
||||||
|
expectedError: ErrInvalidResourceHttpStatusCode,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
scenario: "ErrConsoleExceptions",
|
scenario: "ErrConsoleExceptions",
|
||||||
browser: newChromiumBrowser(
|
browser: newChromiumBrowser(
|
||||||
@@ -505,6 +543,39 @@ func TestChromiumBrowser_pdf(t *testing.T) {
|
|||||||
expectError: true,
|
expectError: true,
|
||||||
expectedError: ErrLoadingFailed,
|
expectedError: ErrLoadingFailed,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
scenario: "ErrResourceLoadingFailed",
|
||||||
|
browser: newChromiumBrowser(
|
||||||
|
browserArguments{
|
||||||
|
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||||
|
wsUrlReadTimeout: 5 * time.Second,
|
||||||
|
allowList: regexp2.MustCompile("", 0),
|
||||||
|
denyList: regexp2.MustCompile("", 0),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
fs: func() *gotenberg.FileSystem {
|
||||||
|
fs := gotenberg.NewFileSystem()
|
||||||
|
|
||||||
|
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<html><head><link rel=\"stylesheet\" type=\"text/css\" href=\"http://localhost:100/style.css\"></head><body><h1>ErrResourceLoadingFailed</h1></body></html>"), 0o755)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fs
|
||||||
|
}(),
|
||||||
|
options: PdfOptions{
|
||||||
|
Options: Options{FailOnResourceLoadingFailed: true},
|
||||||
|
},
|
||||||
|
noDeadline: false,
|
||||||
|
start: true,
|
||||||
|
expectError: true,
|
||||||
|
expectedError: ErrResourceLoadingFailed,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
scenario: "clear cache",
|
scenario: "clear cache",
|
||||||
browser: newChromiumBrowser(
|
browser: newChromiumBrowser(
|
||||||
@@ -1537,6 +1608,44 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
|
|||||||
expectError: true,
|
expectError: true,
|
||||||
expectedError: ErrInvalidHttpStatusCode,
|
expectedError: ErrInvalidHttpStatusCode,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
scenario: "ErrInvalidResourceHttpStatusCode",
|
||||||
|
browser: newChromiumBrowser(
|
||||||
|
browserArguments{
|
||||||
|
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||||
|
wsUrlReadTimeout: 5 * time.Second,
|
||||||
|
allowList: regexp2.MustCompile("", 0),
|
||||||
|
denyList: regexp2.MustCompile("", 0),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
fs: func() *gotenberg.FileSystem {
|
||||||
|
fs := gotenberg.NewFileSystem()
|
||||||
|
|
||||||
|
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.WriteFile(fmt.Sprintf("%s/style.css", fs.WorkingDirPath()), []byte("body{font-family: Arial, Helvetica, sans-serif;}"), 0o755)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<html><head><link rel=\"stylesheet\" type=\"text/css\" href=\"style.css\"></head><body><h1>ErrInvalidResourceHttpStatusCode</h1></body></html>"), 0o755)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fs
|
||||||
|
}(),
|
||||||
|
options: ScreenshotOptions{
|
||||||
|
Options: Options{FailOnResourceHttpStatusCodes: []int64{299}},
|
||||||
|
},
|
||||||
|
noDeadline: false,
|
||||||
|
start: true,
|
||||||
|
expectError: true,
|
||||||
|
expectedError: ErrInvalidResourceHttpStatusCode,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
scenario: "ErrConsoleExceptions",
|
scenario: "ErrConsoleExceptions",
|
||||||
browser: newChromiumBrowser(
|
browser: newChromiumBrowser(
|
||||||
@@ -1580,7 +1689,6 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
|
|||||||
denyList: regexp2.MustCompile("", 0),
|
denyList: regexp2.MustCompile("", 0),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
fs: func() *gotenberg.FileSystem {
|
fs: func() *gotenberg.FileSystem {
|
||||||
fs := gotenberg.NewFileSystem()
|
fs := gotenberg.NewFileSystem()
|
||||||
|
|
||||||
@@ -1597,6 +1705,39 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
|
|||||||
expectError: true,
|
expectError: true,
|
||||||
expectedError: ErrLoadingFailed,
|
expectedError: ErrLoadingFailed,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
scenario: "ErrResourceLoadingFailed",
|
||||||
|
browser: newChromiumBrowser(
|
||||||
|
browserArguments{
|
||||||
|
binPath: os.Getenv("CHROMIUM_BIN_PATH"),
|
||||||
|
wsUrlReadTimeout: 5 * time.Second,
|
||||||
|
allowList: regexp2.MustCompile("", 0),
|
||||||
|
denyList: regexp2.MustCompile("", 0),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
fs: func() *gotenberg.FileSystem {
|
||||||
|
fs := gotenberg.NewFileSystem()
|
||||||
|
|
||||||
|
err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.WriteFile(fmt.Sprintf("%s/index.html", fs.WorkingDirPath()), []byte("<html><head><link rel=\"stylesheet\" type=\"text/css\" href=\"http://localhost:100/style.css\"></head><body><h1>ErrResourceLoadingFailed</h1></body></html>"), 0o755)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error but got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fs
|
||||||
|
}(),
|
||||||
|
options: ScreenshotOptions{
|
||||||
|
Options: Options{FailOnResourceLoadingFailed: true},
|
||||||
|
},
|
||||||
|
noDeadline: false,
|
||||||
|
start: true,
|
||||||
|
expectError: true,
|
||||||
|
expectedError: ErrResourceLoadingFailed,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
scenario: "clear cache",
|
scenario: "clear cache",
|
||||||
browser: newChromiumBrowser(
|
browser: newChromiumBrowser(
|
||||||
|
|||||||
@@ -38,14 +38,22 @@ var (
|
|||||||
// matches with one of the entry in [Options.FailOnHttpStatusCodes].
|
// matches with one of the entry in [Options.FailOnHttpStatusCodes].
|
||||||
ErrInvalidHttpStatusCode = errors.New("invalid HTTP status code")
|
ErrInvalidHttpStatusCode = errors.New("invalid HTTP status code")
|
||||||
|
|
||||||
|
// ErrInvalidResourceHttpStatusCode happens when the status code from one
|
||||||
|
// or more resources matches with one of the entry in
|
||||||
|
// [Options.FailOnResourceHttpStatusCodes].
|
||||||
|
ErrInvalidResourceHttpStatusCode = errors.New("invalid resource HTTP status code")
|
||||||
|
|
||||||
// ErrConsoleExceptions happens when there are exceptions in the Chromium
|
// ErrConsoleExceptions happens when there are exceptions in the Chromium
|
||||||
// console. It also happens only if the [Options.FailOnConsoleExceptions]
|
// console. It also happens only if the [Options.FailOnConsoleExceptions]
|
||||||
// is set to true.
|
// is set to true.
|
||||||
ErrConsoleExceptions = errors.New("console exceptions")
|
ErrConsoleExceptions = errors.New("console exceptions")
|
||||||
|
|
||||||
// ErrLoadingFailed happens when a URL failed to load.
|
// ErrLoadingFailed happens when the main page failed to load.
|
||||||
ErrLoadingFailed = errors.New("loading failed")
|
ErrLoadingFailed = errors.New("loading failed")
|
||||||
|
|
||||||
|
// ErrResourceLoadingFailed happens when one or more resources failed to load.
|
||||||
|
ErrResourceLoadingFailed = errors.New("resource loading failed")
|
||||||
|
|
||||||
// PDF specific.
|
// PDF specific.
|
||||||
|
|
||||||
// ErrOmitBackgroundWithoutPrintBackground happens if
|
// ErrOmitBackgroundWithoutPrintBackground happens if
|
||||||
@@ -86,6 +94,14 @@ type Options struct {
|
|||||||
// code from the main page matches with one of its entries.
|
// code from the main page matches with one of its entries.
|
||||||
FailOnHttpStatusCodes []int64
|
FailOnHttpStatusCodes []int64
|
||||||
|
|
||||||
|
// FailOnResourceHttpStatusCodes sets if the conversion should fail if the
|
||||||
|
// status code from at least one resource matches with one if its entries.
|
||||||
|
FailOnResourceHttpStatusCodes []int64
|
||||||
|
|
||||||
|
// FailOnResourceLoadingFailed sets if the conversion should fail like the
|
||||||
|
// main page if Chromium fails to load at least one resource.
|
||||||
|
FailOnResourceLoadingFailed bool
|
||||||
|
|
||||||
// FailOnConsoleExceptions sets if the conversion should fail if there are
|
// FailOnConsoleExceptions sets if the conversion should fail if there are
|
||||||
// exceptions in the Chromium console.
|
// exceptions in the Chromium console.
|
||||||
FailOnConsoleExceptions bool
|
FailOnConsoleExceptions bool
|
||||||
@@ -124,17 +140,19 @@ type Options struct {
|
|||||||
// DefaultOptions returns the default values for Options.
|
// DefaultOptions returns the default values for Options.
|
||||||
func DefaultOptions() Options {
|
func DefaultOptions() Options {
|
||||||
return Options{
|
return Options{
|
||||||
SkipNetworkIdleEvent: true,
|
SkipNetworkIdleEvent: true,
|
||||||
FailOnHttpStatusCodes: []int64{499, 599},
|
FailOnHttpStatusCodes: []int64{499, 599},
|
||||||
FailOnConsoleExceptions: false,
|
FailOnResourceHttpStatusCodes: nil,
|
||||||
WaitDelay: 0,
|
FailOnResourceLoadingFailed: false,
|
||||||
WaitWindowStatus: "",
|
FailOnConsoleExceptions: false,
|
||||||
WaitForExpression: "",
|
WaitDelay: 0,
|
||||||
Cookies: nil,
|
WaitWindowStatus: "",
|
||||||
UserAgent: "",
|
WaitForExpression: "",
|
||||||
ExtraHttpHeaders: nil,
|
Cookies: nil,
|
||||||
EmulatedMediaType: "",
|
UserAgent: "",
|
||||||
OmitBackground: false,
|
ExtraHttpHeaders: nil,
|
||||||
|
EmulatedMediaType: "",
|
||||||
|
OmitBackground: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package chromium
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
@@ -136,14 +137,36 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, option
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type eventResponseReceivedOptions struct {
|
||||||
|
mainPageUrl string
|
||||||
|
failOnHttpStatusCodes []int64
|
||||||
|
invalidHttpStatusCode *error
|
||||||
|
invalidHttpStatusCodeMu *sync.RWMutex
|
||||||
|
failOnResourceOnHttpStatusCode []int64
|
||||||
|
invalidResourceHttpStatusCode *error
|
||||||
|
invalidResourceHttpStatusCodeMu *sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
// listenForEventResponseReceived listens for an invalid HTTP status code that
|
// listenForEventResponseReceived listens for an invalid HTTP status code that
|
||||||
// is returned by the main page.
|
// is returned by the main page or by one or more resources.
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/613.
|
// See:
|
||||||
func listenForEventResponseReceived(ctx context.Context, logger *zap.Logger, url string, failOnHttpStatusCodes []int64, invalidHttpStatusCode *error, invalidHttpStatusCodeMu *sync.RWMutex) {
|
// https://github.com/gotenberg/gotenberg/issues/613.
|
||||||
|
// https://github.com/gotenberg/gotenberg/issues/1021.
|
||||||
|
func listenForEventResponseReceived(
|
||||||
|
ctx context.Context,
|
||||||
|
logger *zap.Logger,
|
||||||
|
options eventResponseReceivedOptions,
|
||||||
|
) {
|
||||||
for _, code := range []int64{199, 299, 399, 499, 599} {
|
for _, code := range []int64{199, 299, 399, 499, 599} {
|
||||||
if slices.Contains(failOnHttpStatusCodes, code) {
|
if slices.Contains(options.failOnHttpStatusCodes, code) {
|
||||||
for i := code - 99; i <= code; i++ {
|
for i := code - 99; i <= code; i++ {
|
||||||
failOnHttpStatusCodes = append(failOnHttpStatusCodes, i)
|
options.failOnHttpStatusCodes = append(options.failOnHttpStatusCodes, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if slices.Contains(options.failOnResourceOnHttpStatusCode, code) {
|
||||||
|
for i := code - 99; i <= code; i++ {
|
||||||
|
options.failOnResourceOnHttpStatusCode = append(options.failOnResourceOnHttpStatusCode, i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,41 +174,53 @@ func listenForEventResponseReceived(ctx context.Context, logger *zap.Logger, url
|
|||||||
chromedp.ListenTarget(ctx, func(ev interface{}) {
|
chromedp.ListenTarget(ctx, func(ev interface{}) {
|
||||||
switch ev := ev.(type) {
|
switch ev := ev.(type) {
|
||||||
case *network.EventResponseReceived:
|
case *network.EventResponseReceived:
|
||||||
if ev.Response.URL != url {
|
if ev.Response.URL == options.mainPageUrl {
|
||||||
|
logger.Debug(fmt.Sprintf("event EventResponseReceived fired for main page: %+v", ev.Response))
|
||||||
|
|
||||||
|
if slices.Contains(options.failOnHttpStatusCodes, ev.Response.Status) {
|
||||||
|
options.invalidHttpStatusCodeMu.Lock()
|
||||||
|
defer options.invalidHttpStatusCodeMu.Unlock()
|
||||||
|
|
||||||
|
*options.invalidHttpStatusCode = fmt.Errorf("%d: %s", ev.Response.Status, ev.Response.StatusText)
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Debug(fmt.Sprintf("event EventResponseReceived fired for main page: %+v", ev.Response))
|
logger.Debug(fmt.Sprintf("event EventResponseReceived fired for a resource: %+v", ev.Response))
|
||||||
|
|
||||||
if slices.Contains(failOnHttpStatusCodes, ev.Response.Status) {
|
if slices.Contains(options.failOnResourceOnHttpStatusCode, ev.Response.Status) {
|
||||||
invalidHttpStatusCodeMu.Lock()
|
options.invalidResourceHttpStatusCodeMu.Lock()
|
||||||
defer invalidHttpStatusCodeMu.Unlock()
|
defer options.invalidResourceHttpStatusCodeMu.Unlock()
|
||||||
|
|
||||||
*invalidHttpStatusCode = fmt.Errorf("%d: %s", ev.Response.Status, ev.Response.StatusText)
|
*options.invalidResourceHttpStatusCode = multierr.Append(
|
||||||
|
*options.invalidResourceHttpStatusCode,
|
||||||
|
fmt.Errorf("%s - %d: %s", ev.Response.URL, ev.Response.Status, http.StatusText(int(ev.Response.Status))),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type eventLoadingFailedOptions struct {
|
||||||
|
loadingFailed *error
|
||||||
|
loadingFailedMu *sync.RWMutex
|
||||||
|
resourceLoadingFailed *error
|
||||||
|
resourceLoadingFailedMu *sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
// listenForEventLoadingFailed listens for an event indicating that the main
|
// listenForEventLoadingFailed listens for an event indicating that the main
|
||||||
// page failed to load.
|
// page or one or more resources failed to load.
|
||||||
// See:
|
// See:
|
||||||
// https://github.com/gotenberg/gotenberg/issues/913.
|
// https://github.com/gotenberg/gotenberg/issues/913.
|
||||||
// https://github.com/gotenberg/gotenberg/issues/959.
|
// https://github.com/gotenberg/gotenberg/issues/959.
|
||||||
func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, loadingFailed *error, loadingFailedMu *sync.RWMutex) {
|
// https://github.com/gotenberg/gotenberg/issues/1021.
|
||||||
|
func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, options eventLoadingFailedOptions) {
|
||||||
chromedp.ListenTarget(ctx, func(ev interface{}) {
|
chromedp.ListenTarget(ctx, func(ev interface{}) {
|
||||||
switch ev := ev.(type) {
|
switch ev := ev.(type) {
|
||||||
case *network.EventLoadingFailed:
|
case *network.EventLoadingFailed:
|
||||||
logger.Debug(fmt.Sprintf("event EventLoadingFailed fired: %+v", ev.ErrorText))
|
logger.Debug(fmt.Sprintf("event EventLoadingFailed fired: %+v", ev.ErrorText))
|
||||||
|
|
||||||
if ev.Type != network.ResourceTypeDocument {
|
|
||||||
logger.Debug("skip EventLoadingFailed: is not resource type Document")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Supposition: except iframe, an event loading failed with a
|
|
||||||
// resource type Document is about the main page.
|
|
||||||
|
|
||||||
// We are looking for common errors.
|
// We are looking for common errors.
|
||||||
// TODO: sufficient?
|
// TODO: sufficient?
|
||||||
errors := []string{
|
errors := []string{
|
||||||
@@ -199,16 +234,35 @@ func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, loadin
|
|||||||
"net::ERR_ADDRESS_UNREACHABLE",
|
"net::ERR_ADDRESS_UNREACHABLE",
|
||||||
"net::ERR_BLOCKED_BY_CLIENT",
|
"net::ERR_BLOCKED_BY_CLIENT",
|
||||||
"net::ERR_BLOCKED_BY_RESPONSE",
|
"net::ERR_BLOCKED_BY_RESPONSE",
|
||||||
|
"net::ERR_FILE_NOT_FOUND",
|
||||||
}
|
}
|
||||||
if !slices.Contains(errors, ev.ErrorText) {
|
if !slices.Contains(errors, ev.ErrorText) {
|
||||||
logger.Debug(fmt.Sprintf("skip EventLoadingFailed: '%s' is not part of %+v", ev.ErrorText, errors))
|
logger.Debug(fmt.Sprintf("skip EventLoadingFailed: '%s' is not part of %+v", ev.ErrorText, errors))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
loadingFailedMu.Lock()
|
if ev.Type == network.ResourceTypeDocument {
|
||||||
defer loadingFailedMu.Unlock()
|
// Supposition: except iframe, an event loading failed with a
|
||||||
|
// resource type Document is about the main page.
|
||||||
|
logger.Debug("event EventLoadingFailed fired for main page")
|
||||||
|
|
||||||
*loadingFailed = fmt.Errorf("%s", ev.ErrorText)
|
options.loadingFailedMu.Lock()
|
||||||
|
defer options.loadingFailedMu.Unlock()
|
||||||
|
|
||||||
|
*options.loadingFailed = fmt.Errorf("%s", ev.ErrorText)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug("event EventLoadingFailed fired for a resource")
|
||||||
|
|
||||||
|
options.resourceLoadingFailedMu.Lock()
|
||||||
|
defer options.resourceLoadingFailedMu.Unlock()
|
||||||
|
|
||||||
|
*options.resourceLoadingFailed = multierr.Append(
|
||||||
|
*options.resourceLoadingFailed,
|
||||||
|
fmt.Errorf("resource %s: %s", ev.Type, ev.ErrorText),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,17 +29,19 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
|||||||
defaultOptions := DefaultOptions()
|
defaultOptions := DefaultOptions()
|
||||||
|
|
||||||
var (
|
var (
|
||||||
skipNetworkIdleEvent bool
|
skipNetworkIdleEvent bool
|
||||||
failOnHttpStatusCodes []int64
|
failOnHttpStatusCodes []int64
|
||||||
failOnConsoleExceptions bool
|
failOnResourceHttpStatusCodes []int64
|
||||||
waitDelay time.Duration
|
failOnResourceLoadingFailed bool
|
||||||
waitWindowStatus string
|
failOnConsoleExceptions bool
|
||||||
waitForExpression string
|
waitDelay time.Duration
|
||||||
cookies []Cookie
|
waitWindowStatus string
|
||||||
userAgent string
|
waitForExpression string
|
||||||
extraHttpHeaders []ExtraHttpHeader
|
cookies []Cookie
|
||||||
emulatedMediaType string
|
userAgent string
|
||||||
omitBackground bool
|
extraHttpHeaders []ExtraHttpHeader
|
||||||
|
emulatedMediaType string
|
||||||
|
omitBackground bool
|
||||||
)
|
)
|
||||||
|
|
||||||
form := ctx.FormData().
|
form := ctx.FormData().
|
||||||
@@ -57,6 +59,20 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}).
|
}).
|
||||||
|
Custom("failOnResourceHttpStatusCodes", func(value string) error {
|
||||||
|
if value == "" {
|
||||||
|
failOnResourceHttpStatusCodes = defaultOptions.FailOnResourceHttpStatusCodes
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err := json.Unmarshal([]byte(value), &failOnResourceHttpStatusCodes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unmarshal failOnResourceHttpStatusCodes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}).
|
||||||
|
Bool("failOnResourceLoadingFailed", &failOnResourceLoadingFailed, defaultOptions.FailOnResourceLoadingFailed).
|
||||||
Bool("failOnConsoleExceptions", &failOnConsoleExceptions, defaultOptions.FailOnConsoleExceptions).
|
Bool("failOnConsoleExceptions", &failOnConsoleExceptions, defaultOptions.FailOnConsoleExceptions).
|
||||||
Duration("waitDelay", &waitDelay, defaultOptions.WaitDelay).
|
Duration("waitDelay", &waitDelay, defaultOptions.WaitDelay).
|
||||||
String("waitWindowStatus", &waitWindowStatus, defaultOptions.WaitWindowStatus).
|
String("waitWindowStatus", &waitWindowStatus, defaultOptions.WaitWindowStatus).
|
||||||
@@ -158,17 +174,19 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
|||||||
Bool("omitBackground", &omitBackground, defaultOptions.OmitBackground)
|
Bool("omitBackground", &omitBackground, defaultOptions.OmitBackground)
|
||||||
|
|
||||||
options := Options{
|
options := Options{
|
||||||
SkipNetworkIdleEvent: skipNetworkIdleEvent,
|
SkipNetworkIdleEvent: skipNetworkIdleEvent,
|
||||||
FailOnHttpStatusCodes: failOnHttpStatusCodes,
|
FailOnHttpStatusCodes: failOnHttpStatusCodes,
|
||||||
FailOnConsoleExceptions: failOnConsoleExceptions,
|
FailOnResourceHttpStatusCodes: failOnResourceHttpStatusCodes,
|
||||||
WaitDelay: waitDelay,
|
FailOnResourceLoadingFailed: failOnResourceLoadingFailed,
|
||||||
WaitWindowStatus: waitWindowStatus,
|
FailOnConsoleExceptions: failOnConsoleExceptions,
|
||||||
WaitForExpression: waitForExpression,
|
WaitDelay: waitDelay,
|
||||||
Cookies: cookies,
|
WaitWindowStatus: waitWindowStatus,
|
||||||
UserAgent: userAgent,
|
WaitForExpression: waitForExpression,
|
||||||
ExtraHttpHeaders: extraHttpHeaders,
|
Cookies: cookies,
|
||||||
EmulatedMediaType: emulatedMediaType,
|
UserAgent: userAgent,
|
||||||
OmitBackground: omitBackground,
|
ExtraHttpHeaders: extraHttpHeaders,
|
||||||
|
EmulatedMediaType: emulatedMediaType,
|
||||||
|
OmitBackground: omitBackground,
|
||||||
}
|
}
|
||||||
|
|
||||||
return form, options
|
return form, options
|
||||||
@@ -726,12 +744,22 @@ func handleChromiumError(err error, options Options) error {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, ErrInvalidResourceHttpStatusCode) {
|
||||||
|
return api.WrapError(
|
||||||
|
err,
|
||||||
|
api.NewSentinelHttpError(
|
||||||
|
http.StatusConflict,
|
||||||
|
fmt.Sprintf("Invalid HTTP status code from resources:\n%s", strings.ReplaceAll(err.Error(), fmt.Sprintf(": %s", ErrInvalidResourceHttpStatusCode.Error()), "")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if errors.Is(err, ErrConsoleExceptions) {
|
if errors.Is(err, ErrConsoleExceptions) {
|
||||||
return api.WrapError(
|
return api.WrapError(
|
||||||
err,
|
err,
|
||||||
api.NewSentinelHttpError(
|
api.NewSentinelHttpError(
|
||||||
http.StatusConflict,
|
http.StatusConflict,
|
||||||
fmt.Sprintf("Chromium console exceptions:\n %s", strings.ReplaceAll(err.Error(), ErrConsoleExceptions.Error(), "")),
|
fmt.Sprintf("Chromium console exceptions:\n%s", strings.ReplaceAll(err.Error(), ErrConsoleExceptions.Error(), "")),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -746,5 +774,15 @@ func handleChromiumError(err error, options Options) error {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, ErrResourceLoadingFailed) {
|
||||||
|
return api.WrapError(
|
||||||
|
err,
|
||||||
|
api.NewSentinelHttpError(
|
||||||
|
http.StatusConflict,
|
||||||
|
fmt.Sprintf("Chromium failed to load resources: %v", strings.ReplaceAll(err.Error(), fmt.Sprintf(": %s", ErrResourceLoadingFailed.Error()), "")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,44 @@ func TestFormDataChromiumOptions(t *testing.T) {
|
|||||||
compareWithoutDeepEqual: false,
|
compareWithoutDeepEqual: false,
|
||||||
expectValidationError: false,
|
expectValidationError: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
scenario: "invalid failOnResourceHttpStatusCodes form field",
|
||||||
|
ctx: func() *api.ContextMock {
|
||||||
|
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||||
|
ctx.SetValues(map[string][]string{
|
||||||
|
"failOnResourceHttpStatusCodes": {
|
||||||
|
"foo",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return ctx
|
||||||
|
}(),
|
||||||
|
expectedOptions: func() Options {
|
||||||
|
options := DefaultOptions()
|
||||||
|
options.FailOnResourceHttpStatusCodes = nil
|
||||||
|
return options
|
||||||
|
}(),
|
||||||
|
compareWithoutDeepEqual: false,
|
||||||
|
expectValidationError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "valid failOnResourceHttpStatusCodes form field",
|
||||||
|
ctx: func() *api.ContextMock {
|
||||||
|
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||||
|
ctx.SetValues(map[string][]string{
|
||||||
|
"failOnResourceHttpStatusCodes": {
|
||||||
|
`[399,499,599]`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return ctx
|
||||||
|
}(),
|
||||||
|
expectedOptions: func() Options {
|
||||||
|
options := DefaultOptions()
|
||||||
|
options.FailOnResourceHttpStatusCodes = []int64{399, 499, 599}
|
||||||
|
return options
|
||||||
|
}(),
|
||||||
|
compareWithoutDeepEqual: false,
|
||||||
|
expectValidationError: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
scenario: "invalid cookies form field",
|
scenario: "invalid cookies form field",
|
||||||
ctx: func() *api.ContextMock {
|
ctx: func() *api.ContextMock {
|
||||||
@@ -1592,6 +1630,18 @@ func TestConvertUrl(t *testing.T) {
|
|||||||
expectHttpStatus: http.StatusConflict,
|
expectHttpStatus: http.StatusConflict,
|
||||||
expectOutputPathsCount: 0,
|
expectOutputPathsCount: 0,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
scenario: "ErrInvalidResourceHttpStatusCode",
|
||||||
|
ctx: &api.ContextMock{Context: new(api.Context)},
|
||||||
|
api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
|
||||||
|
return ErrInvalidResourceHttpStatusCode
|
||||||
|
}},
|
||||||
|
options: DefaultPdfOptions(),
|
||||||
|
expectError: true,
|
||||||
|
expectHttpError: true,
|
||||||
|
expectHttpStatus: http.StatusConflict,
|
||||||
|
expectOutputPathsCount: 0,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
scenario: "ErrConsoleExceptions",
|
scenario: "ErrConsoleExceptions",
|
||||||
ctx: &api.ContextMock{Context: new(api.Context)},
|
ctx: &api.ContextMock{Context: new(api.Context)},
|
||||||
@@ -1616,6 +1666,18 @@ func TestConvertUrl(t *testing.T) {
|
|||||||
expectHttpStatus: http.StatusBadRequest,
|
expectHttpStatus: http.StatusBadRequest,
|
||||||
expectOutputPathsCount: 0,
|
expectOutputPathsCount: 0,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
scenario: "ErrResourceLoadingFailed",
|
||||||
|
ctx: &api.ContextMock{Context: new(api.Context)},
|
||||||
|
api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
|
||||||
|
return ErrResourceLoadingFailed
|
||||||
|
}},
|
||||||
|
options: DefaultPdfOptions(),
|
||||||
|
expectError: true,
|
||||||
|
expectHttpError: true,
|
||||||
|
expectHttpStatus: http.StatusConflict,
|
||||||
|
expectOutputPathsCount: 0,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
scenario: "error from Chromium",
|
scenario: "error from Chromium",
|
||||||
ctx: &api.ContextMock{Context: new(api.Context)},
|
ctx: &api.ContextMock{Context: new(api.Context)},
|
||||||
@@ -1802,6 +1864,18 @@ func TestScreenshotUrl(t *testing.T) {
|
|||||||
expectHttpStatus: http.StatusConflict,
|
expectHttpStatus: http.StatusConflict,
|
||||||
expectOutputPathsCount: 0,
|
expectOutputPathsCount: 0,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
scenario: "ErrInvalidResourceHttpStatusCode",
|
||||||
|
ctx: &api.ContextMock{Context: new(api.Context)},
|
||||||
|
api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
|
||||||
|
return ErrInvalidResourceHttpStatusCode
|
||||||
|
}},
|
||||||
|
options: DefaultScreenshotOptions(),
|
||||||
|
expectError: true,
|
||||||
|
expectHttpError: true,
|
||||||
|
expectHttpStatus: http.StatusConflict,
|
||||||
|
expectOutputPathsCount: 0,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
scenario: "ErrConsoleExceptions",
|
scenario: "ErrConsoleExceptions",
|
||||||
ctx: &api.ContextMock{Context: new(api.Context)},
|
ctx: &api.ContextMock{Context: new(api.Context)},
|
||||||
@@ -1826,6 +1900,18 @@ func TestScreenshotUrl(t *testing.T) {
|
|||||||
expectHttpStatus: http.StatusBadRequest,
|
expectHttpStatus: http.StatusBadRequest,
|
||||||
expectOutputPathsCount: 0,
|
expectOutputPathsCount: 0,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
scenario: "ErrResourceLoadingFailed",
|
||||||
|
ctx: &api.ContextMock{Context: new(api.Context)},
|
||||||
|
api: &ApiMock{ScreenshotMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
|
||||||
|
return ErrResourceLoadingFailed
|
||||||
|
}},
|
||||||
|
options: DefaultScreenshotOptions(),
|
||||||
|
expectError: true,
|
||||||
|
expectHttpError: true,
|
||||||
|
expectHttpStatus: http.StatusConflict,
|
||||||
|
expectOutputPathsCount: 0,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
scenario: "error from Chromium",
|
scenario: "error from Chromium",
|
||||||
ctx: &api.ContextMock{Context: new(api.Context)},
|
ctx: &api.ContextMock{Context: new(api.Context)},
|
||||||
|
|||||||
8
test/testdata/chromium/html/index.html
vendored
8
test/testdata/chromium/html/index.html
vendored
@@ -2,6 +2,12 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
|
<!-- net::ERR_FILE_NOT_FOUND -->
|
||||||
|
<link rel="stylesheet" type="text/css" href="doesnotexist.css">
|
||||||
|
<!-- net::ERR_CONNECTION_REFUSED -->
|
||||||
|
<link rel="stylesheet" type="text/css" href="http://localhost:100/style.css">
|
||||||
|
<!-- 400 Bad Request -->
|
||||||
|
<link rel="stylesheet" type="text/css" href="https://httpstat.us/400">
|
||||||
<link rel="stylesheet" type="text/css" href="style.css">
|
<link rel="stylesheet" type="text/css" href="style.css">
|
||||||
<link href="https://fonts.googleapis.com/css?family=Montserrat" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css?family=Montserrat" rel="stylesheet">
|
||||||
<title>Gutenberg</title>
|
<title>Gutenberg</title>
|
||||||
@@ -23,7 +29,7 @@
|
|||||||
|
|
||||||
<blockquote cite="https://sites.google.com/site/johanngutenbergper5/q">
|
<blockquote cite="https://sites.google.com/site/johanngutenbergper5/q">
|
||||||
<p>It is a press, certainly, but a press from which shall flow in inexhaustible streams...Through it, God will spread His Word. A spring of truth shall flow from it: like a new star it shall scatter the darkness of ignorance, and cause a light heretofore unknown to shine amongst men.</p>
|
<p>It is a press, certainly, but a press from which shall flow in inexhaustible streams...Through it, God will spread His Word. A spring of truth shall flow from it: like a new star it shall scatter the darkness of ignorance, and cause a light heretofore unknown to shine amongst men.</p>
|
||||||
<footer>— <a href="https://sites.google.com/site/johanngutenbergper5/q">Johannes Gutenberg</a></cite></footer>
|
<footer>— <a href="https://sites.google.com/site/johanngutenbergper5/q">Johannes Gutenberg</a></footer>
|
||||||
</blockquote>
|
</blockquote>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
8
test/testdata/chromium/markdown/index.html
vendored
8
test/testdata/chromium/markdown/index.html
vendored
@@ -2,6 +2,12 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
|
<!-- net::ERR_FILE_NOT_FOUND -->
|
||||||
|
<link rel="stylesheet" type="text/css" href="doesnotexist.css">
|
||||||
|
<!-- net::ERR_CONNECTION_REFUSED -->
|
||||||
|
<link rel="stylesheet" type="text/css" href="http://localhost:100/style.css">
|
||||||
|
<!-- 400 Bad Request -->
|
||||||
|
<link rel="stylesheet" type="text/css" href="https://httpstat.us/400">
|
||||||
<link rel="stylesheet" type="text/css" href="style.css">
|
<link rel="stylesheet" type="text/css" href="style.css">
|
||||||
<link href="https://fonts.googleapis.com/css?family=Montserrat" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css?family=Montserrat" rel="stylesheet">
|
||||||
<title>Gutenberg</title>
|
<title>Gutenberg</title>
|
||||||
@@ -15,7 +21,7 @@
|
|||||||
|
|
||||||
<blockquote cite="https://sites.google.com/site/johanngutenbergper5/q">
|
<blockquote cite="https://sites.google.com/site/johanngutenbergper5/q">
|
||||||
<p>It is a press, certainly, but a press from which shall flow in inexhaustible streams...Through it, God will spread His Word. A spring of truth shall flow from it: like a new star it shall scatter the darkness of ignorance, and cause a light heretofore unknown to shine amongst men.</p>
|
<p>It is a press, certainly, but a press from which shall flow in inexhaustible streams...Through it, God will spread His Word. A spring of truth shall flow from it: like a new star it shall scatter the darkness of ignorance, and cause a light heretofore unknown to shine amongst men.</p>
|
||||||
<footer>— <a href="https://sites.google.com/site/johanngutenbergper5/q">Johannes Gutenberg</a></cite></footer>
|
<footer>— <a href="https://sites.google.com/site/johanngutenbergper5/q">Johannes Gutenberg</a></footer>
|
||||||
</blockquote>
|
</blockquote>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user