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
210 changes: 140 additions & 70 deletions apps/docs/__tests__/Search_.test.res

Large diffs are not rendered by default.

6 changes: 2 additions & 4 deletions apps/docs/app/DocsRoot.res
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,6 @@ external mainCss: string = "default"
@module("../styles/_hljs.css?url")
external hljsCss: string = "default"

@module("../styles/utils.css?url")
external utilsCss: string = "default"

%%raw(`
import hljs from 'highlight.js/lib/core';
import bash from 'highlight.js/lib/languages/bash';
Expand All @@ -17,6 +14,7 @@ external utilsCss: string = "default"
import json from 'highlight.js/lib/languages/json';
import text from 'highlight.js/lib/languages/plaintext';
import html from 'highlight.js/lib/languages/xml';
import yaml from 'highlight.js/lib/languages/yaml';
import toml from 'highlight.js/lib/languages/ini';
import rescript from 'highlightjs-rescript';

Expand All @@ -32,6 +30,7 @@ external utilsCss: string = "default"
hljs.registerLanguage('html', html)
hljs.registerLanguage('diff', diff)
hljs.registerLanguage('typescript', typescript)
hljs.registerLanguage('yaml', yaml)
`)

open ReactRouter
Expand All @@ -45,7 +44,6 @@ let default = () => {
<link rel="preload" href={mainCss} as_="style" />
<link rel="stylesheet" href={mainCss} />
<link rel="stylesheet" href={hljsCss} />
<link rel="stylesheet" href={utilsCss} />
<link rel="icon" href="/favicon.ico" />
<Links />
<ReactRouter.Meta />
Expand Down
3 changes: 2 additions & 1 deletion apps/docs/app/DocsRoot.resi
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/** Includes the stable Cypress bootstrap slot for document hydration tests. */
/** Shared shell styles and the stable Cypress bootstrap slot.
Feature-specific styles load with their components. */
@react.component
let default: unit => Jsx.element
24 changes: 24 additions & 0 deletions apps/docs/e2e/bindings/Cypress.res
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,20 @@ type chain<'a>
type elements
type assertion
type spy
type consoleArgument
type consoleCall = {args: array<consoleArgument>}
@send external getCalls: spy => array<consoleCall> = "getCalls"
@val external consoleArgumentString: option<consoleArgument> => string = "String"
type console
type rec window = {document: Dom.document, console: console, navigator: {clipboard: clipboard}}
and clipboard
type response = {status: int, body: string}
type automation = {command: string, params?: {permissions: array<string>, origin: string}}
type request = {url: string, resourceType: string}
type routeMatcher = {resourceType?: string, pathname?: string}
type searchRouteMatcher = {hostname: RegExp.t, pathname: RegExp.t}
type staticResponse = {statusCode: int, body: JSON.t}
type wrapOptions = {log: bool}
type url
type elementList
type fontFaceSet
Expand Down Expand Up @@ -36,9 +43,20 @@ type cssStyle
@val @scope("cy") external intercept: (routeMatcher, request => unit) => chain<unit> = "intercept"
@val @scope("cy") external interceptAll: (string, request => unit) => chain<unit> = "intercept"
@val @scope("cy") external interceptRoute: routeMatcher => chain<unit> = "intercept"
@val @scope("cy")
external interceptStatic: (searchRouteMatcher, staticResponse) => chain<unit> = "intercept"
@val @scope("cy")
external interceptPattern: (RegExp.t, request => unit) => chain<unit> = "intercept"
@val @scope("cy")
external interceptDeferred: (RegExp.t, unit => promise<unit>) => chain<unit> = "intercept"
@val @scope("cy") external wait: string => chain<response> = "wait"
@val @scope("cy") external wrap: 'a => chain<'a> = "wrap"
@val @scope("cy") external wrapWithOptions: ('a, wrapOptions) => chain<'a> = "wrap"
@val @scope("cy") external run: (unit => promise<unit>) => chain<unit> = "then"
@val @scope("cy") external do_: (unit => unit) => chain<unit> = "then"
@val @scope("cy") external realPressKey: string => chain<unit> = "realPress"
@val @scope("cy") external realPressKeys: array<string> => chain<unit> = "realPress"
@val @scope("cy") external reload: unit => unit = "reload"
@val @scope("cy")
external onBeforeLoad: (@as("window:before:load") _, window => unit) => unit = "on"
@val @scope("cy") external spy: (console, @as("error") _) => spy = "spy"
Expand All @@ -60,6 +78,8 @@ external shouldCss: (chain<elements>, @as("have.css") _, string, string) => chai
@send
external shouldCssProperty: (chain<elements>, @as("have.css") _, string) => chain<string> = "should"
@send external shouldInt: (chain<'a>, string, int) => chain<'a> = "should"
@send
external shouldValue: (chain<elements>, @as("have.value") _, string) => chain<elements> = "should"
@send external shouldEqual: (chain<'a>, @as("equal") _, 'a) => chain<'a> = "should"
@send external shouldDeepEqual: (chain<'a>, @as("deep.equal") _, 'a) => chain<'a> = "should"
@send external shouldMatch: (chain<'a>, @as("match") _, RegExp.t) => chain<'a> = "should"
Expand All @@ -72,6 +92,9 @@ external shouldAttribute: (chain<elements>, @as("have.attr") _, string, string)
@send external propertyInt: (chain<'a>, string) => chain<int> = "its"
@send external shouldSatisfy: (chain<'a>, 'a => unit) => chain<'a> = "should"
@send external click: chain<elements> => chain<elements> = "click"
@send external typeText: (chain<elements>, string) => chain<elements> = "type"
@send
external containsChildRegex: (chain<elements>, string, RegExp.t) => chain<elements> = "contains"
@send external focusElement: chain<elements> => chain<elements> = "focus"
@send external realClick: chain<elements> => chain<elements> = "realClick"
@send external realPress: (chain<elements>, string) => chain<elements> = "realPress"
Expand All @@ -82,6 +105,7 @@ external shouldAttribute: (chain<elements>, @as("have.attr") _, string, string)
@get external complete: Dom.element => bool = "complete"
@get external naturalWidth: Dom.element => int = "naturalWidth"
@send external readText: clipboard => promise<string> = "readText"
@send external destroy: request => unit = "destroy"

@val external expect: ('a, ~message: string=?) => assertion = "expect"
@send @scope("to") external equal: (assertion, 'a) => unit = "equal"
Expand Down
11 changes: 11 additions & 0 deletions apps/docs/e2e/homepage/HomepageHelpers.res
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@ let homepageDocument = callback => {
->ignore
}

let initialScriptUrls = document =>
document
->querySelectorAll(`link[rel="modulepreload"][href], script[src]`)
->elementsFrom
->Array.filterMap(element =>
element
->getAttribute("href")
->Null.toOption
->Option.orElse(element->getAttribute("src")->Null.toOption)
)

let expectElement = (document, selector) => {
let element = document->querySelector(selector)->Null.toOption
expect(element->Option.isSome, ~message=selector)->equal(true)
Expand Down
179 changes: 179 additions & 0 deletions apps/docs/e2e/homepage/HomepageSearch.cy.res
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
open Cypress
open HomepageHelpers

let searchChunk = /\/assets\/SearchModal-[^/]+\.js$/
let search = `button[aria-label="Search"]`
let input = `input[placeholder="Search docs"]`
let close = `button[aria-label="Close search"]`
let searchRoute = {hostname: /\.(algolia\.net|algolianet\.com)$/, pathname: /\/queries$/}

let emptyResults = JSON.parseOrThrow(`{
"results": [{
"hits": [],
"nbHits": 0,
"page": 0,
"nbPages": 0,
"hitsPerPage": 20,
"processingTimeMS": 1,
"query": "/",
"index": "test-index"
}]
}`)

let installationResults = JSON.parseOrThrow(`{
"results": [{
"hits": [{
"objectID": "installation",
"url": "https://rescript-lang.org/docs/manual/installation",
"url_without_anchor": "https://rescript-lang.org/docs/manual/installation",
"type": "lvl1",
"anchor": null,
"content": null,
"hierarchy": {
"lvl0": "ReScript",
"lvl1": "Installation",
"lvl2": null,
"lvl3": null,
"lvl4": null,
"lvl5": null,
"lvl6": null
}
}],
"nbHits": 1,
"page": 0,
"nbPages": 1,
"hitsPerPage": 20,
"processingTimeMS": 1,
"query": "installation",
"index": "test-index",
"queryID": "homepage-search-test"
}]
}`)

it("initial homepage assets exclude the search implementation and styles", () => {
homepageDocument(document => {
let scripts = document->initialScriptUrls
let styles =
document
->querySelectorAll(`link[rel="stylesheet"][href]`)
->elementsFrom
->Array.filterMap(element => element->getAttribute("href")->Null.toOption)
expect(scripts->Array.length)->greaterThan(0)
expect(styles->Array.length)->greaterThan(0)
scripts->Array.forEach(
asset =>
request(asset)
->then(
response => {
expect(response.status, ~message=asset)->equal(200)
expect(response.body, ~message=asset)->notInclude("search-insights")
expect(response.body, ~message=asset)->notInclude("DocSearch-Modal")
},
)
->ignore,
)
styles->Array.forEach(
asset =>
request(asset)
->then(
response => {
expect(response.status, ~message=asset)->equal(200)
expect(response.body, ~message=asset)->notInclude(".DocSearch-Modal")
},
)
->ignore,
)
})
})

it("search loads on activation, stays styled, and supports keyboard reopening", () => {
interceptStatic(searchRoute, {statusCode: 200, body: emptyResults})
->as_("keyboardSearch")
->ignore
let requests = ref([])
interceptAll("**", request => {
if request.url->String.includes("search-insights") || searchChunk->RegExp.test(request.url) {
requests := requests.contents->Array.concat([request.url])
}
})->ignore
visit("/")
get(search)->should("be.visible")->ignore
get(input)->should("not.exist")->ignore
do_(() => expect(requests.contents)->deepEqual([]))->ignore
get(search)->click->ignore
get(input)->should("be.focused")->ignore
do_(() =>
expect(requests.contents->Array.some(url => searchChunk->RegExp.test(url)))->equal(true)
)->ignore
get(".DocSearch-Container")->shouldCss("position", "fixed")->ignore
get(".DocSearch-Modal")->shouldCss("opacity", "1")->shouldCss("max-width", "768px")->ignore
realPressKey("Escape")->ignore
get(input)->should("not.exist")->ignore
realPressKey("/")->ignore
get(input)->should("be.focused")->ignore
realPressKey("/")->ignore
get(input)->shouldValue("/")->ignore
wait("@keyboardSearch")->propertyInt("response.statusCode")->shouldEqual(200)->ignore
realPressKey("Escape")->ignore
get(input)->shouldValue("")->should("be.focused")->ignore
realPressKey("Escape")->ignore
get(input)->should("not.exist")->ignore
realPressKeys(["Control", "k"])->ignore
get(input)->should("be.focused")->ignore
realPressKey("Escape")->ignore
get(input)->should("not.exist")->ignore
})

it("lazy search results navigate into documentation", () => {
interceptStatic(searchRoute, {statusCode: 200, body: installationResults})->ignore
visit("/")
get(search)->click->ignore
get(input)->typeText("installation")->ignore
get(".DocSearch-Modal")->containsChildRegex("a", /^Installation$/)->click->ignore
cyLocation("pathname")->shouldEqual("/docs/manual/installation")->ignore
containsInRegex("h1", /^Installation$/)->should("be.visible")->ignore
get(input)->should("not.exist")->ignore
})

it("a pending search load can be closed without opening the modal afterward", () => {
let download = Promise.withResolvers()
interceptDeferred(searchChunk, () => download.promise)->as_("searchChunk")->ignore
visit("/")
get(search)->click->ignore
get(close)->should("be.visible")->ignore
realPressKey("Escape")->ignore
get(close)->should("not.exist")->ignore
get(search)->should("be.focused")->ignore
do_(() => download.resolve())->ignore
wait("@searchChunk")->ignore
get(search)->should("be.focused")->ignore
get(input)->should("not.exist")->ignore
get(search)->click->ignore
get(input)->should("be.focused")->ignore
})

it("a failed search chunk leaves the page usable and recovers after a reload", () => {
let blockChunk = ref(true)
interceptPattern(searchChunk, request => {
if blockChunk.contents {
request->destroy
}
})->ignore
wrapWithOptions(
[/Failed to fetch dynamically imported module: .*\/assets\/SearchModal-[^/]+\.js/],
{log: false},
)
->as_("expectedConsoleErrors")
->ignore
visit("/")
get(search)->click->ignore
containsIn(`[role="alert"]`, "Search unavailable")->should("be.visible")->ignore
get(close)->click->ignore
get(`[role="alert"]`)->should("not.exist")->ignore
get(search)->should("be.focused")->ignore
containsIn("h1", headline)->should("be.visible")->ignore
do_(() => blockChunk := false)->ignore
reload()
get(search)->click->ignore
get(input)->should("be.focused")->ignore
})
29 changes: 28 additions & 1 deletion apps/docs/e2e/homepage/HomepageSupport.res
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ beforeEach(() => {
run(() => automate({command: "Network.clearBrowserCache"}))->ignore
let spies = ref([])
wrap(spies)->as_("consoleSpies")->ignore
wrap([])->as_("expectedConsoleErrors")->ignore
onBeforeLoad(window => {
let script = window.document->currentScript->Null.toOption
let marker =
Expand All @@ -18,6 +19,32 @@ beforeEach(() => {

afterEach(() => {
alias("@consoleSpies")
->then(spies => spies.contents->Array.forEach(spy => expect(spy->callCount)->equal(0)))
->then(spies => {
let errors =
spies.contents
->Array.flatMap(getCalls)
->Array.map(call => call.args->Array.get(0)->consoleArgumentString)
alias("@expectedConsoleErrors")
->then(
expected => {
let unexpected =
errors->Array.filter(
error => !(expected->Array.some(pattern => pattern->RegExp.test(error))),
)
expect(
unexpected->Array.length,
~message=`unexpected console errors: ${unexpected->Array.join("; ")}`,
)->equal(0)
expected->Array.forEach(
pattern =>
expect(
errors->Array.some(error => pattern->RegExp.test(error)),
~message="expected console error occurred",
)->equal(true),
)
},
)
->ignore
})
->ignore
})
Loading
Loading