@toolstackhq/create-qa-patterns 1.0.5 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -2
- package/index.js +160 -54
- package/package.json +1 -1
- package/templates/cypress-template/.env.example +5 -0
- package/templates/cypress-template/.github/workflows/cypress-tests.yml +47 -0
- package/templates/cypress-template/README.md +195 -0
- package/templates/cypress-template/config/environments.ts +37 -0
- package/templates/cypress-template/config/runtime-config.ts +46 -0
- package/templates/cypress-template/config/secret-manager.ts +19 -0
- package/templates/cypress-template/config/test-env.ts +13 -0
- package/templates/cypress-template/cypress/e2e/ui-journey.cy.ts +20 -0
- package/templates/cypress-template/cypress/support/app-config.ts +23 -0
- package/templates/cypress-template/cypress/support/commands.d.ts +15 -0
- package/templates/cypress-template/cypress/support/commands.ts +17 -0
- package/templates/cypress-template/cypress/support/data/data-factory.ts +33 -0
- package/templates/cypress-template/cypress/support/data/id-generator.ts +13 -0
- package/templates/cypress-template/cypress/support/data/seeded-faker.ts +11 -0
- package/templates/cypress-template/cypress/support/e2e.ts +1 -0
- package/templates/cypress-template/cypress/support/pages/login-page.ts +23 -0
- package/templates/cypress-template/cypress/support/pages/people-page.ts +59 -0
- package/templates/cypress-template/cypress.config.ts +32 -0
- package/templates/cypress-template/demo-apps/ui-demo-app/package.json +10 -0
- package/templates/cypress-template/demo-apps/ui-demo-app/public/styles.css +120 -0
- package/templates/cypress-template/demo-apps/ui-demo-app/src/server.js +149 -0
- package/templates/cypress-template/demo-apps/ui-demo-app/src/store.js +43 -0
- package/templates/cypress-template/demo-apps/ui-demo-app/src/templates.js +121 -0
- package/templates/cypress-template/eslint.config.mjs +93 -0
- package/templates/cypress-template/package-lock.json +3758 -0
- package/templates/cypress-template/package.json +31 -0
- package/templates/cypress-template/scripts/run-cypress.mjs +105 -0
- package/templates/cypress-template/scripts/run-tests.sh +6 -0
- package/templates/cypress-template/tsconfig.json +15 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { TestEnvironment } from "./test-env";
|
|
2
|
+
|
|
3
|
+
type EnvironmentDefaults = {
|
|
4
|
+
uiBaseUrl: string;
|
|
5
|
+
credentials: {
|
|
6
|
+
username: string;
|
|
7
|
+
password: string;
|
|
8
|
+
};
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const environmentDefaults: Record<TestEnvironment, EnvironmentDefaults> = {
|
|
12
|
+
dev: {
|
|
13
|
+
uiBaseUrl: "http://127.0.0.1:3000",
|
|
14
|
+
credentials: {
|
|
15
|
+
username: "tester",
|
|
16
|
+
password: "Password123!"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
staging: {
|
|
20
|
+
uiBaseUrl: "https://staging-ui.example.internal",
|
|
21
|
+
credentials: {
|
|
22
|
+
username: "staging-user",
|
|
23
|
+
password: "staging-password"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
prod: {
|
|
27
|
+
uiBaseUrl: "https://ui.example.internal",
|
|
28
|
+
credentials: {
|
|
29
|
+
username: "prod-user",
|
|
30
|
+
password: "prod-password"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function getEnvironmentDefaults(environment: TestEnvironment): EnvironmentDefaults {
|
|
36
|
+
return environmentDefaults[environment];
|
|
37
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import dotenv from "dotenv";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
import { getEnvironmentDefaults } from "./environments";
|
|
7
|
+
import { EnvSecretProvider, SecretManager } from "./secret-manager";
|
|
8
|
+
import { loadTestEnvironment } from "./test-env";
|
|
9
|
+
|
|
10
|
+
const environment = loadTestEnvironment();
|
|
11
|
+
|
|
12
|
+
dotenv.config({ path: path.resolve(process.cwd(), ".env") });
|
|
13
|
+
dotenv.config({ path: path.resolve(process.cwd(), `.env.${environment}`), override: true });
|
|
14
|
+
|
|
15
|
+
const runtimeConfigSchema = z.object({
|
|
16
|
+
testEnv: z.enum(["dev", "staging", "prod"]),
|
|
17
|
+
testRunId: z.string().min(1),
|
|
18
|
+
uiBaseUrl: z.string().url(),
|
|
19
|
+
credentials: z.object({
|
|
20
|
+
username: z.string().min(1),
|
|
21
|
+
password: z.string().min(1)
|
|
22
|
+
})
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export type RuntimeConfig = z.infer<typeof runtimeConfigSchema>;
|
|
26
|
+
|
|
27
|
+
export function loadRuntimeConfig(): RuntimeConfig {
|
|
28
|
+
const defaults = getEnvironmentDefaults(environment);
|
|
29
|
+
const secretManager = new SecretManager(new EnvSecretProvider());
|
|
30
|
+
const uiBaseUrl =
|
|
31
|
+
process.env[`${environment.toUpperCase()}_UI_BASE_URL`] ??
|
|
32
|
+
process.env.UI_BASE_URL ??
|
|
33
|
+
defaults.uiBaseUrl;
|
|
34
|
+
|
|
35
|
+
return runtimeConfigSchema.parse({
|
|
36
|
+
testEnv: environment,
|
|
37
|
+
testRunId: process.env.TEST_RUN_ID ?? "local",
|
|
38
|
+
uiBaseUrl,
|
|
39
|
+
credentials: {
|
|
40
|
+
username:
|
|
41
|
+
secretManager.getOptionalSecret("APP_USERNAME", environment) ?? defaults.credentials.username,
|
|
42
|
+
password:
|
|
43
|
+
secretManager.getOptionalSecret("APP_PASSWORD", environment) ?? defaults.credentials.password
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { TestEnvironment } from "./test-env";
|
|
2
|
+
|
|
3
|
+
export interface SecretProvider {
|
|
4
|
+
getOptionalSecret(secretName: string, environment: TestEnvironment): string | undefined;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export class EnvSecretProvider implements SecretProvider {
|
|
8
|
+
getOptionalSecret(secretName: string, environment: TestEnvironment): string | undefined {
|
|
9
|
+
return process.env[`${environment.toUpperCase()}_${secretName}`] ?? process.env[secretName];
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class SecretManager {
|
|
14
|
+
constructor(private readonly secretProvider: SecretProvider) {}
|
|
15
|
+
|
|
16
|
+
getOptionalSecret(secretName: string, environment: TestEnvironment): string | undefined {
|
|
17
|
+
return this.secretProvider.getOptionalSecret(secretName, environment);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type TestEnvironment = "dev" | "staging" | "prod";
|
|
2
|
+
|
|
3
|
+
const testEnvironmentValues = new Set<TestEnvironment>(["dev", "staging", "prod"]);
|
|
4
|
+
|
|
5
|
+
export function loadTestEnvironment(): TestEnvironment {
|
|
6
|
+
const testEnv = process.env.TEST_ENV ?? "dev";
|
|
7
|
+
|
|
8
|
+
if (!testEnvironmentValues.has(testEnv as TestEnvironment)) {
|
|
9
|
+
throw new Error(`Unsupported TEST_ENV "${testEnv}". Expected one of: dev, staging, prod.`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return testEnv as TestEnvironment;
|
|
13
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { DataFactory } from "../support/data/data-factory";
|
|
2
|
+
import { loadAppConfig } from "../support/app-config";
|
|
3
|
+
import { peoplePage } from "../support/pages/people-page";
|
|
4
|
+
|
|
5
|
+
describe("UI starter journey", () => {
|
|
6
|
+
it("signs in and adds one person", () => {
|
|
7
|
+
const appConfig = loadAppConfig();
|
|
8
|
+
const dataFactory = new DataFactory(appConfig.testRunId);
|
|
9
|
+
const person = dataFactory.person();
|
|
10
|
+
|
|
11
|
+
cy.signIn(appConfig.credentials.username, appConfig.credentials.password);
|
|
12
|
+
peoplePage.welcomeMessage().should("contain", appConfig.credentials.username);
|
|
13
|
+
|
|
14
|
+
cy.addPerson(person);
|
|
15
|
+
peoplePage.flashMessage().should("contain", "Person added");
|
|
16
|
+
peoplePage.nameCell(person.personId).should("have.text", person.name);
|
|
17
|
+
peoplePage.roleCell(person.personId).should("have.text", person.role);
|
|
18
|
+
peoplePage.emailCell(person.personId).should("have.text", person.email);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
type Credentials = {
|
|
2
|
+
username: string;
|
|
3
|
+
password: string;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type AppConfig = {
|
|
7
|
+
testEnv: string;
|
|
8
|
+
testRunId: string;
|
|
9
|
+
credentials: Credentials;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function loadAppConfig(): AppConfig {
|
|
13
|
+
const credentials = Cypress.env("credentials") as Credentials | undefined;
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
testEnv: String(Cypress.env("testEnv") ?? "dev"),
|
|
17
|
+
testRunId: String(Cypress.env("testRunId") ?? "local"),
|
|
18
|
+
credentials: {
|
|
19
|
+
username: credentials?.username ?? "tester",
|
|
20
|
+
password: credentials?.password ?? "Password123!"
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/// <reference types="cypress" />
|
|
2
|
+
|
|
3
|
+
import type { PersonRecord } from "./data/data-factory";
|
|
4
|
+
|
|
5
|
+
declare global {
|
|
6
|
+
namespace Cypress {
|
|
7
|
+
interface Chainable {
|
|
8
|
+
getByTestId(testId: string): Chainable<JQuery<HTMLElement>>;
|
|
9
|
+
signIn(username: string, password: string): Chainable<void>;
|
|
10
|
+
addPerson(person: PersonRecord): Chainable<void>;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { PersonRecord } from "./data/data-factory";
|
|
2
|
+
import { loginPage } from "./pages/login-page";
|
|
3
|
+
import { peoplePage } from "./pages/people-page";
|
|
4
|
+
|
|
5
|
+
Cypress.Commands.add("getByTestId", (testId: string) => {
|
|
6
|
+
return cy.get(`[data-testid='${testId}']`);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
Cypress.Commands.add("signIn", (username: string, password: string) => {
|
|
10
|
+
loginPage.visit();
|
|
11
|
+
loginPage.login(username, password);
|
|
12
|
+
peoplePage.heading().should("be.visible");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
Cypress.Commands.add("addPerson", (person: PersonRecord) => {
|
|
16
|
+
peoplePage.addPerson(person);
|
|
17
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { IdGenerator } from "./id-generator";
|
|
2
|
+
import { createSeededFaker } from "./seeded-faker";
|
|
3
|
+
|
|
4
|
+
export type PersonRecord = {
|
|
5
|
+
personId: string;
|
|
6
|
+
name: string;
|
|
7
|
+
role: string;
|
|
8
|
+
email: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export class DataFactory {
|
|
12
|
+
private readonly idGenerator: IdGenerator;
|
|
13
|
+
|
|
14
|
+
constructor(private readonly testRunId: string) {
|
|
15
|
+
this.idGenerator = new IdGenerator(testRunId);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
person(overrides?: Partial<PersonRecord>): PersonRecord {
|
|
19
|
+
const personId = overrides?.personId ?? this.idGenerator.next("person");
|
|
20
|
+
const seededFaker = createSeededFaker(`${this.testRunId}:${personId}`);
|
|
21
|
+
const firstName = seededFaker.person.firstName();
|
|
22
|
+
const lastName = seededFaker.person.lastName();
|
|
23
|
+
const name = `${firstName} ${lastName}`;
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
personId,
|
|
27
|
+
name,
|
|
28
|
+
role: overrides?.role ?? seededFaker.person.jobTitle(),
|
|
29
|
+
email: overrides?.email ?? `${firstName}.${lastName}.${personId}@example.test`.toLowerCase(),
|
|
30
|
+
...overrides
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export class IdGenerator {
|
|
2
|
+
private readonly counters = new Map<string, number>();
|
|
3
|
+
|
|
4
|
+
constructor(private readonly testRunId: string) {}
|
|
5
|
+
|
|
6
|
+
next(prefix: string): string {
|
|
7
|
+
const currentCount = this.counters.get(prefix) ?? 0;
|
|
8
|
+
const nextCount = currentCount + 1;
|
|
9
|
+
this.counters.set(prefix, nextCount);
|
|
10
|
+
|
|
11
|
+
return `${prefix}-${this.testRunId}-${String(nextCount).padStart(4, "0")}`;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Faker, en } from "@faker-js/faker";
|
|
2
|
+
|
|
3
|
+
function toNumericSeed(seed: string): number {
|
|
4
|
+
return seed.split("").reduce((accumulator, character) => accumulator + character.charCodeAt(0), 0);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function createSeededFaker(seed: string): Faker {
|
|
8
|
+
const faker = new Faker({ locale: [en] });
|
|
9
|
+
faker.seed(toNumericSeed(seed));
|
|
10
|
+
return faker;
|
|
11
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import "./commands";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const loginPage = {
|
|
2
|
+
visit(): Cypress.Chainable {
|
|
3
|
+
return cy.visit("/login");
|
|
4
|
+
},
|
|
5
|
+
|
|
6
|
+
usernameInput(): Cypress.Chainable<JQuery<HTMLElement>> {
|
|
7
|
+
return cy.get("#username");
|
|
8
|
+
},
|
|
9
|
+
|
|
10
|
+
passwordInput(): Cypress.Chainable<JQuery<HTMLElement>> {
|
|
11
|
+
return cy.get("#password");
|
|
12
|
+
},
|
|
13
|
+
|
|
14
|
+
submitButton(): Cypress.Chainable<JQuery<HTMLElement>> {
|
|
15
|
+
return cy.contains("button", "Sign in");
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
login(username: string, password: string): void {
|
|
19
|
+
this.usernameInput().clear().type(username);
|
|
20
|
+
this.passwordInput().clear().type(password, { log: false });
|
|
21
|
+
this.submitButton().click();
|
|
22
|
+
}
|
|
23
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { PersonRecord } from "../data/data-factory";
|
|
2
|
+
|
|
3
|
+
export const peoplePage = {
|
|
4
|
+
heading(): Cypress.Chainable<JQuery<HTMLElement>> {
|
|
5
|
+
return cy.contains("h1", "People");
|
|
6
|
+
},
|
|
7
|
+
|
|
8
|
+
welcomeMessage(): Cypress.Chainable<JQuery<HTMLElement>> {
|
|
9
|
+
return cy.get("[data-testid='welcome-message']");
|
|
10
|
+
},
|
|
11
|
+
|
|
12
|
+
flashMessage(): Cypress.Chainable<JQuery<HTMLElement>> {
|
|
13
|
+
return cy.get("[data-testid='flash-message']");
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
personIdInput(): Cypress.Chainable<JQuery<HTMLElement>> {
|
|
17
|
+
return cy.get("#personId");
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
nameInput(): Cypress.Chainable<JQuery<HTMLElement>> {
|
|
21
|
+
return cy.get("#name");
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
roleInput(): Cypress.Chainable<JQuery<HTMLElement>> {
|
|
25
|
+
return cy.get("#role");
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
emailInput(): Cypress.Chainable<JQuery<HTMLElement>> {
|
|
29
|
+
return cy.get("#email");
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
submitButton(): Cypress.Chainable<JQuery<HTMLElement>> {
|
|
33
|
+
return cy.contains("button", "Add person");
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
personRow(personId: string): Cypress.Chainable<JQuery<HTMLElement>> {
|
|
37
|
+
return cy.get(`[data-testid='person-row-${personId}']`);
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
nameCell(personId: string): Cypress.Chainable<JQuery<HTMLElement>> {
|
|
41
|
+
return this.personRow(personId).find("td").eq(0);
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
roleCell(personId: string): Cypress.Chainable<JQuery<HTMLElement>> {
|
|
45
|
+
return this.personRow(personId).find("td").eq(1);
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
emailCell(personId: string): Cypress.Chainable<JQuery<HTMLElement>> {
|
|
49
|
+
return this.personRow(personId).find("td").eq(2);
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
addPerson(person: PersonRecord): void {
|
|
53
|
+
this.personIdInput().clear().type(person.personId);
|
|
54
|
+
this.nameInput().clear().type(person.name);
|
|
55
|
+
this.roleInput().clear().type(person.role);
|
|
56
|
+
this.emailInput().clear().type(person.email);
|
|
57
|
+
this.submitButton().click();
|
|
58
|
+
}
|
|
59
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { defineConfig } from "cypress";
|
|
2
|
+
|
|
3
|
+
import { loadRuntimeConfig } from "./config/runtime-config";
|
|
4
|
+
|
|
5
|
+
const runtimeConfig = loadRuntimeConfig();
|
|
6
|
+
|
|
7
|
+
export default defineConfig({
|
|
8
|
+
e2e: {
|
|
9
|
+
baseUrl: runtimeConfig.uiBaseUrl,
|
|
10
|
+
specPattern: "cypress/e2e/**/*.cy.ts",
|
|
11
|
+
supportFile: "cypress/support/e2e.ts",
|
|
12
|
+
setupNodeEvents(_on, config) {
|
|
13
|
+
config.env = {
|
|
14
|
+
...config.env,
|
|
15
|
+
testEnv: runtimeConfig.testEnv,
|
|
16
|
+
testRunId: runtimeConfig.testRunId,
|
|
17
|
+
credentials: runtimeConfig.credentials
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
return config;
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
fixturesFolder: false,
|
|
24
|
+
retries: {
|
|
25
|
+
runMode: 1,
|
|
26
|
+
openMode: 0
|
|
27
|
+
},
|
|
28
|
+
screenshotOnRunFailure: true,
|
|
29
|
+
video: true,
|
|
30
|
+
videosFolder: "reports/videos",
|
|
31
|
+
screenshotsFolder: "reports/screenshots"
|
|
32
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
body {
|
|
2
|
+
margin: 0;
|
|
3
|
+
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
|
4
|
+
background: linear-gradient(180deg, #f5f7fb 0%, #eef2f7 100%);
|
|
5
|
+
color: #102038;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.layout {
|
|
9
|
+
max-width: 1080px;
|
|
10
|
+
margin: 0 auto;
|
|
11
|
+
padding: 32px 24px 48px;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.header {
|
|
15
|
+
display: flex;
|
|
16
|
+
align-items: center;
|
|
17
|
+
justify-content: space-between;
|
|
18
|
+
gap: 16px;
|
|
19
|
+
margin-bottom: 24px;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.brand {
|
|
23
|
+
font-size: 1.5rem;
|
|
24
|
+
font-weight: 700;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.nav {
|
|
28
|
+
display: flex;
|
|
29
|
+
gap: 12px;
|
|
30
|
+
flex-wrap: wrap;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.nav a {
|
|
34
|
+
text-decoration: none;
|
|
35
|
+
color: #102038;
|
|
36
|
+
background: #d8e6ff;
|
|
37
|
+
padding: 10px 14px;
|
|
38
|
+
border-radius: 999px;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.card-grid {
|
|
42
|
+
display: grid;
|
|
43
|
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
44
|
+
gap: 16px;
|
|
45
|
+
margin-bottom: 24px;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.card,
|
|
49
|
+
.panel {
|
|
50
|
+
background: #ffffff;
|
|
51
|
+
border-radius: 18px;
|
|
52
|
+
padding: 20px;
|
|
53
|
+
box-shadow: 0 20px 40px rgba(16, 32, 56, 0.08);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.panel-grid {
|
|
57
|
+
display: grid;
|
|
58
|
+
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
59
|
+
gap: 24px;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
form {
|
|
63
|
+
display: grid;
|
|
64
|
+
gap: 14px;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
label {
|
|
68
|
+
display: grid;
|
|
69
|
+
gap: 6px;
|
|
70
|
+
font-weight: 600;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
input,
|
|
74
|
+
select,
|
|
75
|
+
button {
|
|
76
|
+
font: inherit;
|
|
77
|
+
padding: 10px 12px;
|
|
78
|
+
border-radius: 12px;
|
|
79
|
+
border: 1px solid #c4d3eb;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
button {
|
|
83
|
+
background: #174ea6;
|
|
84
|
+
color: #ffffff;
|
|
85
|
+
border: none;
|
|
86
|
+
font-weight: 700;
|
|
87
|
+
cursor: pointer;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
table {
|
|
91
|
+
width: 100%;
|
|
92
|
+
border-collapse: collapse;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
th,
|
|
96
|
+
td {
|
|
97
|
+
text-align: left;
|
|
98
|
+
padding: 12px 8px;
|
|
99
|
+
border-bottom: 1px solid #e1e9f5;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.flash-message {
|
|
103
|
+
background: #dff6e7;
|
|
104
|
+
border: 1px solid #9dd5ae;
|
|
105
|
+
color: #174f2c;
|
|
106
|
+
padding: 12px 14px;
|
|
107
|
+
border-radius: 14px;
|
|
108
|
+
margin-bottom: 16px;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.login-shell {
|
|
112
|
+
min-height: 100vh;
|
|
113
|
+
display: grid;
|
|
114
|
+
place-items: center;
|
|
115
|
+
padding: 24px;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.login-card {
|
|
119
|
+
width: min(420px, 100%);
|
|
120
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const http = require("node:http");
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
const querystring = require("node:querystring");
|
|
5
|
+
|
|
6
|
+
const { createPerson, getPeople, state } = require("./store");
|
|
7
|
+
const { layout, loginPage, peoplePage } = require("./templates");
|
|
8
|
+
|
|
9
|
+
const host = process.env.HOST || "0.0.0.0";
|
|
10
|
+
const port = Number(process.env.PORT || "3000");
|
|
11
|
+
|
|
12
|
+
function readBody(request) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
let body = "";
|
|
15
|
+
request.on("data", (chunk) => {
|
|
16
|
+
body += chunk.toString();
|
|
17
|
+
});
|
|
18
|
+
request.on("end", () => resolve(querystring.parse(body)));
|
|
19
|
+
request.on("error", reject);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseCookies(request) {
|
|
24
|
+
const header = request.headers.cookie;
|
|
25
|
+
if (!header) {
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return header.split(";").reduce((cookies, entry) => {
|
|
30
|
+
const [key, value] = entry.trim().split("=");
|
|
31
|
+
cookies[key] = value;
|
|
32
|
+
return cookies;
|
|
33
|
+
}, {});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function redirect(response, location) {
|
|
37
|
+
response.writeHead(302, { Location: location });
|
|
38
|
+
response.end();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sendHtml(response, html) {
|
|
42
|
+
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
43
|
+
response.end(html);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sendJson(response, payload) {
|
|
47
|
+
response.writeHead(200, { "Content-Type": "application/json" });
|
|
48
|
+
response.end(JSON.stringify(payload));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isAuthenticated(request) {
|
|
52
|
+
return parseCookies(request).session === "authenticated";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function protectedRoute(request, response) {
|
|
56
|
+
if (!isAuthenticated(request)) {
|
|
57
|
+
redirect(response, "/login");
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function pageMessage(url) {
|
|
65
|
+
return new URL(url, "http://127.0.0.1").searchParams.get("message") || "";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const server = http.createServer(async (request, response) => {
|
|
69
|
+
const url = new URL(request.url, "http://127.0.0.1");
|
|
70
|
+
|
|
71
|
+
if (request.method === "GET" && url.pathname === "/styles.css") {
|
|
72
|
+
const cssPath = path.join(__dirname, "..", "public", "styles.css");
|
|
73
|
+
response.writeHead(200, { "Content-Type": "text/css; charset=utf-8" });
|
|
74
|
+
response.end(fs.readFileSync(cssPath, "utf8"));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (request.method === "GET" && url.pathname === "/health") {
|
|
79
|
+
sendJson(response, { status: "ok", people: state.people.length });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (request.method === "GET" && url.pathname === "/") {
|
|
84
|
+
redirect(response, "/login");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (request.method === "GET" && url.pathname === "/login") {
|
|
89
|
+
sendHtml(response, loginPage());
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (request.method === "POST" && url.pathname === "/login") {
|
|
94
|
+
const body = await readBody(request);
|
|
95
|
+
if (
|
|
96
|
+
body.username === state.credentials.username &&
|
|
97
|
+
body.password === state.credentials.password
|
|
98
|
+
) {
|
|
99
|
+
response.writeHead(302, {
|
|
100
|
+
Location: "/people",
|
|
101
|
+
"Set-Cookie": "session=authenticated; HttpOnly; Path=/; SameSite=Lax"
|
|
102
|
+
});
|
|
103
|
+
response.end();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
sendHtml(response, loginPage("Invalid credentials"));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (request.method === "GET" && url.pathname === "/people") {
|
|
112
|
+
if (!protectedRoute(request, response)) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
sendHtml(
|
|
117
|
+
response,
|
|
118
|
+
layout({
|
|
119
|
+
title: "People",
|
|
120
|
+
body: peoplePage(getPeople(url.searchParams.get("search") || "")),
|
|
121
|
+
flashMessage: pageMessage(request.url),
|
|
122
|
+
username: state.credentials.username
|
|
123
|
+
})
|
|
124
|
+
);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (request.method === "POST" && url.pathname === "/people") {
|
|
129
|
+
if (!protectedRoute(request, response)) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const body = await readBody(request);
|
|
135
|
+
createPerson(body);
|
|
136
|
+
redirect(response, "/people?message=Person%20added");
|
|
137
|
+
} catch (error) {
|
|
138
|
+
redirect(response, `/people?message=${encodeURIComponent(error.message)}`);
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
144
|
+
response.end("Not found");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
server.listen(port, host, () => {
|
|
148
|
+
console.log(`UI demo app listening on http://${host}:${port}`);
|
|
149
|
+
});
|