Skip to content

Commit

Permalink
Improve Zod errors (#1542)
Browse files Browse the repository at this point in the history
Co-authored-by: HiDeoo <494699+HiDeoo@users.noreply.github.com>
  • Loading branch information
delucis and HiDeoo committed Feb 23, 2024
1 parent 1043052 commit b3b7a60
Show file tree
Hide file tree
Showing 12 changed files with 414 additions and 145 deletions.
5 changes: 5 additions & 0 deletions .changeset/itchy-dots-act.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@astrojs/starlight": patch
---

Improves error messages shown by Starlight for configuration errors.
189 changes: 189 additions & 0 deletions packages/starlight/__tests__/basics/config-errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { expect, test } from 'vitest';
import { parseWithFriendlyErrors } from '../../utils/error-map';
import { StarlightConfigSchema, type StarlightUserConfig } from '../../utils/user-config';

function parseStarlightConfigWithFriendlyErrors(config: StarlightUserConfig) {
return parseWithFriendlyErrors(
StarlightConfigSchema,
config,
'Invalid config passed to starlight integration'
);
}

test('parses valid config successfully', () => {
const data = parseStarlightConfigWithFriendlyErrors({ title: '' });
expect(data).toMatchInlineSnapshot(`
{
"components": {
"Banner": "@astrojs/starlight/components/Banner.astro",
"ContentPanel": "@astrojs/starlight/components/ContentPanel.astro",
"EditLink": "@astrojs/starlight/components/EditLink.astro",
"FallbackContentNotice": "@astrojs/starlight/components/FallbackContentNotice.astro",
"Footer": "@astrojs/starlight/components/Footer.astro",
"Head": "@astrojs/starlight/components/Head.astro",
"Header": "@astrojs/starlight/components/Header.astro",
"Hero": "@astrojs/starlight/components/Hero.astro",
"LanguageSelect": "@astrojs/starlight/components/LanguageSelect.astro",
"LastUpdated": "@astrojs/starlight/components/LastUpdated.astro",
"MarkdownContent": "@astrojs/starlight/components/MarkdownContent.astro",
"MobileMenuFooter": "@astrojs/starlight/components/MobileMenuFooter.astro",
"MobileMenuToggle": "@astrojs/starlight/components/MobileMenuToggle.astro",
"MobileTableOfContents": "@astrojs/starlight/components/MobileTableOfContents.astro",
"PageFrame": "@astrojs/starlight/components/PageFrame.astro",
"PageSidebar": "@astrojs/starlight/components/PageSidebar.astro",
"PageTitle": "@astrojs/starlight/components/PageTitle.astro",
"Pagination": "@astrojs/starlight/components/Pagination.astro",
"Search": "@astrojs/starlight/components/Search.astro",
"Sidebar": "@astrojs/starlight/components/Sidebar.astro",
"SiteTitle": "@astrojs/starlight/components/SiteTitle.astro",
"SkipLink": "@astrojs/starlight/components/SkipLink.astro",
"SocialIcons": "@astrojs/starlight/components/SocialIcons.astro",
"TableOfContents": "@astrojs/starlight/components/TableOfContents.astro",
"ThemeProvider": "@astrojs/starlight/components/ThemeProvider.astro",
"ThemeSelect": "@astrojs/starlight/components/ThemeSelect.astro",
"TwoColumnContent": "@astrojs/starlight/components/TwoColumnContent.astro",
},
"customCss": [],
"defaultLocale": {
"dir": "ltr",
"label": "English",
"lang": "en",
"locale": undefined,
},
"disable404Route": false,
"editLink": {},
"favicon": {
"href": "/favicon.svg",
"type": "image/svg+xml",
},
"head": [],
"isMultilingual": false,
"lastUpdated": false,
"locales": undefined,
"pagefind": true,
"pagination": true,
"tableOfContents": {
"maxHeadingLevel": 3,
"minHeadingLevel": 2,
},
"title": "",
"titleDelimiter": "|",
}
`);
});

test('errors if title is missing', () => {
expect(() =>
parseStarlightConfigWithFriendlyErrors({} as any)
).toThrowErrorMatchingInlineSnapshot(
`
"[AstroUserError]:
Invalid config passed to starlight integration
Hint:
**title**: Required"
`
);
});

test('errors if title value is not a string', () => {
expect(() =>
parseStarlightConfigWithFriendlyErrors({ title: 5 } as any)
).toThrowErrorMatchingInlineSnapshot(
`
"[AstroUserError]:
Invalid config passed to starlight integration
Hint:
**title**: Expected type \`"string"\`, received \`"number"\`"
`
);
});

test('errors with bad social icon config', () => {
expect(() =>
parseStarlightConfigWithFriendlyErrors({ title: 'Test', social: { unknown: '' } as any })
).toThrowErrorMatchingInlineSnapshot(
`
"[AstroUserError]:
Invalid config passed to starlight integration
Hint:
**social.unknown**: Invalid enum value. Expected 'twitter' | 'mastodon' | 'github' | 'gitlab' | 'bitbucket' | 'discord' | 'gitter' | 'codeberg' | 'codePen' | 'youtube' | 'threads' | 'linkedin' | 'twitch' | 'microsoftTeams' | 'instagram' | 'stackOverflow' | 'x.com' | 'telegram' | 'rss' | 'facebook' | 'email' | 'reddit' | 'patreon' | 'slack' | 'matrix' | 'openCollective', received 'unknown'
**social.unknown**: Invalid url"
`
);
});

test('errors with bad logo config', () => {
expect(() =>
parseStarlightConfigWithFriendlyErrors({ title: 'Test', logo: { html: '' } as any })
).toThrowErrorMatchingInlineSnapshot(
`
"[AstroUserError]:
Invalid config passed to starlight integration
Hint:
**logo**: Did not match union.
> Expected type \`{ src: string } | { dark: string; light: string }\`
> Received \`{ "html": "" }\`"
`
);
});

test('errors with bad head config', () => {
expect(() =>
parseStarlightConfigWithFriendlyErrors({
title: 'Test',
head: [{ tag: 'unknown', attrs: { prop: null }, content: 20 } as any],
})
).toThrowErrorMatchingInlineSnapshot(
`
"[AstroUserError]:
Invalid config passed to starlight integration
Hint:
**head.0.tag**: Invalid enum value. Expected 'title' | 'base' | 'link' | 'style' | 'meta' | 'script' | 'noscript' | 'template', received 'unknown'
**head.0.attrs.prop**: Did not match union.
> Expected type \`"string" | "boolean" | "undefined"\`, received \`"null"\`
**head.0.content**: Expected type \`"string"\`, received \`"number"\`"
`
);
});

test('errors with bad sidebar config', () => {
expect(() =>
parseStarlightConfigWithFriendlyErrors({
title: 'Test',
sidebar: [{ label: 'Example', href: '/' } as any],
})
).toThrowErrorMatchingInlineSnapshot(
`
"[AstroUserError]:
Invalid config passed to starlight integration
Hint:
**sidebar.0**: Did not match union.
> Expected type \`{ link: string } | { items: array } | { autogenerate: object }\`
> Received \`{ "label": "Example", "href": "/" }\`"
`
);
});

test('errors with bad nested sidebar config', () => {
expect(() =>
parseStarlightConfigWithFriendlyErrors({
title: 'Test',
sidebar: [
{
label: 'Example',
items: [
{ label: 'Nested Example 1', link: '/' },
{ label: 'Nested Example 2', link: true },
],
} as any,
],
})
).toThrowErrorMatchingInlineSnapshot(`
"[AstroUserError]:
Invalid config passed to starlight integration
Hint:
**sidebar.0.items.1**: Did not match union.
> Expected type \`{ link: string } | { items: array } | { autogenerate: object }\`
> Received \`{ "label": "Example", "items": [ { "label": "Nested Example 1", "link": "/" }, { "label": "Nested Example 2", "link": true } ] }\`"
`);
});
Original file line number Diff line number Diff line change
Expand Up @@ -21,25 +21,19 @@ const starlightPageProps: StarlightPageProps = {
};

test('throws a validation error if a built-in field required by the user schema is not passed down', async () => {
expect.assertions(3);

try {
await generateStarlightPageRouteData({
// The first line should be a user-friendly error message describing the exact issue and the second line should be
// the missing description field.
expect(() =>
generateStarlightPageRouteData({
props: starlightPageProps,
url: new URL('https://example.com/test-slug'),
});
} catch (error) {
assert(error instanceof Error);
const lines = error.message.split('\n');
// The first line should be a user-friendly error message describing the exact issue and the second line should be
// the missing description field.
expect(lines).toHaveLength(2);
const [message, missingField] = lines;
expect(message).toMatchInlineSnapshot(
`"Invalid frontmatter props passed to the \`<StarlightPage/>\` component."`
);
expect(missingField).toMatchInlineSnapshot(`"**description**: Required"`);
}
})
).rejects.toThrowErrorMatchingInlineSnapshot(`
"[AstroUserError]:
Invalid frontmatter props passed to the \`<StarlightPage/>\` component.
Hint:
**description**: Required"
`);
});

test('returns new field defined in the user schema', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,38 @@ test('throws error if sidebar is malformated', async () => {
url: starlightPageUrl,
})
).rejects.toThrowErrorMatchingInlineSnapshot(`
[Error: Invalid sidebar prop passed to the \`<StarlightPage/>\` component.
**0**: Did not match union:]
"[AstroUserError]:
Invalid sidebar prop passed to the \`<StarlightPage/>\` component.
Hint:
**0**: Did not match union.
> Expected type \`{ href: string } | { entries: array }\`
> Received \`{ "label": "Custom link 1", "href": 5 }\`"
`);
});

