Move spaces tests from Puppeteer to Cypress (#8645)

* Move spaces tests from Puppeteer to Cypress

* Add missing fixture

* Tweak synapsedocker to not double error on a docker failure

* Fix space hierarchy loading race condition

Fixes https://github.com/matrix-org/element-web-rageshakes/issues/10345

* Fix race condition when creating public space with url update code

* Try Electron once more due to perms issues around clipboard

* Try set browser permissions properly

* Try to enable clipboard another way

* Try electron again

* Try electron again again

* Switch to built-in cypress feature for file uploads

* Mock clipboard instead

* TMPDIR ftw?

* uid:gid pls

* Clipboard tests can now run on any browser due to mocking

* Test Enter as well as button for space creation

* Make the test actually work

* Update cypress/support/util.ts

Co-authored-by: Eric Eastwood <erice@element.io>

Co-authored-by: Eric Eastwood <erice@element.io>
This commit is contained in:
Michael Telatynski 2022-05-26 10:19:00 +01:00 committed by GitHub
parent d75e2f19c5
commit f3f14afbbf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 492 additions and 148 deletions

View file

@ -71,6 +71,7 @@ jobs:
# to run the tests, so use chrome.
browser: chrome
start: npx serve -p 8080 webapp
wait-on: 'http://localhost:8080'
record: true
command-prefix: 'yarn percy exec --'
env:
@ -83,6 +84,8 @@ jobs:
PERCY_BROWSER_EXECUTABLE: /usr/bin/chromium-browser
# pass GitHub token to allow accurately detecting a build vs a re-run build
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# make Node's os.tmpdir() return something where we actually have permissions
TMPDIR: ${{ runner.temp }}
- name: Upload Artifact
if: failure()

1
.gitignore vendored
View file

@ -26,5 +26,4 @@ package-lock.json
/cypress/synapselogs
# These could have files in them but don't currently
# Cypress will still auto-create them though...
/cypress/fixtures
/cypress/performance

BIN
cypress/fixtures/riot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -87,8 +87,8 @@ describe("Threads", () => {
cy.get(".mx_RoomView_body .mx_BasicMessageComposer_input").type("Hello Mr. Bot{enter}");
// Wait for message to send, get its ID and save as @threadId
cy.get(".mx_RoomView_body .mx_EventTile").contains("Hello Mr. Bot")
.closest(".mx_EventTile[data-scroll-tokens]").invoke("attr", "data-scroll-tokens").as("threadId");
cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot")
.invoke("attr", "data-scroll-tokens").as("threadId");
// Bot starts thread
cy.get<string>("@threadId").then(threadId => {
@ -111,7 +111,7 @@ describe("Threads", () => {
cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content").should("contain", "Test");
// User reacts to message instead
cy.get(".mx_ThreadView .mx_EventTile").contains("Hello there").closest(".mx_EventTile_line")
cy.get(".mx_ThreadView .mx_EventTile").contains(".mx_EventTile_line", "Hello there")
.find('[aria-label="React"]').click({ force: true }); // Cypress has no ability to hover
cy.get(".mx_EmojiPicker").within(() => {
cy.get('input[type="text"]').type("wave");
@ -119,7 +119,7 @@ describe("Threads", () => {
});
// User redacts their prior response
cy.get(".mx_ThreadView .mx_EventTile").contains("Test").closest(".mx_EventTile_line")
cy.get(".mx_ThreadView .mx_EventTile").contains(".mx_EventTile_line", "Test")
.find('[aria-label="Options"]').click({ force: true }); // Cypress has no ability to hover
cy.get(".mx_IconizedContextMenu").within(() => {
cy.get('[role="menuitem"]').contains("Remove").click();
@ -166,7 +166,7 @@ describe("Threads", () => {
cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content").should("contain", "Great!");
// User edits & asserts
cy.get(".mx_ThreadView .mx_EventTile_last").contains("Great!").closest(".mx_EventTile_line").within(() => {
cy.get(".mx_ThreadView .mx_EventTile_last").contains(".mx_EventTile_line", "Great!").within(() => {
cy.get('[aria-label="Edit"]').click({ force: true }); // Cypress has no ability to hover
cy.get(".mx_BasicMessageComposer_input").type(" How about yourself?{enter}");
});

View file

@ -0,0 +1,244 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/// <reference types="cypress" />
import type { MatrixClient } from "matrix-js-sdk/src/client";
import type { ICreateRoomOpts } from "matrix-js-sdk/src/@types/requests";
import { SynapseInstance } from "../../plugins/synapsedocker";
import Chainable = Cypress.Chainable;
import { UserCredentials } from "../../support/login";
function openSpaceCreateMenu(): Chainable<JQuery> {
cy.get(".mx_SpaceButton_new").click();
return cy.get(".mx_SpaceCreateMenu_wrapper .mx_ContextualMenu");
}
function getSpacePanelButton(spaceName: string): Chainable<JQuery> {
return cy.get(`.mx_SpaceButton[aria-label="${spaceName}"]`);
}
function openSpaceContextMenu(spaceName: string): Chainable<JQuery> {
getSpacePanelButton(spaceName).rightclick();
return cy.get(".mx_SpacePanel_contextMenu");
}
function spaceCreateOptions(spaceName: string): ICreateRoomOpts {
return {
creation_content: {
type: "m.space",
},
initial_state: [{
type: "m.room.name",
content: {
name: spaceName,
},
}],
};
}
function spaceChildInitialState(roomId: string): ICreateRoomOpts["initial_state"]["0"] {
return {
type: "m.space.child",
state_key: roomId,
content: {
via: [roomId.split(":")[1]],
},
};
}
describe("Spaces", () => {
let synapse: SynapseInstance;
let user: UserCredentials;
beforeEach(() => {
cy.startSynapse("default").then(data => {
synapse = data;
cy.initTestUser(synapse, "Sue").then(_user => {
user = _user;
cy.mockClipboard();
});
});
});
afterEach(() => {
cy.stopSynapse(synapse);
});
it("should allow user to create public space", () => {
openSpaceCreateMenu().within(() => {
cy.get(".mx_SpaceCreateMenuType_public").click();
cy.get('.mx_SpaceBasicSettings_avatarContainer input[type="file"]')
.selectFile("cypress/fixtures/riot.png", { force: true });
cy.get('input[label="Name"]').type("Let's have a Riot");
cy.get('input[label="Address"]').should("have.value", "lets-have-a-riot");
cy.get('textarea[label="Description"]').type("This is a space to reminisce Riot.im!");
cy.get(".mx_AccessibleButton").contains("Create").click();
});
// Create the default General & Random rooms, as well as a custom "Jokes" room
cy.get('input[label="Room name"][value="General"]').should("exist");
cy.get('input[label="Room name"][value="Random"]').should("exist");
cy.get('input[placeholder="Support"]').type("Jokes");
cy.get(".mx_AccessibleButton").contains("Continue").click();
// Copy matrix.to link
cy.get(".mx_SpacePublicShare_shareButton").focus().realClick();
cy.getClipboardText().should("eq", "https://matrix.to/#/#lets-have-a-riot:localhost");
// Go to space home
cy.get(".mx_AccessibleButton").contains("Go to my first room").click();
// Assert rooms exist in the room list
cy.get(".mx_RoomTile").contains("General").should("exist");
cy.get(".mx_RoomTile").contains("Random").should("exist");
cy.get(".mx_RoomTile").contains("Jokes").should("exist");
});
it("should allow user to create private space", () => {
openSpaceCreateMenu().within(() => {
cy.get(".mx_SpaceCreateMenuType_private").click();
cy.get('.mx_SpaceBasicSettings_avatarContainer input[type="file"]')
.selectFile("cypress/fixtures/riot.png", { force: true });
cy.get('input[label="Name"]').type("This is not a Riot");
cy.get('input[label="Address"]').should("not.exist");
cy.get('textarea[label="Description"]').type("This is a private space of mourning Riot.im...");
cy.get(".mx_AccessibleButton").contains("Create").click();
});
cy.get(".mx_SpaceRoomView_privateScope_meAndMyTeammatesButton").click();
// Create the default General & Random rooms, as well as a custom "Projects" room
cy.get('input[label="Room name"][value="General"]').should("exist");
cy.get('input[label="Room name"][value="Random"]').should("exist");
cy.get('input[placeholder="Support"]').type("Projects");
cy.get(".mx_AccessibleButton").contains("Continue").click();
cy.get(".mx_SpaceRoomView").should("contain", "Invite your teammates");
cy.get(".mx_AccessibleButton").contains("Skip for now").click();
// Assert rooms exist in the room list
cy.get(".mx_RoomTile").contains("General").should("exist");
cy.get(".mx_RoomTile").contains("Random").should("exist");
cy.get(".mx_RoomTile").contains("Projects").should("exist");
// Assert rooms exist in the space explorer
cy.get(".mx_SpaceHierarchy_roomTile").contains("General").should("exist");
cy.get(".mx_SpaceHierarchy_roomTile").contains("Random").should("exist");
cy.get(".mx_SpaceHierarchy_roomTile").contains("Projects").should("exist");
});
it("should allow user to create just-me space", () => {
cy.createRoom({
name: "Sample Room",
});
openSpaceCreateMenu().within(() => {
cy.get(".mx_SpaceCreateMenuType_private").click();
cy.get('.mx_SpaceBasicSettings_avatarContainer input[type="file"]')
.selectFile("cypress/fixtures/riot.png", { force: true });
cy.get('input[label="Address"]').should("not.exist");
cy.get('textarea[label="Description"]').type("This is a personal space to mourn Riot.im...");
cy.get('input[label="Name"]').type("This is my Riot{enter}");
});
cy.get(".mx_SpaceRoomView_privateScope_justMeButton").click();
cy.get(".mx_AddExistingToSpace_entry").click();
cy.get(".mx_AccessibleButton").contains("Add").click();
cy.get(".mx_RoomTile").contains("Sample Room").should("exist");
cy.get(".mx_SpaceHierarchy_roomTile").contains("Sample Room").should("exist");
});
it("should allow user to invite another to a space", () => {
let bot: MatrixClient;
cy.getBot(synapse, "BotBob").then(_bot => {
bot = _bot;
});
cy.createSpace({
visibility: "public" as any,
room_alias_name: "space",
}).as("spaceId");
openSpaceContextMenu("#space:localhost").within(() => {
cy.get('.mx_SpacePanel_contextMenu_inviteButton[aria-label="Invite"]').click();
});
cy.get(".mx_SpacePublicShare").within(() => {
// Copy link first
cy.get(".mx_SpacePublicShare_shareButton").focus().realClick();
cy.getClipboardText().should("eq", "https://matrix.to/#/#space:localhost");
// Start Matrix invite flow
cy.get(".mx_SpacePublicShare_inviteButton").click();
});
cy.get(".mx_InviteDialog_other").within(() => {
cy.get('input[type="text"]').type(bot.getUserId());
cy.get(".mx_AccessibleButton").contains("Invite").click();
});
cy.get(".mx_InviteDialog_other").should("not.exist");
});
it("should show space invites at the top of the space panel", () => {
cy.createSpace({
name: "My Space",
});
getSpacePanelButton("My Space").should("exist");
cy.getBot(synapse, "BotBob").then({ timeout: 10000 }, async bot => {
const { room_id: roomId } = await bot.createRoom(spaceCreateOptions("Space Space"));
await bot.invite(roomId, user.userId);
});
// Assert that `Space Space` is above `My Space` due to it being an invite
getSpacePanelButton("Space Space").should("exist")
.parent().next().find('.mx_SpaceButton[aria-label="My Space"]').should("exist");
});
it("should include rooms in space home", () => {
cy.createRoom({
name: "Music",
}).as("roomId1");
cy.createRoom({
name: "Gaming",
}).as("roomId2");
const spaceName = "Spacey Mc. Space Space";
cy.all([
cy.get<string>("@roomId1"),
cy.get<string>("@roomId2"),
]).then(([roomId1, roomId2]) => {
cy.createSpace({
name: spaceName,
initial_state: [
spaceChildInitialState(roomId1),
spaceChildInitialState(roomId2),
],
}).as("spaceId");
});
cy.get("@spaceId").then(() => {
getSpacePanelButton(spaceName).dblclick(); // Open space home
});
cy.get(".mx_SpaceRoomView .mx_SpaceHierarchy_list").within(() => {
cy.get(".mx_SpaceHierarchy_roomTile").contains("Music").should("exist");
cy.get(".mx_SpaceHierarchy_roomTile").contains("Gaming").should("exist");
});
});
});

View file

@ -66,9 +66,6 @@ async function cfgDirFromTemplate(template: string): Promise<SynapseConfig> {
}
const tempDir = await fse.mkdtemp(path.join(os.tmpdir(), 'react-sdk-synapsedocker-'));
// change permissions on the temp directory so the docker container can see its contents
await fse.chmod(tempDir, 0o777);
// copy the contents of the template dir, omitting homeserver.yaml as we'll template that
console.log(`Copy ${templateDir} -> ${tempDir}`);
await fse.copy(templateDir, tempDir, { filter: f => path.basename(f) !== 'homeserver.yaml' });
@ -113,6 +110,7 @@ async function synapseStart(template: string): Promise<SynapseInstance> {
console.log(`Starting synapse with config dir ${synCfg.configDir}...`);
const containerName = `react-sdk-cypress-synapse-${crypto.randomBytes(4).toString("hex")}`;
const userInfo = os.userInfo();
const synapseId = await new Promise<string>((resolve, reject) => {
childProcess.execFile('docker', [
@ -121,6 +119,8 @@ async function synapseStart(template: string): Promise<SynapseInstance> {
"-d",
"-v", `${synCfg.configDir}:/data`,
"-p", `${synCfg.port}:8008/tcp`,
// We run the docker container as our uid:gid otherwise cleaning it up its media_store can be difficult
"-u", `${userInfo.uid}:${userInfo.gid}`,
"matrixdotorg/synapse:develop",
"run",
], (err, stdout) => {
@ -129,8 +129,6 @@ async function synapseStart(template: string): Promise<SynapseInstance> {
});
});
synapses.set(synapseId, { synapseId, ...synCfg });
console.log(`Started synapse with id ${synapseId} on port ${synCfg.port}.`);
// Await Synapse healthcheck
@ -150,7 +148,9 @@ async function synapseStart(template: string): Promise<SynapseInstance> {
});
});
return synapses.get(synapseId);
const synapse: SynapseInstance = { synapseId, ...synCfg };
synapses.set(synapseId, synapse);
return synapse;
}
async function synapseStop(id: string): Promise<void> {

View file

@ -35,6 +35,12 @@ declare global {
* @return the ID of the newly created room
*/
createRoom(options: ICreateRoomOpts): Chainable<string>;
/**
* Create a space with given options.
* @param options the options to apply when creating the space
* @return the ID of the newly created space (room)
*/
createSpace(options: ICreateRoomOpts): Chainable<string>;
/**
* Invites the given user to the given room.
* @param roomId the id of the room to invite to
@ -71,6 +77,15 @@ Cypress.Commands.add("createRoom", (options: ICreateRoomOpts): Chainable<string>
});
});
Cypress.Commands.add("createSpace", (options: ICreateRoomOpts): Chainable<string> => {
return cy.createRoom({
...options,
creation_content: {
"type": "m.space",
},
});
});
Cypress.Commands.add("inviteUser", (roomId: string, userId: string): Chainable<{}> => {
return cy.getClient().then(async (cli: MatrixClient) => {
return cli.invite(roomId, userId);

View file

@ -0,0 +1,57 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/// <reference types="cypress" />
import Chainable = Cypress.Chainable;
// Mock the clipboard, as only Electron gives the app permission to the clipboard API by default
// Virtual clipboard
let copyText: string;
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
/**
* Mock the clipboard on the current window, ready for calling `getClipboardText`.
* Irreversible, refresh the window to restore mock.
*/
mockClipboard(): Chainable<AUTWindow>;
/**
* Read text from the mocked clipboard.
* @return {string} the clipboard text
*/
getClipboardText(): Chainable<string>;
}
}
}
Cypress.Commands.add("mockClipboard", () => {
cy.window({ log: false }).then(win => {
win.navigator.clipboard.writeText = (text) => {
copyText = text;
return Promise.resolve();
};
});
});
Cypress.Commands.add("getClipboardText", (): Chainable<string> => {
return cy.wrap(copyText);
});
// Needed to make this file a module
export { };

View file

@ -17,6 +17,7 @@ limitations under the License.
/// <reference types="cypress" />
import "@percy/cypress";
import "cypress-real-events";
import "./performance";
import "./synapse";
@ -24,3 +25,5 @@ import "./login";
import "./client";
import "./settings";
import "./bot";
import "./clipboard";
import "./util";

View file

@ -16,7 +16,6 @@ limitations under the License.
/// <reference types="cypress" />
import "./client"; // XXX: without an (any) import here, types break down
import Chainable = Cypress.Chainable;
declare global {
@ -99,3 +98,6 @@ Cypress.Commands.add("leaveBeta", (name: string): Chainable<JQuery<HTMLElement>>
return cy.get(".mx_BetaCard_buttons").contains("Leave the beta").click();
});
});
// Needed to make this file a module
export { };

82
cypress/support/util.ts Normal file
View file

@ -0,0 +1,82 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/// <reference types="cypress" />
// @see https://github.com/cypress-io/cypress/issues/915#issuecomment-475862672
// Modified due to changes to `cy.queue` https://github.com/cypress-io/cypress/pull/17448
// Note: this DOES NOT run Promises in parallel like `Promise.all` due to the nature
// of Cypress promise-like objects and command queue. This only makes it convenient to use the same
// API but runs the commands sequentially.
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
type ChainableValue<T> = T extends Cypress.Chainable<infer V> ? V : T;
interface cy {
all<T extends Cypress.Chainable[] | []>(
commands: T
): Cypress.Chainable<{ [P in keyof T]: ChainableValue<T[P]> }>;
queue: any;
}
interface Chainable {
chainerId: string;
}
}
}
const chainStart = Symbol("chainStart");
/**
* @description Returns a single Chainable that resolves when all of the Chainables pass.
* @param {Cypress.Chainable[]} commands - List of Cypress.Chainable to resolve.
* @returns {Cypress.Chainable} Cypress when all Chainables are resolved.
*/
cy.all = function all(commands): Cypress.Chainable {
const chain = cy.wrap(null, { log: false });
const stopCommand = Cypress._.find(cy.queue.get(), {
attributes: { chainerId: chain.chainerId },
});
const startCommand = Cypress._.find(cy.queue.get(), {
attributes: { chainerId: commands[0].chainerId },
});
const p = chain.then(() => {
return cy.wrap(
// @see https://lodash.com/docs/4.17.15#lodash
Cypress._(commands)
.map(cmd => {
return cmd[chainStart]
? cmd[chainStart].attributes
: Cypress._.find(cy.queue.get(), {
attributes: { chainerId: cmd.chainerId },
}).attributes;
})
.concat(stopCommand.attributes)
.slice(1)
.map(cmd => {
return cmd.prev.get("subject");
})
.value(),
);
});
p[chainStart] = startCommand;
return p;
};
// Needed to make this file a module
export { };

View file

@ -169,6 +169,7 @@
"blob-polyfill": "^6.0.20211015",
"chokidar": "^3.5.1",
"cypress": "^9.6.1",
"cypress-real-events": "^1.7.0",
"enzyme": "^3.11.0",
"enzyme-to-json": "^3.6.2",
"eslint": "8.9.0",

View file

@ -18,6 +18,7 @@ import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client";
import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event";
import { Room, RoomEvent } from "matrix-js-sdk/src/models/room";
import { IRoomTimelineData } from "matrix-js-sdk/src/models/event-timeline-set";
import { RoomState, RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import dis from "../dispatcher/dispatcher";
import { ActionPayload } from "../dispatcher/payloads";
@ -175,6 +176,21 @@ export interface IRoomTimelineActionPayload extends Pick<ActionPayload, "action"
isLiveUnfilteredRoomTimelineEvent: boolean;
}
/**
* @typedef IRoomStateEventsActionPayload
* @type {Object}
* @property {string} action 'MatrixActions.RoomState.events'.
* @property {MatrixEvent} event the state event received
* @property {RoomState} state the room state into which the event was applied
* @property {MatrixEvent | null} lastStateEvent the previous value for this (event-type, state-key) tuple in room state
*/
export interface IRoomStateEventsActionPayload extends Pick<ActionPayload, "action"> {
action: 'MatrixActions.RoomState.events';
event: MatrixEvent;
state: RoomState;
lastStateEvent: MatrixEvent | null;
}
/**
* Create a MatrixActions.Room.timeline action that represents a
* MatrixClient `Room.timeline` matrix event, emitted when an event
@ -210,6 +226,31 @@ function createRoomTimelineAction(
};
}
/**
* Create a MatrixActions.Room.timeline action that represents a
* MatrixClient `Room.timeline` matrix event, emitted when an event
* is added to or removed from a timeline of a room.
*
* @param {MatrixClient} matrixClient the matrix client.
* @param {MatrixEvent} event the state event received
* @param {RoomState} state the room state into which the event was applied
* @param {MatrixEvent | null} lastStateEvent the previous value for this (event-type, state-key) tuple in room state
* @returns {IRoomStateEventsActionPayload} an action of type `MatrixActions.RoomState.events`.
*/
function createRoomStateEventsAction(
matrixClient: MatrixClient,
event: MatrixEvent,
state: RoomState,
lastStateEvent: MatrixEvent | null,
): IRoomStateEventsActionPayload {
return {
action: 'MatrixActions.RoomState.events',
event,
state,
lastStateEvent,
};
}
/**
* @typedef RoomMembershipAction
* @type {Object}
@ -312,6 +353,7 @@ export default {
addMatrixClientListener(matrixClient, RoomEvent.Timeline, createRoomTimelineAction);
addMatrixClientListener(matrixClient, RoomEvent.MyMembership, createSelfMembershipAction);
addMatrixClientListener(matrixClient, MatrixEventEvent.Decrypted, createEventDecryptedAction);
addMatrixClientListener(matrixClient, RoomStateEvent.Events, createRoomStateEventsAction);
},
/**

View file

@ -131,6 +131,7 @@ import { IConfigOptions } from "../../IConfigOptions";
import { SnakedObject } from "../../utils/SnakedObject";
import { leaveRoomBehaviour } from "../../utils/leave-behaviour";
import VideoChannelStore from "../../stores/VideoChannelStore";
import { IRoomStateEventsActionPayload } from "../../actions/MatrixActionCreators";
// legacy export
export { default as Views } from "../../Views";
@ -651,6 +652,20 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
case 'view_user_info':
this.viewUser(payload.userId, payload.subAction);
break;
case "MatrixActions.RoomState.events": {
const event = (payload as IRoomStateEventsActionPayload).event;
if (event.getType() === EventType.RoomCanonicalAlias &&
event.getRoomId() === this.state.currentRoomId
) {
// re-view the current room so we can update alias/id in the URL properly
this.viewRoom({
action: Action.ViewRoom,
room_id: this.state.currentRoomId,
metricsTrigger: undefined, // room doesn't change
});
}
break;
}
case Action.ViewRoom: {
// Takes either a room ID or room alias: if switching to a room the client is already
// known to be in (eg. user clicks on a room in the recents panel), supply the ID
@ -891,9 +906,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
// Store this as the ID of the last room accessed. This is so that we can
// persist which room is being stored across refreshes and browser quits.
if (localStorage) {
localStorage.setItem('mx_last_room_id', room.roomId);
}
localStorage?.setItem('mx_last_room_id', room.roomId);
}
// If we are redirecting to a Room Alias and it is for the room we already showing then replace history item

View file

@ -1137,15 +1137,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
if (!this.state.room || this.state.room.roomId !== state.roomId) return;
switch (ev.getType()) {
case EventType.RoomCanonicalAlias:
// re-view the room so MatrixChat can manage the alias in the URL properly
dis.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: this.state.room.roomId,
metricsTrigger: undefined, // room doesn't change
});
break;
case EventType.RoomTombstone:
this.setState({ tombstone: this.getRoomTombstone() });
break;

View file

@ -524,8 +524,13 @@ export const useRoomHierarchy = (space: Room): {
setRooms(hierarchy.rooms);
}, [error, hierarchy]);
const loading = hierarchy?.loading ?? true;
return { loading, rooms, hierarchy, loadMore, error };
return {
loading: hierarchy?.loading ?? true,
rooms,
hierarchy: hierarchy?.root === space ? hierarchy : undefined,
loadMore,
error,
};
};
const useIntersectionObserver = (callback: () => void) => {

View file

@ -60,7 +60,7 @@ import {
defaultDmsRenderer,
defaultRoomsRenderer,
} from "../views/dialogs/AddExistingToSpaceDialog";
import AccessibleButton from "../views/elements/AccessibleButton";
import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton";
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import ErrorBoundary from "../views/elements/ErrorBoundary";
import Field from "../views/elements/Field";
@ -295,7 +295,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
/>;
});
const onNextClick = async (ev) => {
const onNextClick = async (ev: ButtonEvent) => {
ev.preventDefault();
if (busy) return;
setError("");
@ -326,7 +326,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
setBusy(false);
};
let onClick = (ev) => {
let onClick = (ev: ButtonEvent) => {
ev.preventDefault();
onFinished();
};

View file

@ -24,7 +24,6 @@ import { e2eEncryptionScenarios } from './scenarios/e2e-encryption';
import { ElementSession } from "./session";
import { RestSessionCreator } from "./rest/creator";
import { RestMultiSession } from "./rest/multi";
import { spacesScenarios } from './scenarios/spaces';
import { RestSession } from "./rest/session";
import { stickerScenarios } from './scenarios/sticker';
import { userViewScenarios } from "./scenarios/user-view";
@ -56,8 +55,6 @@ export async function scenario(createSession: (s: string) => Promise<ElementSess
console.log("create REST users:");
const charlies = await createRestUsers(restCreator);
await lazyLoadingScenarios(alice, bob, charlies);
// do spaces scenarios last as the rest of the alice/bob tests may get confused by spaces
await spacesScenarios(alice, bob);
// we spawn another session for stickers, partially because it involves injecting
// a custom sticker picker widget for the account, although mostly because for these

View file

@ -1,33 +0,0 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { createSpace, inviteSpace } from "../usecases/create-space";
import { ElementSession } from "../session";
export async function spacesScenarios(alice: ElementSession, bob: ElementSession): Promise<void> {
console.log(" creating a space for spaces scenarios:");
await alice.delay(1000); // wait for dialogs to close
await setupSpaceUsingAliceAndInviteBob(alice, bob);
}
const space = "Test Space";
async function setupSpaceUsingAliceAndInviteBob(alice: ElementSession, bob: ElementSession): Promise<void> {
await createSpace(alice, space);
await inviteSpace(alice, space, "@bob:localhost");
await bob.query(`.mx_SpaceButton[aria-label="${space}"]`); // assert invite received
}

View file

@ -1,82 +0,0 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { ElementSession } from "../session";
export async function openSpaceCreateMenu(session: ElementSession): Promise<void> {
const spaceCreateButton = await session.query('.mx_SpaceButton_new');
await spaceCreateButton.click();
}
export async function createSpace(session: ElementSession, name: string, isPublic = false): Promise<void> {
session.log.step(`creates space "${name}"`);
await openSpaceCreateMenu(session);
const className = isPublic ? ".mx_SpaceCreateMenuType_public" : ".mx_SpaceCreateMenuType_private";
const visibilityButton = await session.query(className);
await visibilityButton.click();
const nameInput = await session.query('input[name="spaceName"]');
await session.replaceInputText(nameInput, name);
await session.delay(100);
const createButton = await session.query('.mx_SpaceCreateMenu_wrapper .mx_AccessibleButton_kind_primary');
await createButton.click();
if (!isPublic) {
const justMeButton = await session.query('.mx_SpaceRoomView_privateScope_justMeButton');
await justMeButton.click();
const continueButton = await session.query('.mx_AddExistingToSpace_footer .mx_AccessibleButton_kind_primary');
await continueButton.click();
} else {
for (let i = 0; i < 2; i++) {
const continueButton = await session.query('.mx_SpaceRoomView_buttons .mx_AccessibleButton_kind_primary');
await continueButton.click();
}
}
session.log.done();
}
export async function inviteSpace(session: ElementSession, spaceName: string, userId: string): Promise<void> {
session.log.step(`invites "${userId}" to space "${spaceName}"`);
const spaceButton = await session.query(`.mx_SpaceButton[aria-label="${spaceName}"]`);
await spaceButton.click({
button: 'right',
});
const inviteButton = await session.query('.mx_SpacePanel_contextMenu_inviteButton[aria-label="Invite"]');
await inviteButton.click();
try {
// You only get this interstitial if it's a public space, so give up after 200ms
// if it hasn't appeared
const button = await session.query('.mx_SpacePublicShare_inviteButton', 200);
await button.click();
} catch (e) {
// ignore
}
const inviteTextArea = await session.query(".mx_InviteDialog_editor input");
await inviteTextArea.type(userId);
const selectUserItem = await session.query(".mx_InviteDialog_roomTile");
await selectUserItem.click();
const confirmButton = await session.query(".mx_InviteDialog_goButton");
await confirmButton.click();
session.log.done();
}

View file

@ -3492,6 +3492,11 @@ csstype@^3.0.2:
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.0.tgz#4ddcac3718d787cf9df0d1b7d15033925c8f29f2"
integrity sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA==
cypress-real-events@^1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/cypress-real-events/-/cypress-real-events-1.7.0.tgz#ad6a78de33af3af0e6437f5c713e30691c44472c"
integrity sha512-iyXp07j0V9sG3YClVDcvHN2DAQDgr+EjTID82uWDw6OZBlU3pXEBqTMNYqroz3bxlb0k+F74U81aZwzMNaKyew==
cypress@^9.6.1:
version "9.6.1"
resolved "https://registry.yarnpkg.com/cypress/-/cypress-9.6.1.tgz#a7d6b5a53325b3dc4960181f5800a5ade0f085eb"