feat: Reinitialize frontend with SvelteKit and TypeScript

- Delete old Vite+Svelte frontend
- Initialize new SvelteKit project with TypeScript
- Configure Tailwind CSS v4 + DaisyUI
- Implement JWT authentication with auto-refresh
- Create login page with form validation (Zod)
- Add protected route guards
- Update Docker configuration for single-stage build
- Add E2E tests with Playwright (6/11 passing)
- Fix Svelte 5 reactivity with $state() runes

Known issues:
- 5 E2E tests failing (timing/async issues)
- Token refresh implementation needs debugging
- Validation error display timing
This commit is contained in:
2026-02-17 16:19:59 -05:00
parent 54df6018f5
commit de2d83092e
28274 changed files with 3816354 additions and 90 deletions

View File

@@ -0,0 +1,101 @@
import IBrowserFrame from '../browser/types/IBrowserFrame.js';
/**
* Handles async tasks.
*/
export default class AsyncTaskManager {
#private;
private static taskID;
private runningTasks;
private runningTaskCount;
private runningTimers;
private runningImmediates;
private debugTrace;
private waitUntilCompleteTimer;
private waitUntilCompleteResolvers;
private aborted;
private destroyed;
/**
* Constructor.
*
* @param browserFrame Browser frame.
*/
constructor(browserFrame: IBrowserFrame);
/**
* Returns a promise that is resolved when async tasks are complete.
*
* @returns Promise.
*/
waitUntilComplete(): Promise<void>;
/**
* Aborts all tasks.
*/
abort(): Promise<void>;
/**
* Destroys the manager.
*/
destroy(): Promise<void>;
/**
* Starts a timer.
*
* @param timerID Timer ID.
*/
startTimer(timerID: NodeJS.Timeout): void;
/**
* Ends a timer.
*
* @param timerID Timer ID.
*/
endTimer(timerID: NodeJS.Timeout): void;
/**
* Starts an immediate.
*
* @param immediateID Immediate ID.
*/
startImmediate(immediateID: NodeJS.Immediate): void;
/**
* Ends an immediate.
*
* @param immediateID Immediate ID.
*/
endImmediate(immediateID: NodeJS.Immediate): void;
/**
* Starts an async task.
*
* @param abortHandler Abort handler.
* @returns Task ID.
*/
startTask(abortHandler?: (destroy?: boolean) => void): number;
/**
* Ends an async task.
*
* @param taskID Task ID.
*/
endTask(taskID: number): void;
/**
* Returns the amount of running tasks.
*
* @returns Count.
*/
getTaskCount(): number;
/**
* Returns a new task ID.
*
* @returns Task ID.
*/
private newTaskID;
/**
* Resolves when complete.
*/
private resolveWhenComplete;
/**
* Applies debugging.
*/
private applyDebugging;
/**
* Aborts all tasks.
*
* @param destroy Destroy.
*/
private abortAll;
}
//# sourceMappingURL=AsyncTaskManager.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"AsyncTaskManager.d.ts","sourceRoot":"","sources":["../../src/async-task-manager/AsyncTaskManager.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,MAAM,mCAAmC,CAAC;AAS9D;;GAEG;AACH,MAAM,CAAC,OAAO,OAAO,gBAAgB;;IACpC,OAAO,CAAC,MAAM,CAAC,MAAM,CAAK;IAC1B,OAAO,CAAC,YAAY,CAAmD;IACvE,OAAO,CAAC,gBAAgB,CAAK;IAC7B,OAAO,CAAC,aAAa,CAAwB;IAC7C,OAAO,CAAC,iBAAiB,CAA0B;IACnD,OAAO,CAAC,UAAU,CAAsE;IACxF,OAAO,CAAC,sBAAsB,CAA+B;IAC7D,OAAO,CAAC,0BAA0B,CAG1B;IACR,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,SAAS,CAAS;IAI1B;;;;OAIG;gBACS,YAAY,EAAE,aAAa;IAIvC;;;;OAIG;IACI,iBAAiB,IAAI,OAAO,CAAC,IAAI,CAAC;IAOzC;;OAEG;IACI,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAU7B;;OAEG;IACI,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAU/B;;;;OAIG;IACI,UAAU,CAAC,OAAO,EAAE,MAAM,CAAC,OAAO,GAAG,IAAI;IAehD;;;;OAIG;IACI,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC,OAAO,GAAG,IAAI;IAe9C;;;;OAIG;IACI,cAAc,CAAC,WAAW,EAAE,MAAM,CAAC,SAAS,GAAG,IAAI;IAe1D;;;;OAIG;IACI,YAAY,CAAC,WAAW,EAAE,MAAM,CAAC,SAAS,GAAG,IAAI;IAexD;;;;;OAKG;IACI,SAAS,CAAC,YAAY,CAAC,EAAE,CAAC,OAAO,CAAC,EAAE,OAAO,KAAK,IAAI,GAAG,MAAM;IAsBpE;;;;OAIG;IACI,OAAO,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI;IAcpC;;;;OAIG;IACI,YAAY,IAAI,MAAM;IAI7B;;;;OAIG;IACH,OAAO,CAAC,SAAS;IAKjB;;OAEG;IACH,OAAO,CAAC,mBAAmB;IAiC3B;;OAEG;IACH,OAAO,CAAC,cAAc;IAkCtB;;;;OAIG;IACH,OAAO,CAAC,QAAQ;CAoChB"}

