mirror of
https://github.com/digital-asset/daml.git
synced 2024-09-20 01:07:18 +03:00
455 lines
15 KiB
TypeScript
455 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, 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<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://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);
|