Remove domain edit feature code (#55991)
This commit is contained in:
1
.github/workflows/test.yml
vendored
1
.github/workflows/test.yml
vendored
@@ -75,7 +75,6 @@ jobs:
|
||||
- shielding
|
||||
# - tests
|
||||
# - tools
|
||||
- tracking
|
||||
- versions
|
||||
- webhooks
|
||||
- workflows
|
||||
|
||||
12
data/ui.yml
12
data/ui.yml
@@ -320,17 +320,7 @@ alerts:
|
||||
WARNING: Warning
|
||||
TIP: Tip
|
||||
CAUTION: Caution
|
||||
domain_edit:
|
||||
name: Domain name
|
||||
edit: Edit
|
||||
edit_your: Edit your domain name
|
||||
experimental: Experimental
|
||||
your_name: Your domain name
|
||||
cancel: Cancel
|
||||
save: Save
|
||||
snippet_about: Updating will include the new domain name in all code snippets across GitHub Docs.
|
||||
learn_more: Learn more
|
||||
submission_failed: Submission failed. Please try again in a minute.
|
||||
|
||||
cookbook_landing:
|
||||
spotlight: Spotlight
|
||||
explore_articles: Explore {{ number }} prompt articles
|
||||
|
||||
@@ -27,7 +27,6 @@ import wrapProceduralImages from './wrap-procedural-images.js'
|
||||
import parseInfoString from './parse-info-string.js'
|
||||
import annotate from './annotate.js'
|
||||
import alerts from './alerts.js'
|
||||
import replaceDomain from './replace-domain.js'
|
||||
import removeHtmlComments from 'remark-remove-comments'
|
||||
import remarkStringify from 'remark-stringify'
|
||||
|
||||
@@ -49,7 +48,6 @@ export function createProcessor(context) {
|
||||
.use(headingLinks)
|
||||
.use(codeHeader)
|
||||
.use(annotate)
|
||||
.use(replaceDomain)
|
||||
.use(highlight, {
|
||||
languages: { ...common, graphql, dockerfile, http, groovy, erb, powershell },
|
||||
subset: false,
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* This makes it so that the `github.com` or `HOSTNAME` in a code snippet
|
||||
* becomes replacable.
|
||||
*/
|
||||
|
||||
import { visit } from 'unist-util-visit'
|
||||
|
||||
// Don't use `g` on these regexes
|
||||
const VALID_REPLACEMENTS = [[/\bHOSTNAME\b/, 'HOSTNAME']]
|
||||
|
||||
const CODE_FENCE_KEYWORD = 'replacedomain'
|
||||
|
||||
const matcher = (node) => {
|
||||
return (
|
||||
node.type === 'element' &&
|
||||
node.tagName === 'pre' &&
|
||||
node.children[0]?.data?.meta[CODE_FENCE_KEYWORD]
|
||||
)
|
||||
}
|
||||
|
||||
export default function alerts() {
|
||||
return (tree) => {
|
||||
visit(tree, matcher, (node) => {
|
||||
const code = node.children[0].children[0].value
|
||||
for (const [regex, replacement] of VALID_REPLACEMENTS) {
|
||||
if (regex.test(code)) {
|
||||
const codeTag = node.children[0]
|
||||
const replacements = codeTag.properties['data-replacedomain'] || []
|
||||
if (!replacements.includes(replacement)) {
|
||||
replacements.push(replacement)
|
||||
codeTag.properties['data-replacedomain'] = replacements
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,5 @@ children:
|
||||
- /permissions
|
||||
- /code-annotations
|
||||
- /alerts
|
||||
- /replace-domain
|
||||
- /html-comments
|
||||
---
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
---
|
||||
title: Replace domain
|
||||
intro: This demonstrates code snippets that have host names that can be replaced.
|
||||
versions:
|
||||
fpt: '*'
|
||||
ghes: '*'
|
||||
ghec: '*'
|
||||
type: how_to
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
If you have an article with code snippets that have the `replacedomain`
|
||||
annotation on its code fence, that means the page *might* take the current
|
||||
user's cookie (indicating their personal hostname) and replace that within
|
||||
the code snippet.
|
||||
|
||||
## Shell code snippet (on)
|
||||
|
||||
```sh replacedomain
|
||||
curl https://HOSTNAME/api/v1
|
||||
```
|
||||
|
||||
## Shell code snippet (off)
|
||||
|
||||
```sh
|
||||
curl https://HOSTNAME/api/v2
|
||||
```
|
||||
|
||||
## JavaScript code snippet (on)
|
||||
|
||||
```js replacedomain
|
||||
await fetch("https://HOSTNAME/api/v1")
|
||||
```
|
||||
|
||||
## JavaScript code snippet (off)
|
||||
|
||||
```js
|
||||
await fetch("https://HOSTNAME/api/v2")
|
||||
```
|
||||
|
||||
## Not always there
|
||||
|
||||
In this next code snippet, the `HOSTNAME` only appears if the current
|
||||
version is `ghes`. That should be fine.
|
||||
|
||||
```text replacedomain copy
|
||||
ssh handle@{% ifversion ghes %}HOSTNAME{% else %}github.com{% endif %}
|
||||
```
|
||||
@@ -320,17 +320,7 @@ alerts:
|
||||
WARNING: Warning
|
||||
TIP: Tip
|
||||
CAUTION: Caution
|
||||
domain_edit:
|
||||
name: Domain name
|
||||
edit: Edit
|
||||
edit_your: Edit your domain name
|
||||
experimental: Experimental
|
||||
your_name: Your domain name
|
||||
cancel: Cancel
|
||||
save: Save
|
||||
snippet_about: Updating will include the new domain name in all code snippets across GitHub Docs.
|
||||
learn_more: Learn more
|
||||
submission_failed: Submission failed. Please try again in a minute.
|
||||
|
||||
cookbook_landing:
|
||||
spotlight: Spotlight
|
||||
explore_articles: Explore {{ number }} prompt articles
|
||||
|
||||
@@ -844,35 +844,3 @@ test.describe('translations', () => {
|
||||
await expect(page).toHaveURL('/ja/get-started/start-your-journey/hello-world')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('view pages with custom domain cookie', () => {
|
||||
test('view article page', async ({ page }) => {
|
||||
await page.goto(
|
||||
'/enterprise-server@latest/get-started/markdown/replace-domain?ghdomain=example.ghe.com',
|
||||
)
|
||||
|
||||
const content = page.locator('pre')
|
||||
await expect(content.nth(0)).toHaveText(/curl https:\/\/example.ghe.com\/api\/v1/)
|
||||
await expect(content.nth(1)).toHaveText(/curl https:\/\/HOSTNAME\/api\/v2/)
|
||||
await expect(content.nth(2)).toHaveText('await fetch("https://example.ghe.com/api/v1")')
|
||||
await expect(content.nth(3)).toHaveText('await fetch("https://HOSTNAME/api/v2")')
|
||||
|
||||
// Now switch to enterprise-cloud, where replacedomain should not be used
|
||||
await page.getByLabel('Select GitHub product version').click()
|
||||
await page.getByLabel('Enterprise Cloud', { exact: true }).click()
|
||||
|
||||
await expect(content.nth(0)).toHaveText(/curl https:\/\/HOSTNAME\/api\/v1/)
|
||||
await expect(content.nth(1)).toHaveText(/curl https:\/\/HOSTNAME\/api\/v2/)
|
||||
await expect(content.nth(2)).toHaveText('await fetch("https://HOSTNAME/api/v1")')
|
||||
await expect(content.nth(3)).toHaveText('await fetch("https://HOSTNAME/api/v2")')
|
||||
|
||||
// Again switch back to enterprise server again
|
||||
await page.getByLabel('Select GitHub product version').click()
|
||||
await page.getByLabel('Enterprise Server 3.').first().click()
|
||||
|
||||
await expect(content.nth(0)).toHaveText(/curl https:\/\/example.ghe.com\/api\/v1/)
|
||||
await expect(content.nth(1)).toHaveText(/curl https:\/\/HOSTNAME\/api\/v2/)
|
||||
await expect(content.nth(2)).toHaveText('await fetch("https://example.ghe.com/api/v1")')
|
||||
await expect(content.nth(3)).toHaveText('await fetch("https://HOSTNAME/api/v2")')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,7 +13,6 @@ import { useTranslation } from '@/languages/components/useTranslation'
|
||||
import { Breadcrumbs } from '@/frame/components/page-header/Breadcrumbs'
|
||||
import { useLanguages } from '@/languages/components/LanguagesContext'
|
||||
import { ClientSideLanguageRedirect } from './ClientSideLanguageRedirect'
|
||||
import { DomainNameEditProvider } from '@/links/components/useEditableDomainContext'
|
||||
import { SearchOverlayContextProvider } from '@/search/components/context/SearchOverlayContext'
|
||||
|
||||
const MINIMAL_RENDER = Boolean(JSON.parse(process.env.MINIMAL_RENDER || 'false'))
|
||||
@@ -76,7 +75,6 @@ export const DefaultLayout = (props: Props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<DomainNameEditProvider>
|
||||
<SearchOverlayContextProvider>
|
||||
<Head>
|
||||
{error === '404' ? (
|
||||
@@ -169,6 +167,5 @@ export const DefaultLayout = (props: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
</SearchOverlayContextProvider>
|
||||
</DomainNameEditProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import { Breadcrumbs } from '@/frame/components/page-header/Breadcrumbs'
|
||||
import { Link } from '@/frame/components/Link'
|
||||
import { useTranslation } from '@/languages/components/useTranslation'
|
||||
import { LinkPreviewPopover } from '@/links/components/LinkPreviewPopover'
|
||||
import { ReplaceDomain } from '@/links/components/replace-domain'
|
||||
|
||||
const ClientSideRefresh = dynamic(() => import('@/frame/components/ClientSideRefresh'), {
|
||||
ssr: false,
|
||||
@@ -104,7 +103,6 @@ export const ArticlePage = () => {
|
||||
<LinkPreviewPopover />
|
||||
{isDev && <ClientSideRefresh />}
|
||||
{router.pathname.includes('/rest/') && <RestRedirect />}
|
||||
<ReplaceDomain />
|
||||
{currentLayout === 'inline' ? (
|
||||
<>
|
||||
<ArticleInlineLayout
|
||||
|
||||
@@ -147,7 +147,6 @@ const DEFAULT_UI_NAMESPACES = [
|
||||
'contribution_cta',
|
||||
'support',
|
||||
'rest',
|
||||
'domain_edit',
|
||||
'cookbook_landing',
|
||||
]
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Suspense } from 'react'
|
||||
import cx from 'classnames'
|
||||
import { KebabHorizontalIcon, LinkExternalIcon } from '@primer/octicons-react'
|
||||
import { IconButton, ActionMenu, ActionList } from '@primer/react'
|
||||
|
||||
import { LanguagePicker } from '@/languages/components/LanguagePicker'
|
||||
import { useTranslation } from '@/languages/components/useTranslation'
|
||||
import DomainNameEdit from '@/links/components/DomainNameEdit'
|
||||
import { VersionPicker } from '@/versions/components/VersionPicker'
|
||||
import { DEFAULT_VERSION, useVersion } from '@/versions/components/useVersion'
|
||||
import { useHasAccount } from '../hooks/useHasAccount'
|
||||
@@ -26,8 +24,6 @@ export function HeaderSearchAndWidgets({ width, isSearchOpen, SearchButton }: Pr
|
||||
hasAccount === false && // don't show if `null`
|
||||
(currentVersion === DEFAULT_VERSION || currentVersion === 'enterprise-cloud@latest')
|
||||
|
||||
const showDomainNameEdit = currentVersion.startsWith('enterprise-server@')
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cx('d-flex flex-items-center', isSearchOpen && 'd-none')}>
|
||||
@@ -105,14 +101,6 @@ export function HeaderSearchAndWidgets({ width, isSearchOpen, SearchButton }: Pr
|
||||
<>
|
||||
<VersionPicker xs={true} />
|
||||
<ActionList.Divider />
|
||||
{showDomainNameEdit && (
|
||||
<>
|
||||
<Suspense>
|
||||
<DomainNameEdit xs={true} />
|
||||
</Suspense>
|
||||
<ActionList.Divider />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{signupCTAVisible && (
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Suspense } from 'react'
|
||||
import cx from 'classnames'
|
||||
import { SearchIcon, XIcon, KebabHorizontalIcon, LinkExternalIcon } from '@primer/octicons-react'
|
||||
import { IconButton, ActionMenu, ActionList } from '@primer/react'
|
||||
|
||||
import { LanguagePicker } from '@/languages/components/LanguagePicker'
|
||||
import { useTranslation } from '@/languages/components/useTranslation'
|
||||
import DomainNameEdit from '@/links/components/DomainNameEdit'
|
||||
import { OldSearchInput } from '@/search/components/input/OldSearchInput'
|
||||
import { VersionPicker } from '@/versions/components/VersionPicker'
|
||||
import { DEFAULT_VERSION, useVersion } from '@/versions/components/useVersion'
|
||||
@@ -29,8 +27,6 @@ export function OldHeaderSearchAndWidgets({ isSearchOpen, setIsSearchOpen, width
|
||||
hasAccount === false && // don't show if `null`
|
||||
(currentVersion === DEFAULT_VERSION || currentVersion === 'enterprise-cloud@latest')
|
||||
|
||||
const showDomainNameEdit = currentVersion.startsWith('enterprise-server@')
|
||||
|
||||
return (
|
||||
<div className={cx('d-flex flex-items-center', isSearchOpen && styles.widgetsContainer)}>
|
||||
{/* <!-- GitHub.com homepage and 404 page has a stylized search; Enterprise homepages do not --> */}
|
||||
@@ -151,14 +147,6 @@ export function OldHeaderSearchAndWidgets({ isSearchOpen, setIsSearchOpen, width
|
||||
<>
|
||||
<VersionPicker xs={true} />
|
||||
<ActionList.Divider />
|
||||
{showDomainNameEdit && (
|
||||
<>
|
||||
<Suspense>
|
||||
<DomainNameEdit xs={true} />
|
||||
</Suspense>
|
||||
<ActionList.Divider />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{signupCTAVisible && (
|
||||
|
||||
@@ -62,7 +62,6 @@ import mockVaPortal from './mock-va-portal'
|
||||
import dynamicAssets from '@/assets/middleware/dynamic-assets'
|
||||
import generalSearchMiddleware from '@/search/middleware/general-search-middleware'
|
||||
import shielding from '@/shielding/middleware'
|
||||
import tracking from '@/tracking/middleware'
|
||||
import { MAX_REQUEST_TIMEOUT } from '@/frame/lib/constants.js'
|
||||
|
||||
const { NODE_ENV } = process.env
|
||||
@@ -200,7 +199,6 @@ export default function (app: Express) {
|
||||
}
|
||||
|
||||
// ** Possible early exits after cookies **
|
||||
app.use(tracking)
|
||||
|
||||
// *** Headers ***
|
||||
app.set('etag', false) // We will manage our own ETags if desired
|
||||
|
||||
@@ -14,4 +14,3 @@
|
||||
|
||||
@import "src/content-render/stylesheets/index.scss";
|
||||
@import "src/links/stylesheets/hover-card.scss";
|
||||
@import "src/links/stylesheets/domain-edit.scss";
|
||||
|
||||
@@ -1,305 +0,0 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { BeakerIcon } from '@primer/octicons-react'
|
||||
|
||||
import { useTranslation } from '@/languages/components/useTranslation'
|
||||
import { Box, Flash, FormControl, Spinner, TextInput } from '@primer/react'
|
||||
import { Dialog } from '@primer/react/experimental'
|
||||
import { useEditableDomainName } from './useEditableDomainContext'
|
||||
import { sendEvent } from '@/events/components/events'
|
||||
import { EventType } from '@/events/types'
|
||||
|
||||
type Props = {
|
||||
xs?: boolean
|
||||
}
|
||||
|
||||
const EXPERIMENT_NAME = 'domain_edit'
|
||||
|
||||
const QUERY_STRING_KEY = 'ghdomain' // Must match the middleware
|
||||
|
||||
const TRANSLATION_NAMESPACE = 'domain_edit'
|
||||
|
||||
export default function DomainNameEdit({ xs }: Props) {
|
||||
const { t } = useTranslation(TRANSLATION_NAMESPACE)
|
||||
|
||||
const { asPath } = useRouter()
|
||||
|
||||
const { domainName, setDomainName } = useEditableDomainName()
|
||||
const [localName, setLocalName] = useState('')
|
||||
useEffect(() => {
|
||||
setLocalName(domainName)
|
||||
}, [domainName])
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
useEffect(() => {
|
||||
function handler(event: MouseEvent) {
|
||||
if (event.target) {
|
||||
const target = event.target as HTMLElement | SVGSVGElement
|
||||
if (
|
||||
(target.tagName === 'BUTTON' && target.classList.contains('replacedomain-edit')) ||
|
||||
(target.tagName === 'SPAN' && target.classList.contains('replacedomain-text')) ||
|
||||
(target.tagName === 'svg' &&
|
||||
target.parentElement &&
|
||||
target.parentElement.classList.contains('replacedomain-edit')) ||
|
||||
(target.tagName === 'path' &&
|
||||
target.parentElement &&
|
||||
target.parentElement.parentElement &&
|
||||
target.parentElement.parentElement.classList.contains('replacedomain-edit'))
|
||||
) {
|
||||
setOpen(true)
|
||||
|
||||
sendEvent({
|
||||
type: EventType.experiment,
|
||||
experiment_name: EXPERIMENT_NAME,
|
||||
experiment_variation: 'opened',
|
||||
experiment_success: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
const main = document.querySelector<HTMLDivElement>('#main-content')
|
||||
if (main) {
|
||||
main.addEventListener('click', handler)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (main) {
|
||||
main.removeEventListener('click', handler)
|
||||
}
|
||||
}
|
||||
}, [asPath])
|
||||
|
||||
useEffect(() => {
|
||||
if (document.querySelectorAll('code[data-replacedomain]').length > 0) {
|
||||
sendEvent({
|
||||
type: EventType.experiment,
|
||||
experiment_name: EXPERIMENT_NAME,
|
||||
experiment_variation: 'available',
|
||||
experiment_success: true,
|
||||
})
|
||||
}
|
||||
}, [asPath])
|
||||
|
||||
const nameInputRef = useRef<HTMLInputElement>(null)
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (nameInputRef.current) {
|
||||
nameInputRef.current.focus()
|
||||
}
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const [submissionFailed, setSubmissionFailed] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
function handlSubmit(name: string) {
|
||||
const searchParams = new URLSearchParams({ [QUERY_STRING_KEY]: name })
|
||||
setLoading(true)
|
||||
fetch(`/__tracking__?${searchParams.toString()}`)
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
setOpen(false)
|
||||
setSubmissionFailed(false)
|
||||
setDomainName(localName.trim().toLowerCase())
|
||||
} else {
|
||||
setSubmissionFailed(true)
|
||||
}
|
||||
|
||||
sendEvent({
|
||||
type: EventType.experiment,
|
||||
experiment_name: EXPERIMENT_NAME,
|
||||
experiment_variation: 'saved',
|
||||
experiment_success: true,
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
const validationError = getValidationError(localName)
|
||||
|
||||
return (
|
||||
<div data-testid="domain-name-edit" className={xs ? 'd-flex' : ''}>
|
||||
{open && (
|
||||
<Dialog
|
||||
title={
|
||||
<>
|
||||
{t('edit_your')}{' '}
|
||||
<span style={{ marginLeft: 15 }}>
|
||||
<BeakerIcon size={18} /> {t('experimental')}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
width="large"
|
||||
footerButtons={[
|
||||
{ buttonType: 'default', content: t('cancel'), onClick: () => setOpen(false) },
|
||||
{
|
||||
buttonType: 'primary',
|
||||
type: 'button',
|
||||
onClick: () => {
|
||||
handlSubmit(localName.trim())
|
||||
},
|
||||
content: loading ? (
|
||||
<>
|
||||
<Spinner size="small" /> {t('save')}
|
||||
</>
|
||||
) : (
|
||||
t('save')
|
||||
),
|
||||
disabled: !!validationError || loading,
|
||||
},
|
||||
]}
|
||||
onClose={() => {
|
||||
setOpen(false)
|
||||
|
||||
sendEvent({
|
||||
type: EventType.experiment,
|
||||
experiment_name: EXPERIMENT_NAME,
|
||||
experiment_variation: 'closed',
|
||||
experiment_success: true,
|
||||
})
|
||||
}}
|
||||
aria-labelledby="header"
|
||||
>
|
||||
<form
|
||||
data-testid="domain-name-edit-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!validationError) {
|
||||
handlSubmit(localName.trim())
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 3 }}>
|
||||
<FormControl>
|
||||
<FormControl.Label>{t('name')}</FormControl.Label>
|
||||
<TextInput
|
||||
value={localName}
|
||||
ref={nameInputRef}
|
||||
aria-label={t('your_name')}
|
||||
placeholder="github.fabrikam.com"
|
||||
onChange={(event) => setLocalName(event.target.value)}
|
||||
validationStatus={localName.trim() && validationError ? 'error' : undefined}
|
||||
sx={{ width: '100%' }}
|
||||
/>
|
||||
{localName.trim() && validationError && (
|
||||
<FormControl.Validation variant="error">{validationError}</FormControl.Validation>
|
||||
)}
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
<SubmissionError error={submissionFailed} />
|
||||
|
||||
<LearnMoreSnippet />
|
||||
</form>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{/* Deliberately commented out until we decide to include this on all pages */}
|
||||
{/* <DisplayAndToggle
|
||||
xs={xs}
|
||||
domainNames={domainNames}
|
||||
trigger={() => setOpen(true)}
|
||||
returnFocusRef={returnFocusRef}
|
||||
/> */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getValidationError(domainName: string) {
|
||||
const clean = domainName.trim().toLowerCase()
|
||||
if (/\s/.test(clean)) {
|
||||
return 'Whitespace'
|
||||
}
|
||||
// if (clean === 'hostname' || !clean) {
|
||||
// return 'Empty'
|
||||
// }
|
||||
if (clean === 'github.com' || clean === 'api.github.com') {
|
||||
return "Can't be github.com"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function SubmissionError({ error }: { error: boolean }) {
|
||||
const { t } = useTranslation(TRANSLATION_NAMESPACE)
|
||||
if (error) {
|
||||
return (
|
||||
<Flash variant="danger">
|
||||
<p>{t('submission_failed')}</p>
|
||||
</Flash>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/* Deliberately commented out until we decide to include this on all pages */
|
||||
// function DisplayAndToggle({
|
||||
// xs,
|
||||
// domainNames,
|
||||
// trigger,
|
||||
// returnFocusRef,
|
||||
// }: {
|
||||
// xs?: boolean
|
||||
// domainNames: string[]
|
||||
// trigger: () => void
|
||||
// returnFocusRef: React.RefObject<HTMLButtonElement>
|
||||
// }) {
|
||||
// const { t } = useTranslation(TRANSLATION_NAMESPACE)
|
||||
// return (
|
||||
// <Box
|
||||
// sx={{
|
||||
// display: !xs ? ['none', null, 'flex'] : undefined,
|
||||
// gap: '2',
|
||||
// alignItems: 'center',
|
||||
// ml: 2,
|
||||
// position: 'relative',
|
||||
// }}
|
||||
// >
|
||||
// <Text
|
||||
// sx={{
|
||||
// fontSize: '1',
|
||||
// color: 'fg.muted',
|
||||
// fontWeight: 'semibold',
|
||||
// }}
|
||||
// >
|
||||
// <Text
|
||||
// sx={{
|
||||
// color: 'fg.default',
|
||||
// }}
|
||||
// >
|
||||
// {t('name')}:
|
||||
// </Text>{' '}
|
||||
// <code>{domainNames.length ? domainNames[0] : DEFAULT}</code>
|
||||
// </Text>
|
||||
// <Button
|
||||
// variant="invisible"
|
||||
// size="small"
|
||||
// sx={{
|
||||
// display: 'absolute',
|
||||
// right: 0,
|
||||
// }}
|
||||
// ref={returnFocusRef}
|
||||
// onClick={() => {
|
||||
// trigger()
|
||||
// }}
|
||||
// >
|
||||
// {t('edit')}
|
||||
// </Button>
|
||||
// </Box>
|
||||
// )
|
||||
// }
|
||||
|
||||
function LearnMoreSnippet() {
|
||||
const { t } = useTranslation(TRANSLATION_NAMESPACE)
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<p>
|
||||
{t('snippet_about')}{' '}
|
||||
<a href="/enterprise-server@latest/early-access/admin/articles/editing-host-names-in-github-docs">
|
||||
{t('learn_more')}
|
||||
</a>
|
||||
</p>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
export function createPenSVG(): SVGSVGElement {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||||
svg.setAttribute('focusable', 'false')
|
||||
svg.setAttribute('role', 'img')
|
||||
svg.setAttribute('viewBox', '0 0 16 16')
|
||||
svg.setAttribute('width', '16')
|
||||
svg.setAttribute('height', '16')
|
||||
svg.setAttribute('fill', 'currentColor')
|
||||
svg.setAttribute(
|
||||
'style',
|
||||
'display: inline-block; user-select: none; vertical-align: text-bottom; overflow: visible; color: var(--color-accent-fg); text-decoration: dashed underline;',
|
||||
)
|
||||
|
||||
const PEN_PATH = `M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.823L9.75 4.81l-6.286 6.287a.253.253 0 0 0-.064.108l-.558 1.953 1.953-.558a.253.253 0 0 0 .108-.064Zm1.238-3.763a.25.25 0 0 0-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 0 0 0-.354Z`
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
||||
path.setAttribute('d', PEN_PATH)
|
||||
svg.appendChild(path)
|
||||
|
||||
return svg
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
import { useVersion } from '@/versions/components/useVersion'
|
||||
import { useEditableDomainName } from './useEditableDomainContext'
|
||||
import { createPenSVG } from './pen-icon'
|
||||
|
||||
// We only want to activate the replace-domain feature for these versions.
|
||||
// This means that if you're on a version we don't want it activated on,
|
||||
// even though you have a your-domain cookie, it *won't* replace the
|
||||
// word HOSTNAME.
|
||||
const REPLACEDOMAIN_VERSION_PREFIXES = ['enterprise-server@']
|
||||
|
||||
function replaceDomains(domain: string | null) {
|
||||
document.querySelectorAll<HTMLElement>('pre code[data-replacedomain]').forEach((codeBlock) => {
|
||||
const replaceDomain = codeBlock.dataset.replacedomain
|
||||
if (!replaceDomain) return
|
||||
const replaceDomains = replaceDomain.split(/\s/)
|
||||
const spans = codeBlock.querySelectorAll<HTMLSpanElement>('span[class*="-string"]')
|
||||
if (spans.length) {
|
||||
spans.forEach((span) => {
|
||||
replaceInTextContent(span, replaceDomains, domain)
|
||||
})
|
||||
} else {
|
||||
replaceInTextContent(codeBlock, replaceDomains, domain)
|
||||
}
|
||||
replaceInClipboard(codeBlock, replaceDomains, domain)
|
||||
})
|
||||
}
|
||||
|
||||
function replaceInClipboard(element: HTMLElement, replaceDomains: string[], domain: string | null) {
|
||||
if (
|
||||
element.parentElement &&
|
||||
element.parentElement.parentElement &&
|
||||
element.parentElement.parentElement.classList.contains('code-example')
|
||||
) {
|
||||
const pre =
|
||||
element.parentElement.parentElement.querySelector<HTMLPreElement>('pre[data-clipboard]')
|
||||
const regex = new RegExp(`(${replaceDomains.join('|')})`)
|
||||
if (pre && pre.textContent) {
|
||||
if (!pre.dataset.originalText) {
|
||||
pre.dataset.originalText = pre.textContent
|
||||
}
|
||||
if (domain) {
|
||||
pre.textContent = pre.dataset.originalText.replace(regex, domain)
|
||||
} else {
|
||||
pre.textContent = pre.dataset.originalText
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function replaceInTextContent(
|
||||
element: HTMLElement,
|
||||
replaceDomains: string[],
|
||||
domain: string | null,
|
||||
) {
|
||||
if (!element.textContent) return
|
||||
|
||||
if (!element.querySelector('.replacedomain-text')) {
|
||||
splitElementText(element, replaceDomains)
|
||||
}
|
||||
|
||||
if (domain !== null) {
|
||||
element.querySelectorAll('.replacedomain-text').forEach((textSpan) => {
|
||||
textSpan.textContent = domain
|
||||
textSpan.classList.add('editable-domain')
|
||||
})
|
||||
element.querySelectorAll('.replacedomain-edit').forEach((toggleElement) => {
|
||||
toggleElement.classList.remove('visually-hidden')
|
||||
})
|
||||
} else {
|
||||
element.querySelectorAll('.replacedomain-text').forEach((textSpan) => {
|
||||
if (element.dataset.replacedomain) {
|
||||
textSpan.textContent = element.dataset.replacedomain
|
||||
}
|
||||
textSpan.classList.remove('editable-domain')
|
||||
})
|
||||
element.querySelectorAll('.replacedomain-edit').forEach((toggleElement) => {
|
||||
toggleElement.classList.remove('visually-hidden')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function splitElementText(element: HTMLElement, replaceDomains: string[]) {
|
||||
const splitText = element.textContent!.split(new RegExp(`(${replaceDomains.join('|')})`))
|
||||
element.textContent = ''
|
||||
for (const text of splitText) {
|
||||
if (replaceDomains.includes(text)) {
|
||||
element.appendChild(createEditWrapper(text))
|
||||
} else {
|
||||
const span = document.createElement('span')
|
||||
span.textContent = text
|
||||
element.appendChild(span)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createEditWrapper(text: string): HTMLSpanElement {
|
||||
const element = document.createElement('span')
|
||||
element.classList.add('replacedomain-edit')
|
||||
const span = document.createElement('span')
|
||||
span.classList.add('replacedomain-text')
|
||||
span.textContent = text
|
||||
element.appendChild(span)
|
||||
element.appendChild(createPenSVG())
|
||||
|
||||
return element
|
||||
}
|
||||
|
||||
export function ReplaceDomain() {
|
||||
const { asPath } = useRouter()
|
||||
const { domainName } = useEditableDomainName()
|
||||
const { currentVersion } = useVersion()
|
||||
|
||||
const enable = REPLACEDOMAIN_VERSION_PREFIXES.some((prefix) => currentVersion.startsWith(prefix))
|
||||
|
||||
useEffect(() => {
|
||||
if (domainName) {
|
||||
if (enable) {
|
||||
replaceDomains(domainName.split(',')[0])
|
||||
} else {
|
||||
replaceDomains(null)
|
||||
}
|
||||
} else if (enable) {
|
||||
replaceDomains(null)
|
||||
}
|
||||
}, [asPath, enable, domainName])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useContext, useEffect, createContext, useState } from 'react'
|
||||
import Cookies from 'js-cookie'
|
||||
|
||||
const COOKIE_KEY = 'github_domains'
|
||||
const DEFAULT = ''
|
||||
|
||||
type DomainNameEdit = {
|
||||
domainName: string
|
||||
setDomainName: (value: string) => void
|
||||
}
|
||||
const UseEditableDomainContext = createContext<DomainNameEdit>({
|
||||
domainName: '',
|
||||
setDomainName: () => {},
|
||||
})
|
||||
|
||||
export function DomainNameEditProvider({ children }: { children: React.ReactNode }) {
|
||||
const [name, setName] = useState(DEFAULT)
|
||||
useEffect(() => {
|
||||
const cookie = Cookies.get(COOKIE_KEY)
|
||||
if (cookie) {
|
||||
setName(cookie.split(',')[0])
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<UseEditableDomainContext.Provider value={{ domainName: name, setDomainName: setName }}>
|
||||
{children}
|
||||
</UseEditableDomainContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useEditableDomainName = () => {
|
||||
const context = useContext(UseEditableDomainContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useEditableDomainName must be inside a DomainNameEditProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
span.replacedomain-edit {
|
||||
color: var(--fgColor-accent, var(--color-accent-fg, #0969da));
|
||||
font-weight: 600;
|
||||
border-width: 1px;
|
||||
border-style: solid solid dashed;
|
||||
border-image: initial;
|
||||
border-top-color: transparent;
|
||||
border-right-color: transparent;
|
||||
border-bottom-color: var(
|
||||
--borderColor-default,
|
||||
var(--color-border-default, #d0d7de)
|
||||
);
|
||||
border-left-color: transparent;
|
||||
padding-left: 2px;
|
||||
padding-right: 2px;
|
||||
cursor: pointer;
|
||||
|
||||
:hover {
|
||||
border-color: var(
|
||||
--borderColor-default,
|
||||
var(--color-border-default, #d0d7de)
|
||||
);
|
||||
background-color: var(
|
||||
--bgColor-default,
|
||||
var(--color-canvas-default, #ffffff)
|
||||
);
|
||||
}
|
||||
|
||||
svg {
|
||||
margin: 0 3px;
|
||||
}
|
||||
}
|
||||
|
||||
span.editable-domain {
|
||||
color: var(--color-accent-fg);
|
||||
cursor: pointer;
|
||||
// text-decoration: dashed underline;
|
||||
}
|
||||
@@ -37,7 +37,7 @@ const RECOGNIZED_KEYS_BY_ANY = new Set([
|
||||
'search-overlay-ask-ai',
|
||||
// The drop-downs on "Webhook events and payloads"
|
||||
'actionType',
|
||||
// Used by the tracking middleware
|
||||
// Legacy domain tracking parameter (no longer processed but still recognized)
|
||||
'ghdomain',
|
||||
// UTM campaign tracking
|
||||
'utm_source',
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
# Tracking
|
||||
|
||||
## Overview
|
||||
|
||||
This is about recording inbound links that helps with "tracking".
|
||||
|
||||
For example, if you arrive on Docs with `?ghdomain=example.ghe.com` we
|
||||
can pick that up and put it in a cookie so that the user's content, when
|
||||
they view it, can say `curl https://example.ghe.com/api/v1` instead
|
||||
of the stock `curl https://HOSTNAME/api/v1`.
|
||||
|
||||
## How it works
|
||||
|
||||
For a certain number of query strings, we "snatch them up" and redirect
|
||||
to the same URL as you were on but with the query string key removed.
|
||||
And in the 302 Found response, we might include a `set-cookie`.
|
||||
|
||||
## Notes
|
||||
|
||||
none
|
||||
@@ -1,86 +0,0 @@
|
||||
import type { Response, NextFunction } from 'express'
|
||||
|
||||
import type { ExtendedRequest } from '@/types'
|
||||
import statsd from '@/observability/lib/statsd.js'
|
||||
import { noCacheControl } from '@/frame/middleware/cache-control.js'
|
||||
|
||||
const STATSD_KEY = 'middleware.handle_tracking_querystrings'
|
||||
|
||||
// Exported for the sake of end-to-end tests
|
||||
export const DOMAIN_QUERY_PARAM = 'ghdomain'
|
||||
export const MAX_DOMAINS_SAVED = 3
|
||||
|
||||
const DOMAIN_COOKIE_AGE_MS = 365 * 24 * 3600 * 1000
|
||||
export const DOMAIN_COOKIE_NAME = 'github_domains'
|
||||
|
||||
export default function handleTrackingQueryStrings(
|
||||
req: ExtendedRequest,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
if (req.path.startsWith('/_next/')) {
|
||||
return next()
|
||||
}
|
||||
|
||||
if (req.query[DOMAIN_QUERY_PARAM] || req.query[DOMAIN_QUERY_PARAM] === '') {
|
||||
if (Array.isArray(req.query[DOMAIN_QUERY_PARAM])) {
|
||||
res.status(400).send('can only be one')
|
||||
|
||||
const tags = [`key:${DOMAIN_QUERY_PARAM}`, 'domain:_multiple_']
|
||||
statsd.increment(STATSD_KEY, 1, tags)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams(req.query as any)
|
||||
|
||||
const oldCookieValue: string = req.cookies[DOMAIN_COOKIE_NAME] || ''
|
||||
const oldCookieValueParsed = oldCookieValue
|
||||
.split(',')
|
||||
.map((x) => x.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
|
||||
const domain = (searchParams.get(DOMAIN_QUERY_PARAM) || '').toLowerCase().trim()
|
||||
if (domain) {
|
||||
const newCookieValue = [domain, ...oldCookieValueParsed.filter((x) => x !== domain)]
|
||||
.slice(0, MAX_DOMAINS_SAVED)
|
||||
.join(',')
|
||||
res.cookie(DOMAIN_COOKIE_NAME, newCookieValue, {
|
||||
maxAge: DOMAIN_COOKIE_AGE_MS,
|
||||
httpOnly: false,
|
||||
})
|
||||
} else {
|
||||
res.clearCookie(DOMAIN_COOKIE_NAME)
|
||||
}
|
||||
|
||||
searchParams.delete(DOMAIN_QUERY_PARAM)
|
||||
|
||||
noCacheControl(res)
|
||||
|
||||
let newURL = req.path
|
||||
if (searchParams.toString()) {
|
||||
newURL += `?${searchParams.toString()}`
|
||||
}
|
||||
|
||||
// Ordinarily, you can put the query string on any URL and the server
|
||||
// will just 302 redirect you to the same URL but with the query string
|
||||
// key removed. However, when we, from the client-side UI, send a
|
||||
// fetch() event, as an XHR request, we can't follow the redirect.
|
||||
// So we have this "dummy" endpoint just to be able return a 200 OK.
|
||||
if (req.path === '/__tracking__') {
|
||||
res.send('OK')
|
||||
} else {
|
||||
res.redirect(302, newURL)
|
||||
}
|
||||
|
||||
const tags = [`key:${DOMAIN_QUERY_PARAM}`, `domain:${domain || '_empty_'}`]
|
||||
statsd.increment(STATSD_KEY, 1, tags)
|
||||
|
||||
return
|
||||
} else if (req.path === '/__tracking__') {
|
||||
// E.g. `GET /__tracking__` but not the lack of query string
|
||||
return res.status(400).type('text').send('Lacking query string')
|
||||
}
|
||||
|
||||
return next()
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import express from 'express'
|
||||
|
||||
import handleTrackingQueryStrings from './handle-query-strings.js'
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
router.use(handleTrackingQueryStrings)
|
||||
|
||||
export default router
|
||||
@@ -1,133 +0,0 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { get } from '@/tests/helpers/e2etest.js'
|
||||
import {
|
||||
DOMAIN_QUERY_PARAM,
|
||||
DOMAIN_COOKIE_NAME,
|
||||
MAX_DOMAINS_SAVED,
|
||||
} from '../middleware/handle-query-strings.js'
|
||||
|
||||
describe('setting a cookie', () => {
|
||||
test('on home page', async () => {
|
||||
const res = await get(`/en?${DOMAIN_QUERY_PARAM}=acme.example.com`)
|
||||
expect(res.statusCode).toBe(302)
|
||||
const setCookie = res.headers['set-cookie'][0]
|
||||
expect(setCookie).toMatch(/github_domains=acme.example.com/)
|
||||
expect(res.headers.location).toBe('/en')
|
||||
expect(res.headers['cache-control']).toMatch(/private/)
|
||||
expect(res.headers['cache-control']).toMatch(/max-age=0/)
|
||||
})
|
||||
|
||||
test('with other query string things', async () => {
|
||||
const res = await get(`/en?${DOMAIN_QUERY_PARAM}=acme.example.com&foo=bar`)
|
||||
expect(res.statusCode).toBe(302)
|
||||
const setCookie = res.headers['set-cookie'][0]
|
||||
expect(setCookie).toMatch(/github_domains=acme.example.com/)
|
||||
expect(res.headers.location).toBe('/en?foo=bar')
|
||||
})
|
||||
|
||||
test('always lowercase', async () => {
|
||||
const res = await get(`/en?${DOMAIN_QUERY_PARAM}=Acme.example.COM`)
|
||||
expect(res.statusCode).toBe(302)
|
||||
const setCookie = res.headers['set-cookie'][0]
|
||||
expect(setCookie).toMatch(/github_domains=acme.example.com/)
|
||||
})
|
||||
|
||||
test('on root page', async () => {
|
||||
const res = await get(`/?${DOMAIN_QUERY_PARAM}=acme.example.com`)
|
||||
expect(res.statusCode).toBe(302)
|
||||
const setCookie = res.headers['set-cookie'][0]
|
||||
expect(setCookie).toMatch(/github_domains=acme.example.com/)
|
||||
expect(res.headers.location).toBe('/')
|
||||
})
|
||||
|
||||
test('empty value does nothing if nothing previous', async () => {
|
||||
const res = await get(`/?${DOMAIN_QUERY_PARAM}=`)
|
||||
expect(res.statusCode).toBe(302)
|
||||
expect(res.headers['set-cookie'][0]).toMatch(`${DOMAIN_COOKIE_NAME}=;`)
|
||||
})
|
||||
|
||||
test('empty value, when trimmed, does nothing if nothing previous', async () => {
|
||||
const res = await get(`/?${DOMAIN_QUERY_PARAM}=%20`)
|
||||
expect(res.statusCode).toBe(302)
|
||||
expect(res.headers['set-cookie'][0]).toMatch(`${DOMAIN_COOKIE_NAME}=;`)
|
||||
})
|
||||
|
||||
test('empty value resets previous cookie', async () => {
|
||||
const res = await get(`/?${DOMAIN_QUERY_PARAM}=`, {
|
||||
headers: {
|
||||
cookie: `${DOMAIN_COOKIE_NAME}=acme.example.com`,
|
||||
},
|
||||
})
|
||||
expect(res.statusCode).toBe(302)
|
||||
const setCookie = res.headers['set-cookie'][0]
|
||||
expect(setCookie).toMatch(/github_domains=;/)
|
||||
})
|
||||
|
||||
test('append with previous', async () => {
|
||||
const res = await get(`/?${DOMAIN_QUERY_PARAM}=next.example.com`, {
|
||||
headers: {
|
||||
cookie: `${DOMAIN_COOKIE_NAME}=previous.example.com`,
|
||||
},
|
||||
})
|
||||
expect(res.statusCode).toBe(302)
|
||||
const setCookie = res.headers['set-cookie'][0]
|
||||
// %2C is a comma
|
||||
expect(setCookie).toMatch(/github_domains=next.example.com%2Cprevious.example.com;/)
|
||||
})
|
||||
|
||||
test('append with too many', async () => {
|
||||
let cookie = ''
|
||||
for (const letter of Array.from('abcdef')) {
|
||||
const next = `${letter}.example.com`
|
||||
const res = await get(`/?${DOMAIN_QUERY_PARAM}=${next}`, {
|
||||
headers: { cookie },
|
||||
})
|
||||
const setCookie: string = res.headers['set-cookie'][0]
|
||||
cookie = setCookie.split(';').filter((x) => x.startsWith(DOMAIN_COOKIE_NAME))[0]
|
||||
if (letter === 'a') {
|
||||
// first
|
||||
expect(cookie).toBe(`${DOMAIN_COOKIE_NAME}=a.example.com`)
|
||||
} else if (letter === 'f') {
|
||||
// last
|
||||
expect(cookie.split('%2C').length).toBe(MAX_DOMAINS_SAVED)
|
||||
expect(cookie.startsWith(`${DOMAIN_COOKIE_NAME}=f.example.com`)).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('append with same as before', async () => {
|
||||
const res = await get(`/?${DOMAIN_QUERY_PARAM}=Acme.example.com`, {
|
||||
headers: {
|
||||
cookie: `${DOMAIN_COOKIE_NAME}=acme.example.com`,
|
||||
},
|
||||
})
|
||||
expect(res.statusCode).toBe(302)
|
||||
const setCookie = res.headers['set-cookie'][0]
|
||||
expect(setCookie).toMatch(/github_domains=acme.example.com;/)
|
||||
})
|
||||
|
||||
test('trying to set multiple', async () => {
|
||||
const res = await get(
|
||||
`/?${DOMAIN_QUERY_PARAM}=a.example.com&${DOMAIN_QUERY_PARAM}=b.example.com`,
|
||||
)
|
||||
expect(res.statusCode).toBe(400)
|
||||
expect(res.body).toMatch(/can only be one/)
|
||||
})
|
||||
|
||||
test('using the custom end point (200 OK)', async () => {
|
||||
const res = await get(`/__tracking__?${DOMAIN_QUERY_PARAM}=Acme.example.com`)
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(res.body).toMatch(/OK/)
|
||||
})
|
||||
test('using the custom end point with no value', async () => {
|
||||
const res = await get(`/__tracking__?${DOMAIN_QUERY_PARAM}=`)
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(res.body).toMatch(/OK/)
|
||||
})
|
||||
test('using the custom end point (400 Bad request)', async () => {
|
||||
const res = await get('/__tracking__')
|
||||
expect(res.statusCode).toBe(400)
|
||||
expect(res.body).toMatch(/Lacking query string/)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user