View File

@@ -0,0 +1,298 @@
// We need to set this as a global constant, so that using fake timers in Jest and Vitest won't override this on the global object.
const TIMER = {
setTimeout: globalThis.setTimeout.bind(globalThis),
clearTimeout: globalThis.clearTimeout.bind(globalThis),
clearImmediate: globalThis.clearImmediate.bind(globalThis)
};
/**
* Handles async tasks.
*/
export default class AsyncTaskManager {
static taskID = 0;
runningTasks = {};
runningTaskCount = 0;
runningTimers = [];
runningImmediates = [];
debugTrace = new Map();
waitUntilCompleteTimer = null;
waitUntilCompleteResolvers = [];
aborted = false;
destroyed = false;
#browserFrame;
#debugTimeout;
/**
* Constructor.
*
* @param browserFrame Browser frame.
*/
constructor(browserFrame) {
this.#browserFrame = browserFrame;
}
/**
* Returns a promise that is resolved when async tasks are complete.
*
* @returns Promise.
*/
waitUntilComplete() {
return new Promise((resolve, reject) => {
this.waitUntilCompleteResolvers.push({ resolve, reject });
this.resolveWhenComplete();
});
}
/**
* Aborts all tasks.
*/
abort() {
if (this.aborted) {
return new Promise((resolve, reject) => {
this.waitUntilCompleteResolvers.push({ resolve, reject });
this.resolveWhenComplete();
});
}
return this.abortAll(false);
}
/**
* Destroys the manager.
*/
destroy() {
if (this.aborted) {
return new Promise((resolve, reject) => {
this.waitUntilCompleteResolvers.push({ resolve, reject });
this.resolveWhenComplete();
});
}
return this.abortAll(true);
}
/**
* Starts a timer.
*
* @param timerID Timer ID.
*/
startTimer(timerID) {
if (this.aborted) {
TIMER.clearTimeout(timerID);
return;
}
if (this.waitUntilCompleteTimer) {
TIMER.clearTimeout(this.waitUntilCompleteTimer);
this.waitUntilCompleteTimer = null;
}
this.runningTimers.push(timerID);
if (this.#browserFrame.page?.context?.browser?.settings?.debug?.traceWaitUntilComplete > 0) {
this.debugTrace.set(timerID, new Error().stack);
}
}
/**
* Ends a timer.
*
* @param timerID Timer ID.
*/
endTimer(timerID) {
if (this.aborted) {
TIMER.clearTimeout(timerID);
return;
}
const index = this.runningTimers.indexOf(timerID);
if (index !== -1) {
this.runningTimers.splice(index, 1);
this.resolveWhenComplete();
}
if (this.#browserFrame.page?.context?.browser?.settings?.debug?.traceWaitUntilComplete > 0) {
this.debugTrace.delete(timerID);
}
}
/**
* Starts an immediate.
*
* @param immediateID Immediate ID.
*/
startImmediate(immediateID) {
if (this.aborted) {
TIMER.clearImmediate(immediateID);
return;
}
if (this.waitUntilCompleteTimer) {
TIMER.clearTimeout(this.waitUntilCompleteTimer);
this.waitUntilCompleteTimer = null;
}
this.runningImmediates.push(immediateID);
if (this.#browserFrame.page?.context?.browser?.settings?.debug?.traceWaitUntilComplete > 0) {
this.debugTrace.set(immediateID, new Error().stack);
}
}
/**
* Ends an immediate.
*
* @param immediateID Immediate ID.
*/
endImmediate(immediateID) {
if (this.aborted) {
TIMER.clearImmediate(immediateID);
return;
}
const index = this.runningImmediates.indexOf(immediateID);
if (index !== -1) {
this.runningImmediates.splice(index, 1);
this.resolveWhenComplete();
}
if (this.#browserFrame.page?.context?.browser?.settings?.debug?.traceWaitUntilComplete > 0) {
this.debugTrace.delete(immediateID);
}
}
/**
* Starts an async task.
*
* @param abortHandler Abort handler.
* @returns Task ID.
*/
startTask(abortHandler) {
if (this.aborted) {
if (abortHandler) {
abortHandler(this.destroyed);
}
throw new this.#browserFrame.window.Error(`Failed to execute 'startTask()' on 'AsyncTaskManager': The asynchrounous task manager has been aborted.`);
}
if (this.waitUntilCompleteTimer) {
TIMER.clearTimeout(this.waitUntilCompleteTimer);
this.waitUntilCompleteTimer = null;
}
const taskID = this.newTaskID();
this.runningTasks[taskID] = abortHandler ? abortHandler : () => { };
this.runningTaskCount++;
if (this.#browserFrame.page?.context?.browser?.settings?.debug?.traceWaitUntilComplete > 0) {
this.debugTrace.set(taskID, new Error().stack);
}
return taskID;
}
/**
* Ends an async task.
*
* @param taskID Task ID.
*/
endTask(taskID) {
if (this.aborted) {
return;
}
if (this.runningTasks[taskID]) {
delete this.runningTasks[taskID];
this.runningTaskCount--;
this.resolveWhenComplete();
}
if (this.#browserFrame.page?.context?.browser?.settings?.debug?.traceWaitUntilComplete > 0) {
this.debugTrace.delete(taskID);
}
}
/**
* Returns the amount of running tasks.
*
* @returns Count.
*/
getTaskCount() {
return this.runningTaskCount;
}
/**
* Returns a new task ID.
*
* @returns Task ID.
*/
newTaskID() {
this.constructor.taskID++;
return this.constructor.taskID;
}
/**
* Resolves when complete.
*/
resolveWhenComplete() {
this.applyDebugging();
if (this.runningTaskCount || this.runningTimers.length || this.runningImmediates.length) {
return;
}
if (this.waitUntilCompleteTimer) {
TIMER.clearTimeout(this.waitUntilCompleteTimer);
this.waitUntilCompleteTimer = null;
}
// It is not possible to detect when all microtasks are complete (such as process.nextTick() or promises).
// To cater for this we use setTimeout() which has the lowest priority and will be executed last.
// @see https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick
this.waitUntilCompleteTimer = TIMER.setTimeout(() => {
this.waitUntilCompleteTimer = null;
if (!this.runningTaskCount && !this.runningTimers.length && !this.runningImmediates.length) {
if (this.#debugTimeout) {
TIMER.clearTimeout(this.#debugTimeout);
}
const resolvers = this.waitUntilCompleteResolvers;
this.waitUntilCompleteResolvers = [];
for (const resolver of resolvers) {
resolver.resolve();
}
this.aborted = false;
}
else {
this.applyDebugging();
}
}, 1);
}
/**
* Applies debugging.
*/
applyDebugging() {
const debug = this.#browserFrame.page?.context?.browser?.settings?.debug;
if (!debug?.traceWaitUntilComplete || debug.traceWaitUntilComplete < 1) {
return;
}
if (this.#debugTimeout) {
return;
}
this.#debugTimeout = TIMER.setTimeout(() => {
this.#debugTimeout = null;
let errorMessage = `The maximum time was reached for "waitUntilComplete()".\n\n${this.debugTrace.size} task${this.debugTrace.size === 1 ? '' : 's'} did not end in time.\n\nThe following traces were recorded:\n\n`;
for (const [key, value] of this.debugTrace.entries()) {
const type = typeof key === 'number' ? 'Task' : 'Timer';
errorMessage += `${type} #${key}\n‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾${value
.replace(/Error:/, '')
.replace(/\s+at /gm, '\n> ')}\n\n`;
}
const error = new Error(errorMessage);
for (const resolver of this.waitUntilCompleteResolvers) {
resolver.reject(error);
}
this.abortAll(true);
}, debug.traceWaitUntilComplete);
}
/**
* Aborts all tasks.
*
* @param destroy Destroy.
*/
abortAll(destroy) {
const runningTimers = this.runningTimers;
const runningImmediates = this.runningImmediates;
const runningTasks = this.runningTasks;
this.aborted = true;
this.destroyed = destroy;
this.runningTasks = {};
this.runningTaskCount = 0;
this.runningImmediates = [];
this.runningTimers = [];
this.debugTrace = new Map();
if (this.waitUntilCompleteTimer) {
TIMER.clearTimeout(this.waitUntilCompleteTimer);
this.waitUntilCompleteTimer = null;
}
for (const immediate of runningImmediates) {
TIMER.clearImmediate(immediate);
}
for (const timer of runningTimers) {
TIMER.clearTimeout(timer);
}
for (const key of Object.keys(runningTasks)) {
runningTasks[key](destroy);
}
// We need to wait for microtasks to complete before resolving.
return new Promise((resolve, reject) => {
this.waitUntilCompleteResolvers.push({ resolve, reject });
this.resolveWhenComplete();
});
}
}
//# sourceMappingURL=AsyncTaskManager.js.map

File diff suppressed because one or more lines are too long