// 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 } from '@daml/ledger'; import { User } from '@daml.js/create-daml-app'; import { authConfig } 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: authConfig.makeToken("participant_admin")}); 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 () : [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 = []; 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 () => { // If the JSON API server was previously shut down abruptly then the port file // may not have been removed. // Since we use this file to know when the server is up, we remove it first // (if it exists) to be sure. const jsonApiPortFilePath = `../${JSON_API_PORT_FILE_NAME}`; // relative to ui folder await removeFile(jsonApiPortFilePath); // 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' }; // Arguments for `daml start` (besides those in the `daml.yaml`). // The JSON API `--port-file` gives us a file we can check to know that both // the sandbox and JSON API server are up and running. // We use the default ports for the sandbox and JSON API as done in the // Getting Started Guide. const startArgs = [ 'start', `--json-api-option=--port-file=${JSON_API_PORT_FILE_NAME}`, ]; console.debug("Starting daml start"); startProc = spawn('daml', startArgs, startOpts); await waitOn({resources: [`file:${jsonApiPortFilePath}`]}); console.debug("daml start API are 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-cli.js', ['run-script', '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:localhost:${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) { 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 = authConfig.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://localhost:${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) => 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'); 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'); 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 = authConfig.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'); await usernameInput.click(); await usernameInput.type(partyName); await page.click('.test-select-login-button'); await page.waitForFunction(async () => await window.getError() !== undefined); expect(dismissError).toHaveBeenCalled(); return error; } test('error on user id with invalid format', async () => { // user ids must be lowercase 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);