Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve Zod errors #1542

Merged
merged 20 commits into from
Feb 23, 2024
Merged
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
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
Loading