From f013971c525810848624ccf7675c4508f908c7c2 Mon Sep 17 00:00:00 2001 From: Ed Rivas Date: Thu, 18 Jan 2024 02:48:18 +0000 Subject: [PATCH] [Shared Resources] Add JS API tests (#1954) Co-authored-by: Jonny Gerig Meyer Co-authored-by: James Stuckey Weber --- js-api-spec/compiler.node.test.ts | 140 +++++++++++++++++++++ js-api-spec/compiler.test.ts | 201 ++++++++++++++++++++++++++++++ 2 files changed, 341 insertions(+) create mode 100644 js-api-spec/compiler.node.test.ts create mode 100644 js-api-spec/compiler.test.ts diff --git a/js-api-spec/compiler.node.test.ts b/js-api-spec/compiler.node.test.ts new file mode 100644 index 000000000..8af5c13b8 --- /dev/null +++ b/js-api-spec/compiler.node.test.ts @@ -0,0 +1,140 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import type {AsyncCompiler, Compiler, CompileResult, Importer} from 'sass'; +import {initAsyncCompiler, initCompiler} from 'sass'; + +import { + asyncImporters, + functions, + getLogger, + getTriggeredImporter, + importers, +} from './compiler.test'; +import {sandbox} from './sandbox'; +import {URL} from './utils'; + +describe('Compiler', () => { + let compiler: Compiler; + + beforeEach(() => { + compiler = initCompiler(); + }); + + afterEach(() => { + compiler.dispose(); + }); + + describe('compile', () => { + it('performs complete compilations', () => + sandbox(dir => { + const logger = getLogger(); + dir.write({'input.scss': '@import "bar"; .fn {value: foo(bar)}'}); + const result = compiler.compile(dir('input.scss'), { + importers, + functions, + logger, + }); + expect(result.css).toEqualIgnoringWhitespace( + '.import {value: bar;} .fn {value: "bar";}' + ); + expect(logger.debug).toHaveBeenCalledTimes(1); + })); + + it('performs compilations in callbacks', () => + sandbox(dir => { + dir.write({'input-nested.scss': 'x {y: z}'}); + const nestedImporter: Importer = { + canonicalize: () => new URL('foo:bar'), + load: () => ({ + contents: compiler.compile(dir('input-nested.scss')).css, + syntax: 'scss', + }), + }; + dir.write({'input.scss': '@import "nested"; a {b: c}'}); + const result = compiler.compile(dir('input.scss'), { + importers: [nestedImporter], + }); + expect(result.css).toEqualIgnoringWhitespace('x {y: z;} a {b: c;}'); + })); + + it('throws after being disposed', () => + sandbox(dir => { + dir.write({'input.scss': '$a: b; c {d: $a}'}); + compiler.dispose(); + expect(() => compiler.compile(dir('input.scss'))).toThrowError(); + })); + }); +}); + +describe('AsyncCompiler', () => { + let compiler: AsyncCompiler; + + beforeEach(async () => { + compiler = await initAsyncCompiler(); + }); + + afterEach(async () => { + await compiler.dispose(); + }); + + describe('compileAsync', () => { + it( + 'handles multiple concurrent compilations', + () => + sandbox(async dir => { + const runs = 1000; // Number of concurrent compilations to run + const logger = getLogger(); + const compilations = Array(runs) + .fill(0) + .map((_, i) => { + const filename = `input-${i}.scss`; + dir.write({ + [filename]: `@import "${i}"; .fn {value: foo(${i})}`, + }); + return compiler.compileAsync(dir(filename), { + importers: asyncImporters, + functions, + logger, + }); + }); + Array.from(await Promise.all(compilations)) + .map((result: CompileResult) => result.css) + .forEach((result, i) => { + expect(result).toEqualIgnoringWhitespace( + `.import {value: ${i};} .fn {value: "${i}";}` + ); + }); + expect(logger.debug).toHaveBeenCalledTimes(runs); + }), + 40_000 // Increase timeout for slow CI + ); + + it('throws after being disposed', () => + sandbox(async dir => { + dir.write({'input.scss': '$a: b; c {d: $a}'}); + await compiler.dispose(); + expect(() => compiler.compileAsync(dir('input.scss'))).toThrowError(); + })); + + it('waits for compilations to finish before disposing', () => + sandbox(async dir => { + let completed = false; + dir.write({'input.scss': '@import "slow"'}); + const {importer, triggerComplete} = getTriggeredImporter( + () => (completed = true) + ); + const compilation = compiler.compileAsync(dir('input.scss'), { + importers: [importer], + }); + const disposalPromise = compiler.dispose(); + expect(completed).toBeFalse(); + triggerComplete(); + + await disposalPromise; + expect(completed).toBeTrue(); + await expectAsync(compilation).toBeResolved(); + })); + }); +}); diff --git a/js-api-spec/compiler.test.ts b/js-api-spec/compiler.test.ts new file mode 100644 index 000000000..065f3e7e4 --- /dev/null +++ b/js-api-spec/compiler.test.ts @@ -0,0 +1,201 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import type {CompileResult, Importer} from 'sass'; +import { + initAsyncCompiler, + initCompiler, + SassString, + AsyncCompiler, + Compiler, +} from 'sass'; + +import {spy, URL} from './utils'; + +export const functions = { + 'foo($args)': (args: unknown) => new SassString(`${args}`), +}; + +export const importers: Array = [ + { + canonicalize: url => new URL(`u:${url}`), + load: url => ({ + contents: `.import {value: ${url.pathname}} @debug "imported";`, + syntax: 'scss' as const, + }), + }, +]; + +export const asyncImporters: Array = [ + { + canonicalize: url => Promise.resolve(new URL(`u:${url}`)), + load: url => Promise.resolve(importers[0].load(url)), + }, +]; + +export const getLogger = () => ({debug: spy(() => {})}); + +/* A triggered importer that executes a callback after a trigger is called */ +export function getTriggeredImporter(callback: () => void): { + importer: Importer; + triggerComplete: () => void; +} { + let promiseResolve: (value: unknown) => void; + const awaitedPromise = new Promise(resolve => { + promiseResolve = resolve; + }); + return { + importer: { + canonicalize: async () => new URL('foo:bar'), + load: async () => { + await awaitedPromise; + callback(); + return {contents: '', syntax: 'scss' as const}; + }, + }, + triggerComplete: () => promiseResolve(undefined), + }; +} + +describe('Compiler', () => { + let compiler: Compiler; + + beforeEach(() => { + compiler = initCompiler(); + }); + + afterEach(() => { + compiler.dispose(); + }); + + describe('compileString', () => { + it('performs complete compilations', () => { + const logger = getLogger(); + const result = compiler.compileString( + '@import "bar"; .fn {value: foo(baz)}', + {importers, functions, logger} + ); + expect(result.css).toEqualIgnoringWhitespace( + '.import {value: bar;} .fn {value: "baz";}' + ); + expect(logger.debug).toHaveBeenCalledTimes(1); + }); + + it('performs compilations in callbacks', () => { + const nestedImporter: Importer = { + canonicalize: () => new URL('foo:bar'), + load: () => ({ + contents: compiler.compileString('x {y: z}').css, + syntax: 'scss', + }), + }; + const result = compiler.compileString('@import "nested"; a {b: c}', { + importers: [nestedImporter], + }); + expect(result.css).toEqualIgnoringWhitespace('x {y: z;} a {b: c;}'); + }); + + it('throws after being disposed', () => { + compiler.dispose(); + expect(() => compiler.compileString('$a: b; c {d: $a}')).toThrowError(); + }); + + it('succeeds after a compilation failure', () => { + expect(() => compiler.compileString('a')).toThrowSassException({ + includes: 'expected "{"', + }); + const result2 = compiler.compileString('x {y: z}'); + expect(result2.css).toEqualIgnoringWhitespace('x {y: z;}'); + }); + }); + + it('errors if constructor invoked directly', () => { + // Strip types to allow calling private constructor. + class Untyped {} + const UntypedCompiler = Compiler as unknown as typeof Untyped; + expect(() => new UntypedCompiler()).toThrowError( + /Compiler can not be directly constructed/ + ); + }); +}); + +describe('AsyncCompiler', () => { + let compiler: AsyncCompiler; + const runs = 1000; // Number of concurrent compilations to run + + beforeEach(async () => { + compiler = await initAsyncCompiler(); + }); + + afterEach(async () => { + await compiler.dispose(); + }); + + describe('compileStringAsync', () => { + it('handles multiple concurrent compilations', async () => { + const logger = getLogger(); + const compilations = Array(runs) + .fill(0) + .map((_, i) => + compiler.compileStringAsync( + `@import "${i}"; .fn {value: foo(${i})}`, + {importers: asyncImporters, functions, logger} + ) + ); + Array.from(await Promise.all(compilations)) + .map((result: CompileResult) => result.css) + .forEach((result, i) => { + expect(result).toEqualIgnoringWhitespace( + `.import {value: ${i};} .fn {value: "${i}";}` + ); + }); + expect(logger.debug).toHaveBeenCalledTimes(runs); + }, 15_000); // Increase timeout for slow CI + + it('throws after being disposed', async () => { + await compiler.dispose(); + expect(() => + compiler.compileStringAsync('$a: b; c {d: $a}') + ).toThrowError(); + }); + + it('waits for compilations to finish before disposing', async () => { + let completed = false; + const {importer, triggerComplete} = getTriggeredImporter( + () => (completed = true) + ); + const compilation = compiler.compileStringAsync('@import "slow"', { + importers: [importer], + }); + + const disposalPromise = compiler.dispose(); + expect(completed).toBeFalse(); + triggerComplete(); + + await disposalPromise; + expect(completed).toBeTrue(); + await expectAsync(compilation).toBeResolved(); + }); + + it('succeeds after a compilation failure', async () => { + expectAsync( + async () => await compiler.compileStringAsync('a') + ).toThrowSassException({ + includes: 'expected "{"', + }); + + const result2 = await compiler.compileStringAsync('x {y: z}'); + expect(result2.css).toEqualIgnoringWhitespace('x {y: z;}'); + }); + }); + + it('errors if constructor invoked directly', () => { + // Strip types to allow calling private constructor. + class Untyped {} + const UntypedAsyncCompiler = AsyncCompiler as unknown as typeof Untyped; + expect(() => new UntypedAsyncCompiler()).toThrowError( + /AsyncCompiler can not be directly constructed/ + ); + }); +});