daml/templates/create-daml-app-test-resources/index.test.ts
Moritz Kiefer 5b4fdf0757
Create alias contracts in frontend in create-daml-app (#12848)
* Create alias contracts in frontend in create-daml-app

Creating them in the setup script is nice but it makes it much harder
to port this to Daml hub where we cannot rely on this.

This also requires figuring out the public party in the frontend.

I’ve chosen to infer it from the user rights which seems broadly
sensible.

For the backwards compat mode, I just hardcoded it because there isn’t
a great way to figure it out.

changelog_begin
changelog_end

* .

changelog_begin
changelog_end
2022-02-10 15:04:13 +01:00

423 lines
15 KiB
TypeScript

// 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<Page> => {
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);