summaryrefslogtreecommitdiff
path: root/packages/astro/test/0-css.test.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/astro/test/0-css.test.js')
-rw-r--r--packages/astro/test/0-css.test.js454
1 files changed, 454 insertions, 0 deletions
diff --git a/packages/astro/test/0-css.test.js b/packages/astro/test/0-css.test.js
new file mode 100644
index 000000000..65010f580
--- /dev/null
+++ b/packages/astro/test/0-css.test.js
@@ -0,0 +1,454 @@
+/**
+ * CSS test
+ * Run this test first! This uses quite a bit of memory, so prefixing with `0-` helps it start and finish early,
+ * rather than trying to start up when all other threads are busy and having to fight for resources
+ */
+
+import assert from 'node:assert/strict';
+import { after, before, describe, it } from 'node:test';
+import * as cheerio from 'cheerio';
+import { loadFixture } from './test-utils.js';
+
+/** @type {import('./test-utils').Fixture} */
+let fixture;
+
+describe('CSS', function () {
+ before(async () => {
+ fixture = await loadFixture({ root: './fixtures/0-css/' });
+ });
+
+ // test HTML and CSS contents for accuracy
+ describe('build', () => {
+ let $;
+ let html;
+ let bundledCSS;
+
+ before(
+ async () => {
+ await fixture.build();
+
+ // get bundled CSS (will be hashed, hence DOM query)
+ html = await fixture.readFile('/index.html');
+ $ = cheerio.load(html);
+ const bundledCSSHREF = $('link[rel=stylesheet][href^=/_astro/]').attr('href');
+ bundledCSS = (await fixture.readFile(bundledCSSHREF.replace(/^\/?/, '/')))
+ .replace(/\s/g, '')
+ .replace('/n', '');
+ },
+ {
+ timeout: 45000,
+ },
+ );
+
+ describe('Astro Styles', () => {
+ it('HTML and CSS scoped correctly', async () => {
+ const el1 = $('#dynamic-class');
+ const el2 = $('#dynamic-vis');
+ const classes = $('#class');
+ let scopedAttribute;
+ for (const [key] of Object.entries(classes[0].attribs)) {
+ if (/^data-astro-cid-[A-Za-z\d-]+/.test(key)) {
+ // Ema: this is ugly, but for reasons that I don't want to explore, cheerio
+ // lower case the hash of the attribute
+ scopedAttribute = key;
+ }
+ }
+ if (!scopedAttribute) {
+ throw new Error("Couldn't find scoped attribute");
+ }
+
+ // 1. check HTML
+ assert.equal(el1.attr('class'), `blue`);
+ assert.equal(el2.attr('class'), `visible`);
+
+ // 2. check CSS
+ const expected = `.blue[${scopedAttribute}],.color\\:blue[${scopedAttribute}]{color:#b0e0e6}.visible[${scopedAttribute}]{display:block}`;
+ assert.equal(bundledCSS.includes(expected), true);
+ });
+
+ it('Generated link tags are void elements', async () => {
+ assert.notEqual(html.includes('</link>'), true);
+ });
+
+ it('No <style> skips scoping', async () => {
+ // Astro component without <style> should not include scoped class
+ assert.equal($('#no-scope').attr('class'), undefined);
+ });
+
+ it('Child inheritance', (_t, done) => {
+ for (const [key] of Object.entries($('#passed-in')[0].attribs)) {
+ if (/^data-astro-cid-[A-Za-z\d-]+/.test(key)) {
+ done();
+ }
+ }
+ });
+
+ it('Using hydrated components adds astro-island styles', async () => {
+ const inline = $('style').html();
+ assert.equal(inline.includes('display:contents'), true);
+ });
+
+ it('<style lang="sass">', async () => {
+ assert.match(bundledCSS, /h1\[data-astro-cid-[^{]*\{color:#90ee90\}/);
+ });
+
+ it('<style lang="scss">', async () => {
+ assert.match(bundledCSS, /h1\[data-astro-cid-[^{]*\{color:#ff69b4\}/);
+ });
+
+ it('Styles through barrel files should only include used Astro scoped styles', async () => {
+ const barrelHtml = await fixture.readFile('/barrel-styles/index.html');
+ const barrel$ = cheerio.load(barrelHtml);
+ const barrelBundledCssHref = barrel$('link[rel=stylesheet][href^=/_astro/]').attr('href');
+ const style = await fixture.readFile(barrelBundledCssHref.replace(/^\/?/, '/'));
+ assert.match(style, /\.comp-a\[data-astro-cid/);
+ assert.match(style, /\.comp-c\{/);
+ assert.doesNotMatch(style, /\.comp-b/);
+ });
+ });
+
+ describe('Styles in src/', () => {
+ it('.css', async () => {
+ assert.match(bundledCSS, /.linked-css[^{]*\{color:gold/);
+ });
+
+ it('.sass', async () => {
+ assert.match(bundledCSS, /.linked-sass[^{]*\{color:#789/);
+ });
+
+ it('.scss', async () => {
+ assert.match(bundledCSS, /.linked-scss[^{]*\{color:#6b8e23/);
+ });
+ });
+
+ describe('JSX', () => {
+ it('.css', async () => {
+ const el = $('#react-css');
+ // 1. check HTML
+ assert.equal(el.attr('class'), 'react-title');
+ // 2. check CSS
+ assert.equal(bundledCSS.includes('.react-title'), true);
+ });
+
+ it('.module.css', async () => {
+ const el = $('#react-module-css');
+ const classes = el.attr('class').split(' ');
+ const moduleClass = classes.find((name) => /^_title_[\w-]+/.test(name));
+
+ // 1. check HTML
+ assert.equal(el.attr('class').includes(moduleClass), true);
+
+ // 2. check CSS
+ assert.match(bundledCSS, new RegExp(`.${moduleClass}[^{]*{font-family:fantasy`));
+ });
+
+ it('.sass', async () => {
+ const el = $('#react-sass');
+
+ // 1. check HTML
+ assert.equal(el.attr('class').includes('react-sass-title'), true);
+
+ // 2. check CSS
+ assert.match(bundledCSS, /.react-sass-title[^{]*\{font-family:fantasy/);
+ });
+
+ it('.scss', async () => {
+ const el = $('#react-scss');
+
+ // 1. check HTML
+ assert.equal(el.attr('class').includes('react-scss-title'), true);
+
+ // 2. check CSS
+ assert.match(bundledCSS, /.react-scss-title[^{]*\{font-family:fantasy/);
+ });
+
+ it('.module.sass', async () => {
+ const el = $('#react-module-sass');
+ const classes = el.attr('class').split(' ');
+ const moduleClass = classes.find((name) => /^_title_[\w-]+/.test(name));
+
+ // 1. check HTML
+ assert.equal(el.attr('class').includes(moduleClass), true);
+
+ // 2. check CSS
+ assert.match(bundledCSS, new RegExp(`.${moduleClass}[^{]*{font-family:fantasy`));
+ });
+
+ it('.module.scss', async () => {
+ const el = $('#react-module-scss');
+ const classes = el.attr('class').split(' ');
+ const moduleClass = classes.find((name) => /^_title_[\w-]+/.test(name));
+
+ // 1. check HTML
+ assert.equal(el.attr('class').includes(moduleClass), true);
+
+ // 2. check CSS
+ assert.match(bundledCSS, new RegExp(`.${moduleClass}[^{]*{font-family:fantasy`));
+ });
+
+ it('.module.css ordering', () => {
+ const globalStyleClassIndex = bundledCSS.indexOf('.module-ordering');
+ const moduleStyleClassIndex = bundledCSS.indexOf('._module_ordering');
+ // css module has higher priority than global style
+ assert.equal(globalStyleClassIndex > -1, true);
+ assert.equal(moduleStyleClassIndex > -1, true);
+ assert.equal(moduleStyleClassIndex > globalStyleClassIndex, true);
+ });
+ });
+
+ describe('Vue', () => {
+ it('<style>', async () => {
+ const el = $('#vue-css');
+
+ // 1. check HTML
+ assert.equal(el.attr('class').includes('vue-css'), true);
+
+ // 2. check CSS
+ assert.match(bundledCSS, /.vue-css[^{]*\{font-family:cursive/);
+ });
+
+ it('<style scoped>', async () => {
+ const el = $('#vue-scoped');
+
+ // find data-v-* attribute (how Vue CSS scoping works)
+ const { attribs } = el.get(0);
+ const scopeId = Object.keys(attribs).find((k) => k.startsWith('data-v-'));
+ assert.ok(scopeId);
+
+ // 1. check HTML
+ assert.equal(el.attr('class').includes('vue-scoped'), true);
+
+ // 2. check CSS
+ assert.equal(bundledCSS.includes(`.vue-scoped[${scopeId}]`), true);
+ });
+
+ it('<style module>', async () => {
+ const el = $('#vue-modules');
+ const classes = el.attr('class').split(' ');
+ const moduleClass = classes.find((name) => /^_vueModules_[\w-]+/.test(name));
+
+ // 1. check HTML
+ assert.equal(el.attr('class').includes(moduleClass), true);
+
+ // 2. check CSS
+ assert.match(bundledCSS, new RegExp(`.${moduleClass}[^{]*{font-family:cursive`));
+ });
+
+ it('<style lang="sass">', async () => {
+ const el = $('#vue-sass');
+
+ // 1. check HTML
+ assert.equal(el.attr('class').includes('vue-sass'), true);
+
+ // 2. check CSS
+ assert.match(bundledCSS, /.vue-sass[^{]*\{font-family:cursive/);
+ });
+
+ it('<style lang="scss">', async () => {
+ const el = $('#vue-scss');
+
+ // 1. check HTML
+ assert.equal(el.attr('class').includes('vue-scss'), true);
+
+ // 2. check CSS
+ assert.match(bundledCSS, /.vue-scss[^{]*\{font-family:cursive/);
+ });
+ });
+
+ describe('Svelte', () => {
+ it('<style>', async () => {
+ const el = $('#svelte-css');
+ const classes = el.attr('class').split(' ');
+ const scopedClass = classes.find(
+ (name) => name !== 'svelte-css' && /^svelte-[A-Za-z\d-]+/.test(name),
+ );
+
+ // 1. check HTML
+ assert.equal(el.attr('class').includes('svelte-css'), true);
+
+ // 2. check CSS
+ assert.match(
+ bundledCSS,
+ new RegExp(`.svelte-css.${scopedClass}[^{]*{font-family:ComicSansMS`),
+ );
+ });
+
+ it('<style lang="sass">', async () => {
+ const el = $('#svelte-sass');
+ const classes = el.attr('class').split(' ');
+ const scopedClass = classes.find(
+ (name) => name !== 'svelte-sass' && /^svelte-[A-Za-z\d-]+/.test(name),
+ );
+
+ // 1. check HTML
+ assert.equal(el.attr('class').includes('svelte-sass'), true);
+
+ // 2. check CSS
+ assert.match(
+ bundledCSS,
+ new RegExp(`.svelte-sass.${scopedClass}[^{]*{font-family:ComicSansMS`),
+ );
+ });
+
+ it('<style lang="scss">', async () => {
+ const el = $('#svelte-scss');
+ const classes = el.attr('class').split(' ');
+ const scopedClass = classes.find(
+ (name) => name !== 'svelte-scss' && /^svelte-[A-Za-z\d-]+/.test(name),
+ );
+
+ // 1. check HTML
+ assert.equal(el.attr('class').includes('svelte-scss'), true);
+
+ // 2. check CSS
+ assert.match(
+ bundledCSS,
+ new RegExp(`.svelte-scss.${scopedClass}[^{]*{font-family:ComicSansMS`),
+ );
+ });
+
+ it('client:only and SSR in two pages, both should have styles', async () => {
+ const onlyHtml = await fixture.readFile('/client-only-and-ssr/only/index.html');
+ const $onlyHtml = cheerio.load(onlyHtml);
+ const onlyHtmlCssHref = $onlyHtml('link[rel=stylesheet][href^=/_astro/]').attr('href');
+ const onlyHtmlCss = await fixture.readFile(onlyHtmlCssHref.replace(/^\/?/, '/'));
+
+ const ssrHtml = await fixture.readFile('/client-only-and-ssr/ssr/index.html');
+ const $ssrHtml = cheerio.load(ssrHtml);
+ const ssrHtmlCssHref = $ssrHtml('link[rel=stylesheet][href^=/_astro/]').attr('href');
+ const ssrHtmlCss = await fixture.readFile(ssrHtmlCssHref.replace(/^\/?/, '/'));
+
+ assert.equal(onlyHtmlCss.includes('.svelte-only-and-ssr'), true);
+ assert.equal(ssrHtmlCss.includes('.svelte-only-and-ssr'), true);
+ });
+ });
+
+ describe('Vite features', () => {
+ it('.css?raw return a string', () => {
+ const el = $('#css-raw');
+ assert.equal(el.text(), '.foo {color: red;}');
+ });
+ });
+ });
+
+ // with "build" handling CSS checking, the dev tests are mostly testing the paths resolve in dev
+ describe('dev', () => {
+ let devServer;
+ let $;
+
+ before(async () => {
+ devServer = await fixture.startDevServer();
+ const html = await fixture.fetch('/').then((res) => res.text());
+ $ = cheerio.load(html);
+ });
+
+ after(async () => {
+ await devServer.stop();
+ });
+
+ it('resolves CSS in public/', async () => {
+ const href = $('link[href="/global.css"]').attr('href');
+ assert.equal((await fixture.fetch(href)).status, 200);
+ });
+
+ // Skipped until upstream fix lands
+ // Our fix: https://github.com/withastro/astro/pull/2106
+ // OG Vite PR: https://github.com/vitejs/vite/pull/5940
+ // Next Vite PR: https://github.com/vitejs/vite/pull/5796
+ it.skip('resolved imported CSS with ?url', async () => {
+ const href = $('link[href$="imported-url.css"]').attr('href');
+ assert.ok(href);
+ assert.equal((await fixture.fetch(href)).status, 200);
+ });
+
+ it('resolves ESM style imports', async () => {
+ const allInjectedStyles = $('style').text().replace(/\s*/g, '');
+
+ assert.equal(allInjectedStyles.includes('.imported{'), true, 'styles/imported-url.css');
+ assert.equal(allInjectedStyles.includes('.imported-sass{'), true, 'styles/imported-url.sass');
+ assert.equal(allInjectedStyles.includes('.imported-scss{'), true, 'styles/imported-url.scss');
+ });
+
+ it('resolves Astro styles', async () => {
+ const allInjectedStyles = $('style').text();
+
+ assert.equal(allInjectedStyles.includes('.linked-css[data-astro-cid-'), true);
+ assert.equal(allInjectedStyles.includes('.linked-sass[data-astro-cid-'), true);
+ assert.equal(allInjectedStyles.includes('.linked-scss[data-astro-cid-'), true);
+ assert.equal(allInjectedStyles.includes('.wrapper[data-astro-cid-'), true);
+ });
+
+ it('resolves Styles from React', async () => {
+ const styles = [
+ 'ReactModules.module.css',
+ 'ReactModules.module.scss',
+ 'ReactModules.module.sass',
+ ];
+ for (const style of styles) {
+ const href = $(`style[data-vite-dev-id$="${style}"]`).attr('data-vite-dev-id');
+ assert.equal((await fixture.fetch(href)).status, 200);
+ }
+
+ const allInjectedStyles = $('style').text().replace(/\s*/g, '');
+
+ assert.equal(allInjectedStyles.includes('.react-title{'), true);
+ assert.equal(allInjectedStyles.includes('.react-sass-title{'), true);
+ assert.equal(allInjectedStyles.includes('.react-scss-title{'), true);
+ });
+
+ it('resolves CSS from Svelte', async () => {
+ const allInjectedStyles = $('style').text();
+
+ assert.equal(allInjectedStyles.includes('.svelte-css'), true);
+ assert.equal(allInjectedStyles.includes('.svelte-sass'), true);
+ assert.equal(allInjectedStyles.includes('.svelte-scss'), true);
+ });
+
+ it('resolves CSS from Vue', async () => {
+ const allInjectedStyles = $('style').text().replace(/\s*/g, '');
+
+ assert.equal(allInjectedStyles.includes('.vue-css{'), true);
+ assert.equal(allInjectedStyles.includes('.vue-sass{'), true);
+ assert.equal(allInjectedStyles.includes('.vue-scss{'), true);
+ assert.equal(allInjectedStyles.includes('.vue-scoped[data-v-'), true);
+ assert.equal(allInjectedStyles.includes('._vueModules_'), true);
+ });
+
+ it('remove unused styles from client:load components', async () => {
+ const bundledAssets = await fixture.readdir('./_astro');
+ // SvelteDynamic styles is already included in the main page css asset
+ const unusedCssAsset = bundledAssets.find((asset) => /SvelteDynamic\..*\.css/.test(asset));
+ assert.equal(unusedCssAsset, undefined, 'Found unused style ' + unusedCssAsset);
+
+ let foundVitePreloadCSS = false;
+ const bundledJS = await fixture.glob('**/*.?(m)js');
+ for (const filename of bundledJS) {
+ const content = await fixture.readFile(filename);
+ if (content.match(/ReactDynamic\..*\.css/)) {
+ foundVitePreloadCSS = filename;
+ }
+ }
+ assert.equal(
+ foundVitePreloadCSS,
+ false,
+ 'Should not have found a preload for the dynamic CSS',
+ );
+ });
+
+ it('.module.css ordering', () => {
+ const globalStyleTag = $('style[data-vite-dev-id$="default.css"]');
+ const moduleStyleTag = $('style[data-vite-dev-id$="ModuleOrdering.module.css"]');
+ const globalStyleClassIndex = globalStyleTag.index();
+ const moduleStyleClassIndex = moduleStyleTag.index();
+ // css module has higher priority than global style
+ assert.equal(globalStyleClassIndex > -1, true);
+ assert.equal(moduleStyleClassIndex > -1, true);
+ assert.equal(moduleStyleClassIndex > globalStyleClassIndex, true);
+ });
+
+ it('.css?raw return a string', () => {
+ const el = $('#css-raw');
+ assert.equal(el.text(), '.foo {color: red;}');
+ });
+ });
+});