diff --git a/tests-ui/tests/users.test.js b/tests-ui/tests/users.test.js new file mode 100644 index 000000000..e42bbe3c1 --- /dev/null +++ b/tests-ui/tests/users.test.js @@ -0,0 +1,223 @@ +// @ts-check +/// +const { start } = require("../utils"); +const lg = require("../utils/litegraph"); + +describe("users", () => { + beforeEach(() => { + lg.setup(global); + }); + + afterEach(() => { + lg.teardown(global); + }); + + function expectNoUserScreen() { + // Ensure login isnt visible + const selection = document.querySelectorAll("#comfy-user-selection")?.[0]; + expect(selection["style"].display).toBe("none"); + const menu = document.querySelectorAll(".comfy-menu")?.[0]; + expect(window.getComputedStyle(menu)?.display).not.toBe("none"); + } + + describe("multi-user", () => { + async function testUserScreen(onShown, users) { + if (!users) { + users = {}; + } + const starting = start({ + resetEnv: true, + users, + }); + + // Ensure no current user + expect(localStorage["Comfy.userId"]).toBeFalsy(); + expect(localStorage["Comfy.userName"]).toBeFalsy(); + + await new Promise(process.nextTick); // wait for promises to resolve + + const selection = document.querySelectorAll("#comfy-user-selection")?.[0]; + expect(selection).toBeTruthy(); + + // Ensure login is visible + expect(window.getComputedStyle(selection)?.display).not.toBe("none"); + // Ensure menu is hidden + const menu = document.querySelectorAll(".comfy-menu")?.[0]; + expect(window.getComputedStyle(menu)?.display).toBe("none"); + + const isCreate = await onShown(selection); + + // Submit form + selection.querySelectorAll("form")[0].submit(); + await new Promise(process.nextTick); // wait for promises to resolve + + // Wait for start + const s = await starting; + + // Ensure login is removed + expect(document.querySelectorAll("#comfy-user-selection")).toHaveLength(0); + expect(window.getComputedStyle(menu)?.display).not.toBe("none"); + + // Ensure settings + templates are saved + const { api } = require("../../web/scripts/api"); + expect(api.createUser).toHaveBeenCalledTimes(+isCreate); + expect(api.storeSettings).toHaveBeenCalledTimes(+isCreate); + expect(api.storeUserData).toHaveBeenCalledTimes(+isCreate); + if (isCreate) { + expect(api.storeUserData).toHaveBeenCalledWith("comfy.templates.json", null, { stringify: false }); + expect(s.app.isNewUserSession).toBeTruthy(); + } else { + expect(s.app.isNewUserSession).toBeFalsy(); + } + + return { users, selection, ...s }; + } + + it("allows user creation if no users", async () => { + const { users } = await testUserScreen((selection) => { + // Ensure we have no users flag added + expect(selection.classList.contains("no-users")).toBeTruthy(); + + // Enter a username + const input = selection.getElementsByTagName("input")[0]; + input.focus(); + input.value = "Test User"; + + return true; + }); + + expect(users).toStrictEqual({ + "Test User!": "Test User", + }); + + expect(localStorage["Comfy.userId"]).toBe("Test User!"); + expect(localStorage["Comfy.userName"]).toBe("Test User"); + }); + it("allows user creation if no current user but other users", async () => { + const users = { + "Test User 2!": "Test User 2", + }; + + await testUserScreen((selection) => { + expect(selection.classList.contains("no-users")).toBeFalsy(); + + // Enter a username + const input = selection.getElementsByTagName("input")[0]; + input.focus(); + input.value = "Test User 3"; + return true; + }, users); + + expect(users).toStrictEqual({ + "Test User 2!": "Test User 2", + "Test User 3!": "Test User 3", + }); + + expect(localStorage["Comfy.userId"]).toBe("Test User 3!"); + expect(localStorage["Comfy.userName"]).toBe("Test User 3"); + }); + it("allows user selection if no current user but other users", async () => { + const users = { + "A!": "A", + "B!": "B", + "C!": "C", + }; + + await testUserScreen((selection) => { + expect(selection.classList.contains("no-users")).toBeFalsy(); + + // Check user list + const select = selection.getElementsByTagName("select")[0]; + const options = select.getElementsByTagName("option"); + expect( + [...options] + .filter((o) => !o.disabled) + .reduce((p, n) => { + p[n.getAttribute("value")] = n.textContent; + return p; + }, {}) + ).toStrictEqual(users); + + // Select an option + select.focus(); + select.value = options[2].value; + + return false; + }, users); + + expect(users).toStrictEqual(users); + + expect(localStorage["Comfy.userId"]).toBe("B!"); + expect(localStorage["Comfy.userName"]).toBe("B"); + }); + it("doesnt show user screen if current user", async () => { + const starting = start({ + resetEnv: true, + users: { + "User!": "User", + }, + localStorage: { + "Comfy.userId": "User!", + "Comfy.userName": "User", + }, + }); + await new Promise(process.nextTick); // wait for promises to resolve + + expectNoUserScreen(); + + await starting; + }); + it("allows user switching", async () => { + const { app } = await start({ + resetEnv: true, + users: { + "User!": "User", + }, + localStorage: { + "Comfy.userId": "User!", + "Comfy.userName": "User", + }, + }); + + // cant actually test switching user easily but can check the setting is present + expect(app.ui.settings.settingsLookup["Comfy.SwitchUser"]).toBeTruthy(); + }); + }); + describe("single-user", () => { + it("doesnt show user creation if no default user", async () => { + const { app } = await start({ + resetEnv: true, + users: false, + }); + expectNoUserScreen(); + + // It should store the settings + const { api } = require("../../web/scripts/api"); + expect(api.storeSettings).toHaveBeenCalledTimes(1); + expect(api.storeUserData).toHaveBeenCalledTimes(1); + expect(api.storeUserData).toHaveBeenCalledWith("comfy.templates.json", null, { stringify: false }); + expect(app.isNewUserSession).toBeTruthy(); + }); + it("doesnt show user creation if default user", async () => { + const { app } = await start({ + resetEnv: true, + users: true, + }); + expectNoUserScreen(); + + // It should store the settings + const { api } = require("../../web/scripts/api"); + expect(api.storeSettings).toHaveBeenCalledTimes(0); + expect(api.storeUserData).toHaveBeenCalledTimes(0); + expect(app.isNewUserSession).toBeFalsy(); + }); + it("doesnt allow user switching", async () => { + const { app } = await start({ + resetEnv: true, + }); + expectNoUserScreen(); + + expect(app.ui.settings.settingsLookup["Comfy.SwitchUser"]).toBeFalsy(); + }); + }); +}); diff --git a/tests-ui/utils/index.js b/tests-ui/utils/index.js index 3a018f566..349e5b675 100644 --- a/tests-ui/utils/index.js +++ b/tests-ui/utils/index.js @@ -1,10 +1,18 @@ const { mockApi } = require("./setup"); const { Ez } = require("./ezgraph"); const lg = require("./litegraph"); +const fs = require("fs"); +const path = require("path"); + +const html = fs.readFileSync(path.resolve(__dirname, "../../web/index.html")) /** * - * @param { Parameters[0] & { resetEnv?: boolean, preSetup?(app): Promise } } config + * @param { Parameters[0] & { + * resetEnv?: boolean, + * preSetup?(app): Promise, + * localStorage?: Record + * } } config * @returns */ export async function start(config = {}) { @@ -12,8 +20,14 @@ export async function start(config = {}) { jest.resetModules(); jest.resetAllMocks(); lg.setup(global); + localStorage.clear(); + sessionStorage.clear(); } + Object.assign(localStorage, config.localStorage ?? {}); + document.body.innerHTML = html; + + mockUtils(); mockApi(config); const { app } = require("../../web/scripts/app"); config.preSetup?.(app); @@ -21,6 +35,12 @@ export async function start(config = {}) { return { ...Ez.graph(app, global["LiteGraph"], global["LGraphCanvas"]), app }; } +function mockUtils() { + jest.mock("../../web/scripts/utils", () => ({ + addStylesheet: () => Promise.resolve() + })); +} + /** * @param { ReturnType["graph"] } graph * @param { (hasReloaded: boolean) => (Promise | void) } cb diff --git a/tests-ui/utils/setup.js b/tests-ui/utils/setup.js index 54aff954b..87d0fe026 100644 --- a/tests-ui/utils/setup.js +++ b/tests-ui/utils/setup.js @@ -18,16 +18,21 @@ function* walkSync(dir) { */ /** - * @param { - * { + * @param {{ * mockExtensions?: string[], * mockNodeDefs?: Record, * users?: boolean | Record * settings?: Record * userData?: Record - * } } config + * }} config */ -export function mockApi({ mockExtensions, mockNodeDefs, users, settings, userData } = {}) { +export function mockApi(config = {}) { + let { mockExtensions, mockNodeDefs, users, settings, userData } = { + users: true, + settings: {}, + userData: {}, + ...config, + }; if (!mockExtensions) { mockExtensions = Array.from(walkSync(path.resolve("../web/extensions/core"))) .filter((x) => x.endsWith(".js")) @@ -36,16 +41,7 @@ export function mockApi({ mockExtensions, mockNodeDefs, users, settings, userDat if (!mockNodeDefs) { mockNodeDefs = JSON.parse(fs.readFileSync(path.resolve("./data/object_info.json"))); } - if(!users) { - users = true; - } - if(!settings) { - settings = {}; - } - if(!userData) { - userData = {}; - } - + const events = new EventTarget(); const mockApi = { addEventListener: events.addEventListener.bind(events), @@ -56,15 +52,26 @@ export function mockApi({ mockExtensions, mockNodeDefs, users, settings, userDat getNodeDefs: jest.fn(() => mockNodeDefs), init: jest.fn(), apiURL: jest.fn((x) => "../../web/" + x), + createUser: jest.fn((username) => { + if(username in users) { + return { status: 400, json: () => "Duplicate" } + } + users[username + "!"] = username; + return { status: 200, json: () => username + "!" } + }), getUsers: jest.fn(() => users), - getSettings: jest.fn(() => settings ?? {}), - getUserData: jest.fn(f => { - if(f in userData) { + getSettings: jest.fn(() => settings), + storeSettings: jest.fn((v) => Object.assign(settings, v)), + getUserData: jest.fn((f) => { + if (f in userData) { return { status: 200, json: () => userData[f] }; } else { - return { status: 404 } + return { status: 404 }; } - }) + }), + storeUserData: jest.fn((file, data) => { + userData[file] = data; + }), }; jest.mock("../../web/scripts/api", () => ({ get api() { diff --git a/web/scripts/api.js b/web/scripts/api.js index 250e936b2..aeafdc90f 100644 --- a/web/scripts/api.js +++ b/web/scripts/api.js @@ -324,13 +324,28 @@ class ComfyApi extends EventTarget { } /** - * Gets all setting values for the current user - * @returns { Promise } A dictionary of id -> value + * Gets a list of users or true if single user mode and default user is created, or false if single user mode and default user is not created. + * @returns { Promise | boolean } If multi-user, a dictionary of id -> value, else whether the default user is created */ async getUsers() { return (await this.fetchApi("/users")).json(); } + /** + * Creates a new user + * @param { string } username + * @returns The fetch response + */ + createUser(username) { + return this.fetchApi("/users", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ username }), + }); + } + /** * Gets all setting values for the current user * @returns { Promise } A dictionary of id -> value diff --git a/web/scripts/ui/userSelection.js b/web/scripts/ui/userSelection.js index 9145905b9..f9f1ca807 100644 --- a/web/scripts/ui/userSelection.js +++ b/web/scripts/ui/userSelection.js @@ -9,7 +9,7 @@ export class UserSelectionScreen { await addStylesheet(import.meta.url); const userSelection = document.getElementById("comfy-user-selection"); userSelection.style.display = ""; - return new Promise(async (r) => { + return new Promise((resolve) => { const input = userSelection.getElementsByTagName("input")[0]; const select = userSelection.getElementsByTagName("select")[0]; const inputSection = input.closest("section"); @@ -40,7 +40,6 @@ export class UserSelectionScreen { e.preventDefault(); if (inputActive == null) { error.textContent = "Please enter a username or select an existing user."; - return; } else if (inputActive) { const username = input.value.trim(); if (!username) { @@ -53,13 +52,7 @@ export class UserSelectionScreen { const spinner = createSpinner(); button.prepend(spinner); try { - const resp = await api.fetchApi("/users", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ username }), - }); + const resp = await api.createUser(username); if (resp.status >= 300) { let message = "Error creating user: " + resp.status + " " + resp.statusText; try { @@ -72,7 +65,7 @@ export class UserSelectionScreen { throw new Error(message); } - r({ username, userId: await resp.json(), created: true }); + resolve({ username, userId: await resp.json(), created: true }); } catch (err) { spinner.remove(); error.textContent = err.message ?? err.statusText ?? err ?? "An unknown error occurred."; @@ -83,7 +76,7 @@ export class UserSelectionScreen { error.textContent = "Please select an existing user."; return; } else { - r({ username: users[select.value], userId: select.value, created: false }); + resolve({ username: users[select.value], userId: select.value, created: false }); } }); @@ -106,7 +99,7 @@ export class UserSelectionScreen { select.style.color = "var(--descrip-text)"; if (select.value) { - // Focus the input, do this separately as sometimes browsers like to fill in the value + // Focus the select, do this separately as sometimes browsers like to fill in the value select.focus(); } } else {