test('throws error if sidebar uses wrong literal for entry type', async () => {
// This test also makes sure we show a helpful error for incorrect literals.
expect(() =>
generateStarlightPageRouteData({
props: {
...starlightPageProps,
sidebar: [
{
//@ts-expect-error Intentionally bad type to cause error.
type: 'typo',
label: 'Custom link 1',
href: '/',
},
],
},
url: starlightPageUrl,
})
).rejects.toThrowErrorMatchingInlineSnapshot(`
"[AstroUserError]:
Invalid sidebar prop passed to the \`<StarlightPage/>\` component.
Hint:
**0**: Did not match union.
> **0.type**: Expected \`"link" | "group"\`, received \`"typo"\`"
`);
});

Expand Down
22 changes: 22 additions & 0 deletions packages/starlight/__tests__/snapshot-serializer-astro-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { AstroError } from 'astro/errors';
import type { SnapshotSerializer } from 'vitest';

export default {
/** Check if a value should be handled by this serializer, i.e. if it is an `AstroError`. */
test(val) {
return !!val && AstroError.is(val);
},
/** Customize serialization of Astro errors to include the `hint`. Vitest only uses `message` by default. */
serialize({ name, message, hint }: AstroError, config, indentation, depth, refs, printer) {
const prettyError = `[${name}]:\n${indent(message)}\nHint:\n${indent(hint)}`;
return printer(prettyError, config, indentation, depth, refs);
},
} satisfies SnapshotSerializer;

/** Indent each line in `string` with a given character. */
function indent(string = '', indentation = '\t') {
return string
.split('\n')
.map((line) => indentation + line)
.join('\n');
}
3 changes: 3 additions & 0 deletions packages/starlight/__tests__/test-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,8 @@ export async function defineVitestConfig(
plugins: [
vitePluginStarlightUserConfig(starlightConfig, { root, srcDir, build, trailingSlash }),
],
test: {
snapshotSerializers: ['./snapshot-serializer-astro-error.ts'],
},
});
}
4 changes: 2 additions & 2 deletions packages/starlight/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -176,9 +176,9 @@
"devDependencies": {
"@astrojs/markdown-remark": "^4.2.1",
"@types/node": "^18.16.19",
"@vitest/coverage-v8": "^1.2.2",
"@vitest/coverage-v8": "^1.3.1",
"astro": "^4.3.5",
"vitest": "^1.2.2"
"vitest": "^1.3.1"
},
"dependencies": {
"@astrojs/mdx": "^2.1.1",
Expand Down
Loading

0 comments on commit b3b7a60

Please sign in to comment.