Migrate web builds from Vue CLI to Vite

This commit changes the web application's build, transpilation and
minification process from Vue CLI to Vite. This shift paves the way for
a full migration to Vite as the primary build tool (#230).

Configuration changes:

- `.vscode/extensions.json`: Update recommended plugins, replacing
  unmaintained ones with official recommendations.
- Legacy browser support:
  - Use `@vitejs/plugin-legacy` to transpile for older browsers.
  - Remove `core-js` dependency and `babel.config.cjs` configuration as
    they're now handled by the legacy plugin.
  - Delete `@babel/preset-typescript` and `@babel/preset-typescript`
    dependencies as legacy plugin handles babel dependencies by default.
  - Add `terser` dependency that's used by the legacy plugin for
    minification, as per Vite's official documentation.
- `tsconfig.json`:
  - Remove obsolete `webpack-env` types.
  - Add `"resolveJsonModule": true` to be able to read JSON files in
    right way.
  - Use correct casing as configuration values.
  - Simplify `lib` to align with Vite and Vue starter configuration.
  - Add `"skipLibCheck": true` as `npm run build` now runs `tsc` which
    fails on inconsistent typings inside `node_modules` due to npm's
    weak dependency resoultion.
- PostCSS:
  - Add `autoprefixer` as dependency, no longer installed by Vue CLI.
  - Epxlicitly added `postcss` as dependency to anticipate potential
    peer dependency changes.
- Remove related `@vue/cli` dependencies.
- Remove `sass-loader` as Vite has native CSS preprocessing support.
- Run integration tests with `jsdom` environment so `window` object can
  be used.

Client-side changes:

- Abstract build tool specific environment variable population.
  Environment variables were previously populated by Vue CLI and now by
  Vite but not having an abstraction caused issues. This abstraction
  solves build errors and allows easier future migrations and testing.
- Change Vue CLI-specific `~@` aliases to `@` to be able to compile with
  Vite.
- Update types in LiquorTree to satisfy `tsc`.
- Remove Vue CLI-specific workaround from `src/presentation/main.ts`.

Restructuring:

- Move `public/` to `presentation/` to align with the layered structure,
  which was not possible with Vue CLI.
- Move `index.html` to web root instead of having it inside `public/` to
  align with official recommended structure.
- Move logic shared by both integration and unit tests to
  `tests/shared`.
- Move logo creation script to `scripts/` and its npm command to include
  `build` to align with rest of the structure.
This commit is contained in:
undergroundwires
2023-08-23 23:12:56 +02:00
parent 6e40edd3f8
commit 736590558b
52 changed files with 1870 additions and 2286 deletions

View File

@@ -1,8 +1,9 @@
/* eslint-disable max-classes-per-file */
import { describe, it, expect } from 'vitest';
import type { CollectionData } from '@/application/collections/';
import { VueAppEnvironment, parseProjectInformation } from '@/application/Parser/ProjectInformationParser';
import { parseProjectInformation } from '@/application/Parser/ProjectInformationParser';
import { CategoryCollectionParserType, parseApplication } from '@/application/Parser/ApplicationParser';
import { IAppMetadata } from '@/infrastructure/Metadata/IAppMetadata';
import WindowsData from '@/application/collections/windows.yaml';
import MacOsData from '@/application/collections/macos.yaml';
import LinuxData from '@/application/collections/linux.yaml';
@@ -12,9 +13,9 @@ import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { getEnumValues } from '@/application/Common/Enum';
import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub';
import { getProcessEnvironmentStub } from '@tests/unit/shared/Stubs/ProcessEnvironmentStub';
import { CollectionDataStub } from '@tests/unit/shared/Stubs/CollectionDataStub';
import { getAbsentCollectionTestCases, AbsentObjectTestCases } from '@tests/unit/shared/TestCases/AbsentTests';
import { AppMetadataStub } from '@tests/unit/shared/Stubs/AppMetadataStub';
describe('ApplicationParser', () => {
describe('parseApplication', () => {
@@ -41,7 +42,7 @@ describe('ApplicationParser', () => {
describe('processEnv', () => {
it('used to parse expected project information', () => {
// arrange
const env = getProcessEnvironmentStub();
const env = new AppMetadataStub();
const expected = parseProjectInformation(env);
const parserSpy = new CategoryCollectionParserSpy();
const parserMock = parserSpy.mockParser();
@@ -138,7 +139,7 @@ class ApplicationParserBuilder {
private categoryCollectionParser: CategoryCollectionParserType = new CategoryCollectionParserSpy()
.mockParser();
private environment: VueAppEnvironment = getProcessEnvironmentStub();
private environment: IAppMetadata = new AppMetadataStub();
private collectionsData: CollectionData[] = [new CollectionDataStub()];
@@ -150,7 +151,7 @@ class ApplicationParserBuilder {
}
public withEnvironment(
environment: VueAppEnvironment,
environment: IAppMetadata,
): this {
this.environment = environment;
return this;

View File

@@ -1,59 +1,59 @@
import { describe, it, expect } from 'vitest';
import { VueAppEnvironmentKeys, parseProjectInformation } from '@/application/Parser/ProjectInformationParser';
import { getProcessEnvironmentStub } from '@tests/unit/shared/Stubs/ProcessEnvironmentStub';
import { parseProjectInformation } from '@/application/Parser/ProjectInformationParser';
import { IProjectInformation } from '@/domain/IProjectInformation';
import { AppMetadataStub } from '@tests/unit/shared/Stubs/AppMetadataStub';
describe('ProjectInformationParser', () => {
describe('parseProjectInformation', () => {
interface IEnvironmentParsingTestCase {
readonly testCaseName: string;
readonly environmentVariableName: string;
readonly environmentVariableValue: string;
readonly setMetadata: (appMetadataStub: AppMetadataStub, value: string) => AppMetadataStub;
readonly expectedValue: string;
readonly getActualValue: (info: IProjectInformation) => string;
}
const testCases: readonly IEnvironmentParsingTestCase[] = [
{
testCaseName: 'version',
environmentVariableName: VueAppEnvironmentKeys.VUE_APP_VERSION,
environmentVariableValue: '0.11.3',
setMetadata: (metadata, value) => metadata.withVersion(value),
expectedValue: '0.11.3',
getActualValue: (info) => info.version.toString(),
},
{
testCaseName: 'name',
environmentVariableName: VueAppEnvironmentKeys.VUE_APP_NAME,
environmentVariableValue: 'expected-app-name',
setMetadata: (metadata, value) => metadata.witName(value),
expectedValue: 'expected-app-name',
getActualValue: (info) => info.name,
},
{
testCaseName: 'homepage',
environmentVariableName: VueAppEnvironmentKeys.VUE_APP_HOMEPAGE_URL,
environmentVariableValue: 'https://expected.sexy',
setMetadata: (metadata, value) => metadata.withHomepageUrl(value),
expectedValue: 'https://expected.sexy',
getActualValue: (info) => info.homepage,
},
{
testCaseName: 'repositoryUrl',
environmentVariableName: VueAppEnvironmentKeys.VUE_APP_REPOSITORY_URL,
environmentVariableValue: 'https://expected-repository.url',
setMetadata: (metadata, value) => metadata.withRepositoryUrl(value),
expectedValue: 'https://expected-repository.url',
getActualValue: (info) => info.repositoryUrl,
},
{
testCaseName: 'slogan',
environmentVariableName: VueAppEnvironmentKeys.VUE_APP_SLOGAN,
environmentVariableValue: 'expected-slogan',
setMetadata: (metadata, value) => metadata.withSlogan(value),
expectedValue: 'expected-slogan',
getActualValue: (info) => info.slogan,
},
];
for (const testCase of testCases) {
it(`${testCase.testCaseName}`, () => {
for (const {
expectedValue, testCaseName, setMetadata, getActualValue,
} of testCases) {
it(testCaseName, () => {
// act
const expected = testCase.environmentVariableValue;
const env = getProcessEnvironmentStub();
env[testCase.environmentVariableName] = testCase.environmentVariableValue;
const metadata = setMetadata(new AppMetadataStub(), expectedValue);
// act
const info = parseProjectInformation(env);
const info = parseProjectInformation(metadata);
// assert
const actual = testCase.getActualValue(info);
expect(actual).to.be.equal(expected);
const actual = getActualValue(info);
expect(actual).to.be.equal(expectedValue);
});
}
});

View File

@@ -4,11 +4,12 @@ import { OperatingSystem } from '@/domain/OperatingSystem';
import { EnumRangeTestRunner } from '@tests/unit/application/Common/EnumRangeTestRunner';
import { VersionStub } from '@tests/unit/shared/Stubs/VersionStub';
import { Version } from '@/domain/Version';
import { PropertyKeys } from '@tests/shared/TypeHelpers';
describe('ProjectInformation', () => {
describe('retrieval of property values', () => {
interface IPropertyTestCase {
readonly testCaseName: string;
interface IInformationParsingTestCase {
readonly description?: string;
readonly expectedValue: string;
readonly buildWithExpectedValue: (
builder: ProjectInformationBuilder,
@@ -16,81 +17,105 @@ describe('ProjectInformation', () => {
) => ProjectInformationBuilder;
readonly getActualValue: (sut: ProjectInformation) => string;
}
const propertyTestCases: readonly IPropertyTestCase[] = [
{
testCaseName: 'name',
expectedValue: 'expected-name',
const propertyTestCases: {
readonly [K in PropertyKeys<ProjectInformation>]: readonly IInformationParsingTestCase[];
} = {
name: [{
expectedValue: 'expected-app-name',
buildWithExpectedValue: (builder, expected) => builder
.withName(expected),
getActualValue: (sut) => sut.name,
},
{
testCaseName: 'version',
}],
version: [{
expectedValue: '0.11.3',
buildWithExpectedValue: (builder, expected) => builder
.withVersion(new VersionStub(expected)),
getActualValue: (sut) => sut.version.toString(),
},
{
testCaseName: 'repositoryWebUrl - not ending with .git',
expectedValue: 'expected-repository-url',
buildWithExpectedValue: (builder, expected) => builder
.withRepositoryUrl(expected),
getActualValue: (sut) => sut.repositoryWebUrl,
},
{
testCaseName: 'repositoryWebUrl - ending with .git',
expectedValue: 'expected-repository-url',
buildWithExpectedValue: (builder, expected) => builder
.withRepositoryUrl(`${expected}.git`),
getActualValue: (sut) => sut.repositoryWebUrl,
},
{
testCaseName: 'slogan',
}],
slogan: [{
expectedValue: 'expected-slogan',
buildWithExpectedValue: (builder, expected) => builder
.withSlogan(expected),
getActualValue: (sut) => sut.slogan,
},
{
testCaseName: 'homepage',
}],
repositoryUrl: [{
description: 'without `.git` suffix',
expectedValue: 'expected-repository-url',
buildWithExpectedValue: (builder, expected) => builder
.withRepositoryUrl(expected),
getActualValue: (sut) => sut.repositoryUrl,
}, {
description: 'with `.git` suffix',
expectedValue: 'expected-repository-url',
buildWithExpectedValue: (builder, expected) => builder
.withRepositoryUrl(expected),
getActualValue: (sut) => sut.repositoryUrl,
}],
repositoryWebUrl: [{
description: 'without `.git` suffix',
expectedValue: 'expected-repository-url',
buildWithExpectedValue: (builder, expected) => builder
.withRepositoryUrl(expected),
getActualValue: (sut) => sut.repositoryWebUrl,
}, {
description: 'with `.git` suffix',
expectedValue: 'expected-repository-url',
buildWithExpectedValue: (builder, expected) => builder
.withRepositoryUrl(`${expected}.git`),
getActualValue: (sut) => sut.repositoryWebUrl,
}],
homepage: [{
expectedValue: 'expected-homepage',
buildWithExpectedValue: (builder, expected) => builder
.withHomepage(expected),
getActualValue: (sut) => sut.homepage,
},
{
testCaseName: 'feedbackUrl',
}],
feedbackUrl: [{
description: 'without `.git` suffix',
expectedValue: 'https://github.com/undergroundwires/privacy.sexy/issues',
buildWithExpectedValue: (builder) => builder
.withRepositoryUrl('https://github.com/undergroundwires/privacy.sexy'),
getActualValue: (sut) => sut.feedbackUrl,
}, {
description: 'with `.git` suffix',
expectedValue: 'https://github.com/undergroundwires/privacy.sexy/issues',
buildWithExpectedValue: (builder) => builder
.withRepositoryUrl('https://github.com/undergroundwires/privacy.sexy.git'),
getActualValue: (sut) => sut.feedbackUrl,
},
{
testCaseName: 'releaseUrl',
}],
releaseUrl: [{
description: 'without `.git` suffix',
expectedValue: 'https://github.com/undergroundwires/privacy.sexy/releases/tag/0.7.2',
buildWithExpectedValue: (builder) => builder
.withRepositoryUrl('https://github.com/undergroundwires/privacy.sexy')
.withVersion(new VersionStub('0.7.2')),
getActualValue: (sut) => sut.releaseUrl,
}, {
description: 'with `.git` suffix',
expectedValue: 'https://github.com/undergroundwires/privacy.sexy/releases/tag/0.7.2',
buildWithExpectedValue: (builder) => builder
.withRepositoryUrl('https://github.com/undergroundwires/privacy.sexy.git')
.withVersion(new VersionStub('0.7.2')),
getActualValue: (sut) => sut.releaseUrl,
},
];
for (const testCase of propertyTestCases) {
it(`should return the expected ${testCase.testCaseName} value`, () => {
// arrange
const expected = testCase.expectedValue;
const builder = new ProjectInformationBuilder();
const sut = testCase
.buildWithExpectedValue(builder, expected)
.build();
}],
};
Object.entries(propertyTestCases).forEach(([propertyName, testList]) => {
testList.forEach(({
description, buildWithExpectedValue, expectedValue, getActualValue,
}) => {
it(`${propertyName}${description ? ` (${description})` : ''}`, () => {
// arrange
const builder = new ProjectInformationBuilder();
const sut = buildWithExpectedValue(builder, expectedValue).build();
// act
const actual = testCase.getActualValue(sut);
// act
const actual = getActualValue(sut);
// assert
expect(actual).to.equal(expected);
// assert
expect(actual).to.equal(expectedValue);
});
});
}
});
});
describe('correct retrieval of download URL per operating system', () => {
const testCases: ReadonlyArray<{
@@ -128,7 +153,7 @@ describe('ProjectInformation', () => {
.withVersion(new VersionStub(version))
.withRepositoryUrl(repositoryUrl)
.build();
// act
// act
const actual = sut.getDownloadUrl(os);
// assert
expect(actual).to.equal(expected);

View File

@@ -0,0 +1,65 @@
import {
describe, beforeEach, afterEach, expect,
} from 'vitest';
import { ViteAppMetadata } from '@/infrastructure/Metadata/Vite/ViteAppMetadata';
import { VITE_ENVIRONMENT_KEYS } from '@/infrastructure/Metadata/Vite/ViteEnvironmentKeys';
import { PropertyKeys } from '@tests/shared/TypeHelpers';
describe('ViteAppMetadata', () => {
describe('reads values from import.meta.env', () => {
let originalMetaEnv;
beforeEach(() => {
originalMetaEnv = { ...import.meta.env };
});
afterEach(() => {
Object.assign(import.meta.env, originalMetaEnv);
});
interface ITestCase {
readonly getActualValue: (sut: ViteAppMetadata) => string;
readonly environmentVariable: typeof VITE_ENVIRONMENT_KEYS[
keyof typeof VITE_ENVIRONMENT_KEYS];
readonly expected: string;
}
const testCases: { [K in PropertyKeys<ViteAppMetadata>]: ITestCase } = {
name: {
environmentVariable: VITE_ENVIRONMENT_KEYS.NAME,
expected: 'expected-name',
getActualValue: (sut) => sut.name,
},
version: {
environmentVariable: VITE_ENVIRONMENT_KEYS.VERSION,
expected: 'expected-version',
getActualValue: (sut) => sut.version,
},
repositoryUrl: {
environmentVariable: VITE_ENVIRONMENT_KEYS.REPOSITORY_URL,
expected: 'expected-slogan',
getActualValue: (sut) => sut.repositoryUrl,
},
slogan: {
environmentVariable: VITE_ENVIRONMENT_KEYS.SLOGAN,
expected: 'expected-repositoryUrl',
getActualValue: (sut) => sut.slogan,
},
homepageUrl: {
environmentVariable: VITE_ENVIRONMENT_KEYS.HOMEPAGE_URL,
expected: 'expected-homepageUrl',
getActualValue: (sut) => sut.homepageUrl,
},
};
Object.values(testCases).forEach(({ environmentVariable, expected, getActualValue }) => {
it(`should correctly get the value of ${environmentVariable}`, () => {
// arrange
import.meta.env[environmentVariable] = expected;
// act
const sut = new ViteAppMetadata();
const actualValue = getActualValue(sut);
// assert
expect(actualValue).toBe(expected);
});
});
});
});

View File

@@ -0,0 +1,13 @@
import { expect, describe, it } from 'vitest';
import { VITE_ENVIRONMENT_KEYS } from '@/infrastructure/Metadata/Vite/ViteEnvironmentKeys';
describe('VITE_ENVIRONMENT_KEYS', () => {
describe('each key should have a non-empty string', () => {
Object.entries(VITE_ENVIRONMENT_KEYS).forEach(([key, value]) => {
it(`The key ${key} should have a non-empty string value`, () => {
expect(typeof value).toBe('string');
expect(value.length).toBeGreaterThan(0);
});
});
});
});

View File

@@ -0,0 +1,38 @@
import { IAppMetadata } from '@/infrastructure/Metadata/IAppMetadata';
export class AppMetadataStub implements IAppMetadata {
public version = '0.12.2';
public name = 'stub-name';
public slogan = 'stub-slogan';
public repositoryUrl = 'stub-repository-url';
public homepageUrl = 'stub-homepage-url';
public withVersion(version: string): this {
this.version = version;
return this;
}
public witName(name: string): this {
this.name = name;
return this;
}
public withSlogan(slogan: string): this {
this.slogan = slogan;
return this;
}
public withRepositoryUrl(repositoryUrl: string): this {
this.repositoryUrl = repositoryUrl;
return this;
}
public withHomepageUrl(homepageUrl: string): this {
this.homepageUrl = homepageUrl;
return this;
}
}

View File

@@ -1,7 +1,7 @@
import { expect } from 'vitest';
import { Constructible } from '@tests/shared/TypeHelpers';
import { ICodeValidationRule } from '@/application/Parser/Script/Validation/ICodeValidationRule';
import { ICodeValidator } from '@/application/Parser/Script/Validation/ICodeValidator';
import { Type } from '../Type';
export class CodeValidatorStub implements ICodeValidator {
public callHistory = new Array<{
@@ -21,7 +21,7 @@ export class CodeValidatorStub implements ICodeValidator {
public assertHistory(expected: {
validatedCodes: readonly string[],
rules: readonly Type<ICodeValidationRule>[],
rules: readonly Constructible<ICodeValidationRule>[],
}) {
expect(this.callHistory).to.have.lengthOf(expected.validatedCodes.length);
const actualValidatedCodes = this.callHistory.map((args) => args.code);

View File

@@ -1,11 +0,0 @@
import { VueAppEnvironment } from '@/application/Parser/ProjectInformationParser';
export function getProcessEnvironmentStub(): VueAppEnvironment {
return {
VUE_APP_VERSION: '0.11.3',
VUE_APP_NAME: 'stub-name',
VUE_APP_SLOGAN: 'stub-slogan',
VUE_APP_REPOSITORY_URL: 'stub-repository-url',
VUE_APP_HOMEPAGE_URL: 'stub-homepage-url',
};
}

View File

@@ -1,9 +1,9 @@
import { it, expect } from 'vitest';
import { Type } from '../Type';
import { Constructible } from '@tests/shared/TypeHelpers';
interface ISingletonTestData<T> {
getter: () => T;
expectedType: Type<T>;
expectedType: Constructible<T>;
}
export function itIsSingleton<T>(test: ISingletonTestData<T>): void {

View File

@@ -1,5 +0,0 @@
// eslint-disable-next-line @typescript-eslint/ban-types
export type Type<T, TArgs extends unknown[] = never> = Function & {
prototype: T,
apply: (this: unknown, args: TArgs) => void
};