// Copyright (c) 2022 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // Keep in sync with compatibility/bazel_tools/create-daml-app/index.test.ts import { ChildProcess, spawn, spawnSync, SpawnOptions } from "child_process"; import { promises as fs } from "fs"; import puppeteer, { Browser, Page } from "puppeteer"; import waitOn from "wait-on"; import Ledger, { UserRightHelper, UserRight } from "@daml/ledger"; import { User } from "@daml.js/create-daml-app"; import { insecure } from "./config"; const JSON_API_PORT_FILE_NAME = "json-api.port"; const UI_PORT = 3000; // `daml start` process let startProc: ChildProcess | undefined = undefined; // `npm start` process let uiProc: ChildProcess | undefined = undefined; // Chrome browser that we run in headless mode let browser: Browser | undefined = undefined; let publicUser: string | undefined; let publicParty: string | undefined; const adminLedger = new Ledger({ token: insecure.makeToken("participant_admin"), httpBaseUrl: "http://127.0.0.1:7575/", }); const toAlias = (userId: string): string => userId.charAt(0).toUpperCase() + userId.slice(1); // Function to generate unique party names for us. let nextPartyId = 1; const getParty = async (): Promise<[string, string]> => { const allocResult = await adminLedger.allocateParty({}); const user = `u${nextPartyId}`; const party = allocResult.identifier; const rights: UserRight[] = [UserRightHelper.canActAs(party)].concat( publicParty !== undefined ? [UserRightHelper.canReadAs(publicParty)] : [], ); await adminLedger.createUser(user, rights, party); nextPartyId++; return [user, party]; }; test("Party names are unique", async () => { let r: string[] = []; for (let i = 0; i < 10; ++i) { r = r.concat((await getParty())[1]); } const parties = new Set(r); expect(parties.size).toEqual(10); }, 20_000); const removeFile = async (path: string) => { try { await fs.stat(path); await fs.unlink(path); } catch (_e) { // Do nothing if the file does not exist. } }; // Start the Daml and UI processes before the tests begin. // To reduce test times, we reuse the same processes between all the tests. // This means we need to use a different set of parties and a new browser page for each test. beforeAll(async () => { // Run `daml start` from the project root (where the `daml.yaml` is located). // The path should include '.daml/bin' in the environment where this is run, // which contains the `daml` assistant executable. const startOpts: SpawnOptions = { cwd: "..", stdio: "inherit" }; console.debug("Starting daml start"); startProc = spawn("daml", ["start"], startOpts); await waitOn({ resources: [`tcp:127.0.0.1:6865`] }); console.debug("daml sandbox is running"); await waitOn({ resources: [`tcp:127.0.0.1:7575`] }); console.debug("JSON API is running"); [publicUser, publicParty] = await getParty(); // Run `npm start` in another shell. // Disable automatically opening a browser using the env var described here: // https://github.com/facebook/create-react-app/issues/873#issuecomment-266318338 const env = { ...process.env, BROWSER: "none" }; console.debug("Starting npm start"); uiProc = spawn("npm", ["start"], { env, stdio: "inherit", detached: true, }); // Note(kill-npm-start): The `detached` flag starts the process in a new process group. // This allows us to kill the process with all its descendents after the tests finish, // following https://azimi.me/2014/12/31/kill-child_process-node-js.html. // Ensure the UI server is ready by checking that the port is available. await waitOn({ resources: [`tcp:127.0.0.1:${UI_PORT}`] }); console.debug("npm start is running"); // Launch a single browser for all tests. console.debug("Starting puppeteer"); browser = await puppeteer.launch(); console.debug("Puppeteer is running"); }, 60_000); afterAll(async () => { // Kill the `daml start` process, allowing the sandbox and JSON API server to // shut down gracefully. // The latter process should also remove the JSON API port file. // TODO: Test this on Windows. if (startProc) { startProc.kill("SIGTERM"); } // Kill the `npm start` process including all its descendents. // The `-` indicates to kill all processes in the process group. // See Note(kill-npm-start). // TODO: Test this on Windows. if (uiProc && uiProc.pid) { process.kill(-uiProc.pid); } if (browser) { browser.close(); } }); test("create and look up user using ledger library", async () => { const [user, party] = await getParty(); const token = insecure.makeToken(user); const ledger = new Ledger({ token }); const users0 = await ledger.query(User.User); expect(users0).toEqual([]); const userPayload = { username: party, following: [], public: publicParty }; const userContract1 = await ledger.create(User.User, userPayload); const userContract2 = await ledger.fetchByKey(User.User, party); expect(userContract1).toEqual(userContract2); const users = await ledger.query(User.User); expect(users[0]).toEqual(userContract1); }, 20_000); // The tests following use the headless browser to interact with the app. // We select the relevant DOM elements using CSS class names that we embedded // specifically for testing. // See https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors. const newUiPage = async (): Promise => { if (!browser) { throw Error("Puppeteer browser has not been launched"); } const page = await browser.newPage(); await page.setViewport({ width: 1366, height: 1080 }); page.on("console", message => console.log( `${message.type().substr(0, 3).toUpperCase()} ${message.text()}`, ), ); await page.goto(`http://127.0.0.1:${UI_PORT}`); // ignore the Response return page; }; // Note that Follow is a consuming choice on a contract // with a contract key so it is crucial to wait between follows. // Otherwise, you get errors due to contention. // Those can manifest in puppeteer throwing `Target closed` // but that is not the underlying error (the JSON API will // output the contention errors as well so look through the log). const waitForFollowers = async (page: Page, n: number) => { await page.waitForFunction( (n: number) => document.querySelectorAll(".test-select-following").length == n, {}, n, ); }; // LOGIN_FUNCTION_BEGIN // Log in using a party name and wait for the main screen to load. const login = async (page: Page, partyName: string) => { const usernameInput = await page.waitForSelector( ".test-select-username-field", ); if (usernameInput) { await usernameInput.click(); await usernameInput.type(partyName); await page.click(".test-select-login-button"); await page.waitForSelector(".test-select-main-menu"); } }; // LOGIN_FUNCTION_END // Log out and wait to get back to the login screen. const logout = async (page: Page) => { await page.click(".test-select-log-out"); await page.waitForSelector(".test-select-login-screen"); }; // Follow a user using the text input in the follow panel. const follow = async (page: Page, userToFollow: string) => { const followInput = await page.waitForSelector(".test-select-follow-input"); if (followInput) { await followInput.click(); await followInput.type(userToFollow); await followInput.press("Enter"); await page.click(".test-select-follow-button"); // Wait for the request to complete, either successfully or after the error // dialog has been handled. // We check this by the absence of the `loading` class. // (Both the `test-...` and `loading` classes appear in `div`s surrounding // the `input`, due to the translation of Semantic UI's `Input` element.) await page.waitForSelector(".test-select-follow-input > :not(.loading)", { timeout: 40_000, }); } }; // LOGIN_TEST_BEGIN test("log in as a new user, log out and log back in", async () => { const [user, party] = await getParty(); // Log in as a new user. const page = await newUiPage(); await login(page, user); // Check that the ledger contains the new User contract. const token = insecure.makeToken(user); const ledger = new Ledger({ token }); const users = await ledger.query(User.User); expect(users).toHaveLength(1); expect(users[0].payload.username).toEqual(party); // Log out and in again as the same user. await logout(page); await login(page, user); // Check we have the same one user. const usersFinal = await ledger.query(User.User); expect(usersFinal).toHaveLength(1); expect(usersFinal[0].payload.username).toEqual(party); await page.close(); }, 40_000); // LOGIN_TEST_END // This tests following users in a few different ways: // - using the text box in the Follow panel // - using the icon in the Network panel // - while the user that is followed is logged in // - while the user that is followed is logged out // These are all successful cases. test("log in as three different users and start following each other", async () => { const [user1, party1] = await getParty(); const [user2, party2] = await getParty(); const [user3, party3] = await getParty(); // Log in as Party 1. const page1 = await newUiPage(); await login(page1, user1); // Log in as Party 2. const page2 = await newUiPage(); await login(page2, user2); // Log in as Party 3. const page3 = await newUiPage(); await login(page3, user3); // Party 1 should initially follow no one. const noFollowing1 = await page1.$$(".test-select-following"); expect(noFollowing1).toEqual([]); // Follow Party 2 using the text input. // This should work even though Party 2 has not logged in yet. // Check Party 1 follows exactly Party 2. await follow(page1, party2); await waitForFollowers(page1, 1); const followingList1 = await page1.$$eval( ".test-select-following", following => following.map(e => e.innerHTML), ); expect(followingList1).toEqual([toAlias(user2)]); // Add Party 3 as well and check both are in the list. await follow(page1, party3); await waitForFollowers(page1, 2); const followingList11 = await page1.$$eval( ".test-select-following", following => following.map(e => e.innerHTML), ); expect(followingList11).toHaveLength(2); expect(followingList11).toContain(toAlias(user2)); expect(followingList11).toContain(toAlias(user3)); // Party 2 should initially follow no one. const noFollowing2 = await page2.$$(".test-select-following"); expect(noFollowing2).toEqual([]); // However, Party 2 should see Party 1 in the network. await page2.waitForSelector(".test-select-user-in-network"); const network2 = await page2.$$eval(".test-select-user-in-network", users => users.map(e => e.innerHTML), ); expect(network2).toEqual([toAlias(user1)]); // Follow Party 1 using the 'add user' icon on the right. await page2.waitForSelector(".test-select-add-user-icon"); const userIcons = await page2.$$(".test-select-add-user-icon"); expect(userIcons).toHaveLength(1); await userIcons[0].click(); await waitForFollowers(page2, 1); // Also follow Party 3 using the text input. // Note that we can also use the icon to follow Party 3 as they appear in the // Party 1's Network panel, but that's harder to test at the // moment because there is no loading indicator to tell when it's done. await follow(page2, party3); // Check the following list is updated correctly. await waitForFollowers(page2, 2); const followingList2 = await page2.$$eval( ".test-select-following", following => following.map(e => e.innerHTML), ); expect(followingList2).toHaveLength(2); expect(followingList2).toContain(toAlias(user1)); expect(followingList2).toContain(toAlias(user3)); // Party 1 should now also see Party 2 in the network (but not Party 3 as they // didn't yet started following Party 1). await page1.waitForSelector(".test-select-user-in-network"); const network1 = await page1.$$eval( ".test-select-user-in-network", following => following.map(e => e.innerHTML), ); expect(network1).toEqual([toAlias(user2)]); // Party 3 should follow no one. const noFollowing3 = await page3.$$(".test-select-following"); expect(noFollowing3).toEqual([]); // However, Party 3 should see both Party 1 and Party 2 in the network. await page3.waitForSelector(".test-select-user-in-network"); const network3 = await page3.$$eval( ".test-select-user-in-network", following => following.map(e => e.innerHTML), ); expect(network3).toHaveLength(2); expect(network3).toContain(toAlias(user1)); expect(network3).toContain(toAlias(user2)); await page1.close(); await page2.close(); await page3.close(); }, 60_000); test("error when following self", async () => { const [user, party] = await getParty(); const page = await newUiPage(); const dismissError = jest.fn(dialog => dialog.dismiss()); page.on("dialog", dismissError); await login(page, user); await follow(page, party); expect(dismissError).toHaveBeenCalled(); await page.close(); }); test("error when adding a user that you are already following", async () => { const [user1, party1] = await getParty(); const [user2, party2] = await getParty(); const page = await newUiPage(); const dismissError = jest.fn(dialog => dialog.dismiss()); page.on("dialog", dismissError); await login(page, user1); // First attempt should succeed await follow(page, party2); // Second attempt should result in an error await follow(page, party2); expect(dismissError).toHaveBeenCalled(); await page.close(); }, 10000); const failedLogin = async (page: Page, partyName: string) => { let error: string | undefined = undefined; await page.exposeFunction("getError", () => error); const dismissError = jest.fn(async dialog => { error = dialog.message(); await dialog.dismiss(); }); page.on("dialog", dismissError); const usernameInput = await page.waitForSelector( ".test-select-username-field", ); if (usernameInput) { await usernameInput.click(); await usernameInput.type(partyName); await page.click(".test-select-login-button"); await page.waitForFunction( // Casting window as any so the TS compiler doesn't flag this as an // error. // The window object normally doesn't have a .getError method, but // we're adding one above with exposeFunction. async () => (await (window as any).getError()) !== undefined, ); expect(dismissError).toHaveBeenCalled(); return error; } }; test("error on user id with invalid format", async () => { // user ids should not contains `%` const invalidUser = "Alice%"; const page = await newUiPage(); const error = await failedLogin(page, invalidUser); expect(error).toMatch(/User ID \\"Alice%\\" does not match regex/); await page.close(); }, 40_000); test("error on non-existent user id", async () => { const invalidUser = "nonexistent"; const page = await newUiPage(); const error = await failedLogin(page, invalidUser); expect(error).toMatch( /getting user failed for unknown user \\"nonexistent\\"/, ); await page.close(); }, 40_000); test("error on user with no primary party", async () => { const invalidUser = "noprimary"; await adminLedger.createUser(invalidUser, []); const page = await newUiPage(); const error = await failedLogin(page, invalidUser); expect(error).toMatch(/User 'noprimary' has no primary party/); await page.close(); }, 40_000);