@dxos/react-ui 0.1.39-next.90cb9b6 → 0.1.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -31,7 +31,10 @@ export const IdentityListItem = ({
31
31
  ...(presence === SpaceMember.PresenceState.OFFLINE && {
32
32
  status: 'inactive',
33
33
  description: (
34
- <p className='font-system-normal text-xs text-neutral-700 dark:text-neutral-300'>
34
+ <p
35
+ className='font-system-normal text-xs text-neutral-700 dark:text-neutral-300'
36
+ data-testid='identity-list-item.description'
37
+ >
35
38
  {t('identity offline description')}
36
39
  </p>
37
40
  )
@@ -19,8 +19,7 @@ export const InvitationListContainer = ({ spaceKey, createInvitationUrl }: Invit
19
19
  const invitations = useSpaceInvitations(spaceKey);
20
20
  const onClickRemove = useCallback(
21
21
  (invitation: CancellableInvitationObservable) => {
22
- const invitationId = invitation.get().invitationId;
23
- invitationId && space?.removeInvitation(invitationId);
22
+ void invitation.cancel();
24
23
  },
25
24
  [space]
26
25
  );
@@ -64,9 +64,7 @@ const DeviceListView = ({ createInvitationUrl, titleId, onDone, doneActionParent
64
64
  <div role='region' className={mx(defaultSurface, 'rounded-be-md p-2')}>
65
65
  <InvitationList
66
66
  invitations={invitations}
67
- onClickRemove={(invitation) =>
68
- invitation.get() && client.halo.removeInvitation(invitation.get().invitationId)
69
- }
67
+ onClickRemove={(invitation) => invitation.cancel()}
70
68
  createInvitationUrl={createInvitationUrl}
71
69
  />
72
70
  <Button
@@ -322,24 +322,38 @@ const joinMachine = createMachine<JoinMachineContext, JoinEvent>(
322
322
  identity: () => null
323
323
  }),
324
324
  resetInvitation: assign<JoinMachineContext, EmptyInvitationEvent>({
325
- halo: (context, event) =>
326
- event.type === 'resetHaloInvitation'
327
- ? {
328
- ...context.halo,
329
- invitation: undefined,
330
- invitationObservable: undefined,
331
- invitationSubscribable: undefined
332
- }
333
- : context.halo,
334
- space: (context, event) =>
335
- event.type === 'resetSpaceInvitation'
336
- ? {
337
- ...context.space,
338
- invitation: undefined,
339
- invitationObservable: undefined,
340
- invitationSubscribable: undefined
341
- }
342
- : context.space
325
+ halo: (context, event) => {
326
+ if (event.type !== 'resetHaloInvitation') {
327
+ return context.halo;
328
+ }
329
+
330
+ if (context.halo.invitationObservable) {
331
+ void context.halo.invitationObservable.cancel();
332
+ }
333
+
334
+ return {
335
+ ...context.halo,
336
+ invitation: undefined,
337
+ invitationObservable: undefined,
338
+ invitationSubscribable: undefined
339
+ };
340
+ },
341
+ space: (context, event) => {
342
+ if (event.type !== 'resetSpaceInvitation') {
343
+ return context.space;
344
+ }
345
+
346
+ if (context.space.invitationObservable) {
347
+ void context.space.invitationObservable.cancel();
348
+ }
349
+
350
+ return {
351
+ ...context.space,
352
+ invitation: undefined,
353
+ invitationObservable: undefined,
354
+ invitationSubscribable: undefined
355
+ };
356
+ }
343
357
  }),
344
358
  setInvitation: assign<JoinMachineContext, SetInvitationEvent>({
345
359
  halo: (context, event) =>
@@ -397,6 +411,7 @@ const useJoinMachine = (client: Client, options?: Parameters<typeof useMachine<J
397
411
  },
398
412
  [client]
399
413
  );
414
+
400
415
  const redeemSpaceInvitationCode = useCallback(
401
416
  ({ space }: JoinMachineContext) => {
402
417
  if (space.unredeemedCode) {
@@ -414,6 +429,7 @@ const useJoinMachine = (client: Client, options?: Parameters<typeof useMachine<J
414
429
  },
415
430
  [client]
416
431
  );
432
+
417
433
  return useMachine(joinMachine, {
418
434
  ...options,
419
435
  actions: {
@@ -52,7 +52,7 @@ const CurrentSpaceView = observer(({ space, createInvitationUrl, titleId }: Spac
52
52
  <div role='region' className={mx(defaultSurface, 'rounded-be-md p-2')}>
53
53
  <InvitationList
54
54
  invitations={invitations}
55
- onClickRemove={(invitation) => invitation.get() && space?.removeInvitation(invitation.get().invitationId)}
55
+ onClickRemove={(invitation) => invitation.cancel()}
56
56
  createInvitationUrl={createInvitationUrl}
57
57
  />
58
58
  <Button
@@ -34,7 +34,7 @@ export class InvitationsManager extends ShellManager {
34
34
 
35
35
  const { page } = await setupPage(this._browser, {
36
36
  url: storybookUrl('invitations--default'),
37
- waitFor: (page) => page.getByTestId('invitations.identity-header').nth(0).isVisible()
37
+ waitFor: (page) => page.getByTestId('invitations.identity-header').first().isVisible()
38
38
  });
39
39
 
40
40
  this.page = page;
@@ -44,9 +44,24 @@ export class InvitationsManager extends ShellManager {
44
44
 
45
45
  // Getters
46
46
 
47
+ peer(id: number) {
48
+ return this.page.getByTestId(`peer-${id}`);
49
+ }
50
+
51
+ async getNetworkStatus(id: number) {
52
+ const selector = this.peer(id).getByTestId('identity-list-item.description').first();
53
+
54
+ try {
55
+ await selector.waitFor({ timeout: 500 });
56
+ return ConnectionState.OFFLINE;
57
+ } catch {
58
+ return ConnectionState.ONLINE;
59
+ }
60
+ }
61
+
47
62
  async getDisplayName(id: number) {
48
63
  // TODO(wittjosiah): Update id.
49
- return this.peer(id).getByTestId('identity-list-item').nth(0).textContent();
64
+ return this.peer(id).getByTestId('identity-list-item').first().textContent();
50
65
  }
51
66
 
52
67
  async getSpaceName(id: number, nth: number) {
@@ -54,15 +69,15 @@ export class InvitationsManager extends ShellManager {
54
69
  return this.peer(id).getByTestId('space-list-item').nth(nth).textContent();
55
70
  }
56
71
 
72
+ async getAuthCode(): Promise<string> {
73
+ this._authCode = new Trigger<string>();
74
+ return await this._authCode.wait();
75
+ }
76
+
57
77
  // Actions
58
78
 
59
- async setConnectionState(id: number, state: ConnectionState) {
60
- await this.page.evaluate(
61
- ({ id, state }) => {
62
- (window as any)[`peer${id}client`].mesh.setConnectionState(state);
63
- },
64
- { id, state }
65
- );
79
+ async toggleNetworkStatus(id: number) {
80
+ await this.peer(id).getByTestId('invitations.toggle-network').click();
66
81
  }
67
82
 
68
83
  async openPanel(id: number, panel: PanelType) {
@@ -137,15 +152,6 @@ export class InvitationsManager extends ShellManager {
137
152
  await this.page.keyboard.press('Enter');
138
153
  }
139
154
 
140
- async getAuthCode(): Promise<string> {
141
- this._authCode = new Trigger<string>();
142
- return await this._authCode.wait();
143
- }
144
-
145
- peer(id: number) {
146
- return this.page.getByTestId(`peer-${id}`);
147
- }
148
-
149
155
  private async _onConsoleMessage(message: ConsoleMessage) {
150
156
  try {
151
157
  const json = JSON.parse(message.text());
@@ -4,7 +4,6 @@
4
4
 
5
5
  import { test } from '@playwright/test';
6
6
  import { expect } from 'chai';
7
- import waitForExpect from 'wait-for-expect';
8
7
 
9
8
  import { sleep } from '@dxos/async';
10
9
  import { ConnectionState, Invitation } from '@dxos/protocols/proto/dxos/client/services';
@@ -14,6 +13,13 @@ import { InvitationsManager } from './invitations-manager';
14
13
  test.describe('Invitations', () => {
15
14
  let manager: InvitationsManager;
16
15
 
16
+ // TODO(wittjosiah): Storybook takes a bit to be ready for testing against.
17
+ test.beforeAll(async ({ browser }) => {
18
+ test.setTimeout(60_000);
19
+ manager = new InvitationsManager(browser);
20
+ await manager.init();
21
+ });
22
+
17
23
  test.beforeEach(async ({ browser }) => {
18
24
  manager = new InvitationsManager(browser);
19
25
  await manager.init();
@@ -46,6 +52,8 @@ test.describe('Invitations', () => {
46
52
  });
47
53
 
48
54
  test('invalid & retry auth code', async () => {
55
+ test.slow();
56
+
49
57
  await manager.createIdentity(0);
50
58
  await manager.openPanel(0, 'devices');
51
59
  const invitation = await manager.createInvitation(0, 'device');
@@ -61,6 +69,8 @@ test.describe('Invitations', () => {
61
69
  });
62
70
 
63
71
  test('invalid & max auth code retries reached, retry invitation', async () => {
72
+ test.slow();
73
+
64
74
  await manager.createIdentity(0);
65
75
  await manager.openPanel(0, 'devices');
66
76
  const invitation = await manager.createInvitation(0, 'device');
@@ -95,10 +105,7 @@ test.describe('Invitations', () => {
95
105
  await manager.openPanel(1, 'identity');
96
106
  await manager.acceptInvitation(1, 'device', invitation);
97
107
 
98
- // Wait for timeout to fire.
99
- await waitForExpect(async () => {
100
- expect(await manager.invitationFailed(manager.peer(1))).to.be.true;
101
- }, 100);
108
+ expect(await manager.invitationFailed(manager.peer(1))).to.be.true;
102
109
  });
103
110
 
104
111
  test('invitation cancelled by host', async () => {
@@ -108,14 +115,10 @@ test.describe('Invitations', () => {
108
115
 
109
116
  await manager.openPanel(1, 'identity');
110
117
  await manager.acceptInvitation(1, 'device', invitation);
111
- // Wait for invitation to connect before cancelling it.
112
- await sleep(100);
118
+ expect(await manager.readyToAuthenticate('device', manager.peer(1))).to.be.true;
113
119
  await manager.cancelInvitation('device', 'host', manager.peer(0));
114
120
 
115
- // Wait for cancellation to propagate.
116
- await waitForExpect(async () => {
117
- expect(await manager.invitationFailed(manager.peer(1))).to.be.true;
118
- }, 100);
121
+ expect(await manager.invitationFailed(manager.peer(1))).to.be.true;
119
122
  });
120
123
 
121
124
  test('invitation cancelled by guest & retry', async () => {
@@ -136,18 +139,19 @@ test.describe('Invitations', () => {
136
139
  });
137
140
 
138
141
  test('recover from network failure during invitation', async () => {
142
+ test.slow();
143
+
139
144
  await manager.createIdentity(0);
140
145
  await manager.openPanel(0, 'devices');
141
146
  const invitation = await manager.createInvitation(0, 'device');
142
147
 
143
148
  await manager.openPanel(1, 'identity');
144
149
  await manager.acceptInvitation(1, 'device', invitation);
145
- await sleep(100);
146
- await manager.setConnectionState(0, ConnectionState.OFFLINE);
147
- await sleep(100);
148
- await manager.setConnectionState(0, ConnectionState.ONLINE);
149
- // TODO(wittjosiah): Requires attempting an action to discover the network failure.
150
- await manager.authenticateInvitation('device', '00000', manager.peer(1));
150
+ expect(await manager.readyToAuthenticate('device', manager.peer(1))).to.be.true;
151
+ await manager.toggleNetworkStatus(0);
152
+ expect(await manager.getNetworkStatus(0)).to.equal(ConnectionState.OFFLINE);
153
+ await manager.toggleNetworkStatus(0);
154
+ expect(await manager.getNetworkStatus(0)).to.equal(ConnectionState.ONLINE);
151
155
  await manager.resetInvitation(manager.peer(1));
152
156
  await manager.invitationInputContinue('device', manager.peer(1));
153
157
  const [authCode] = await Promise.all([manager.getAuthCode(), manager.clearAuthCode('device', manager.peer(1))]);
@@ -158,24 +162,30 @@ test.describe('Invitations', () => {
158
162
  });
159
163
 
160
164
  test('multiple concurrent invitations', async () => {
165
+ test.slow();
166
+
161
167
  await manager.createIdentity(0);
162
168
  await manager.openPanel(0, 'devices');
163
- const invitation1 = await manager.createInvitation(0, 'device');
164
- const invitation2 = await manager.createInvitation(0, 'device');
165
-
166
169
  await manager.openPanel(1, 'identity');
167
170
  await manager.openPanel(2, 'identity');
171
+
172
+ // TODO(wittjosiah): Improve auth code fetching to make it easier to disambiguate them.
173
+ const invitation1 = await manager.createInvitation(0, 'device');
168
174
  const [authCode1] = await Promise.all([
169
175
  manager.getAuthCode(),
170
176
  manager.acceptInvitation(1, 'device', invitation1)
171
177
  ]);
172
- // Prevent auth code from being reused.
173
- await sleep(100);
178
+
179
+ const invitation2 = await manager.createInvitation(0, 'device');
174
180
  const [authCode2] = await Promise.all([
175
181
  manager.getAuthCode(),
176
182
  manager.acceptInvitation(2, 'device', invitation2)
177
183
  ]);
184
+
178
185
  await manager.authenticateInvitation('device', authCode1, manager.peer(1));
186
+ // TODO(wittjosiah): Managing focus in tests is flaky.
187
+ // Helps to ensure both auth codes are fully input (especially in webkit).
188
+ await sleep(100);
179
189
  await manager.authenticateInvitation('device', authCode2, manager.peer(2));
180
190
  await manager.doneInvitation('device', manager.peer(1));
181
191
  await manager.doneInvitation('device', manager.peer(2));
@@ -219,6 +229,8 @@ test.describe('Invitations', () => {
219
229
  });
220
230
 
221
231
  test('invalid & retry auth code', async () => {
232
+ test.slow();
233
+
222
234
  await manager.createIdentity(0);
223
235
  await manager.createSpace(0);
224
236
  await manager.openPanel(0, 0);
@@ -240,6 +252,8 @@ test.describe('Invitations', () => {
240
252
  });
241
253
 
242
254
  test('invalid & max auth code retries reached, retry invitation', async () => {
255
+ test.slow();
256
+
243
257
  await manager.createIdentity(0);
244
258
  await manager.createSpace(0);
245
259
  await manager.openPanel(0, 0);
@@ -279,10 +293,7 @@ test.describe('Invitations', () => {
279
293
  await manager.openPanel(1, 'join');
280
294
  await manager.acceptInvitation(1, 'space', invitation);
281
295
 
282
- // Wait for timeout to fire.
283
- await waitForExpect(async () => {
284
- expect(await manager.invitationFailed(manager.peer(1))).to.be.true;
285
- }, 100);
296
+ expect(await manager.invitationFailed(manager.peer(1))).to.be.true;
286
297
  });
287
298
 
288
299
  test('invitation cancelled by host', async () => {
@@ -294,14 +305,10 @@ test.describe('Invitations', () => {
294
305
  await manager.createIdentity(1);
295
306
  await manager.openPanel(1, 'join');
296
307
  await manager.acceptInvitation(1, 'space', invitation);
297
- // Wait for invitation to connect before cancelling it.
298
- await sleep(100);
308
+ expect(await manager.readyToAuthenticate('space', manager.peer(1))).to.be.true;
299
309
  await manager.cancelInvitation('space', 'host', manager.peer(0));
300
310
 
301
- // Wait for cancellation to propagate.
302
- await waitForExpect(async () => {
303
- expect(await manager.invitationFailed(manager.peer(1))).to.be.true;
304
- }, 100);
311
+ expect(await manager.invitationFailed(manager.peer(1))).to.be.true;
305
312
  });
306
313
 
307
314
  test('invitation cancelled by guest & retry', async () => {
@@ -327,6 +334,8 @@ test.describe('Invitations', () => {
327
334
  });
328
335
 
329
336
  test('recover from network failure during invitation', async () => {
337
+ test.slow();
338
+
330
339
  await manager.createIdentity(0);
331
340
  await manager.createSpace(0);
332
341
  await manager.openPanel(0, 0);
@@ -335,12 +344,11 @@ test.describe('Invitations', () => {
335
344
  await manager.createIdentity(1);
336
345
  await manager.openPanel(1, 'join');
337
346
  await manager.acceptInvitation(1, 'space', invitation);
338
- await sleep(100);
339
- await manager.setConnectionState(0, ConnectionState.OFFLINE);
340
- await sleep(100);
341
- await manager.setConnectionState(0, ConnectionState.ONLINE);
342
- // TODO(wittjosiah): Requires attempting an action to discover the network failure.
343
- await manager.authenticateInvitation('space', '00000', manager.peer(1));
347
+ expect(await manager.readyToAuthenticate('space', manager.peer(1))).to.be.true;
348
+ await manager.toggleNetworkStatus(0);
349
+ expect(await manager.getNetworkStatus(0)).to.equal(ConnectionState.OFFLINE);
350
+ await manager.toggleNetworkStatus(0);
351
+ expect(await manager.getNetworkStatus(0)).to.equal(ConnectionState.ONLINE);
344
352
  await manager.resetInvitation(manager.peer(1));
345
353
  await manager.invitationInputContinue('space', manager.peer(1));
346
354
  const [authCode] = await Promise.all([manager.getAuthCode(), manager.clearAuthCode('space', manager.peer(1))]);
@@ -352,21 +360,26 @@ test.describe('Invitations', () => {
352
360
  });
353
361
 
354
362
  test('multiple concurrent invitations', async () => {
355
- await manager.createIdentity(0);
356
- await manager.createSpace(0);
357
- await manager.openPanel(0, 0);
358
- const invitation1 = await manager.createInvitation(0, 'space');
359
- const invitation2 = await manager.createInvitation(0, 'space');
363
+ test.slow();
360
364
 
365
+ await manager.createIdentity(0);
361
366
  await manager.createIdentity(1);
362
367
  await manager.createIdentity(2);
368
+
369
+ await manager.createSpace(0);
370
+ await manager.openPanel(0, 0);
363
371
  await manager.openPanel(1, 'join');
364
372
  await manager.openPanel(2, 'join');
373
+
374
+ const invitation1 = await manager.createInvitation(0, 'space');
365
375
  const [authCode1] = await Promise.all([manager.getAuthCode(), manager.acceptInvitation(1, 'space', invitation1)]);
366
- // Prevent auth code from being reused.
367
- await sleep(100);
376
+
377
+ const invitation2 = await manager.createInvitation(0, 'space');
368
378
  const [authCode2] = await Promise.all([manager.getAuthCode(), manager.acceptInvitation(2, 'space', invitation2)]);
379
+
369
380
  await manager.authenticateInvitation('space', authCode1, manager.peer(1));
381
+ // Helps to ensure both auth codes are fully input (especially in webkit).
382
+ await sleep(100);
370
383
  await manager.authenticateInvitation('space', authCode2, manager.peer(2));
371
384
  await manager.doneInvitation('space', manager.peer(1));
372
385
  await manager.doneInvitation('space', manager.peer(2));
@@ -4,10 +4,11 @@
4
4
 
5
5
  import '@dxosTheme';
6
6
  import { faker } from '@faker-js/faker';
7
- import { Intersect, Laptop, Planet, Plus, PlusCircle, QrCode } from '@phosphor-icons/react';
7
+ import { Intersect, Laptop, Planet, Plus, PlusCircle, QrCode, WifiHigh, WifiSlash } from '@phosphor-icons/react';
8
8
  import React, { useMemo, useState } from 'react';
9
9
 
10
- import { Space, SpaceProxy, useClient, useIdentity, useSpaces } from '@dxos/react-client';
10
+ import { ConnectionState, SpaceMember } from '@dxos/protocols/proto/dxos/client/services';
11
+ import { Space, SpaceProxy, useClient, useIdentity, useNetworkStatus, useSpaces } from '@dxos/react-client';
11
12
  import { ClientDecorator } from '@dxos/react-client/testing';
12
13
  import { Button, ButtonGroup, getSize, Group } from '@dxos/react-components';
13
14
 
@@ -96,6 +97,7 @@ const Panel = ({ id, panel, setPanel }: { id: number; panel?: PanelType; setPane
96
97
 
97
98
  const render = ({ id }: { id: number }) => {
98
99
  const client = useClient();
100
+ const networkStatus = useNetworkStatus().state;
99
101
  const identity = useIdentity();
100
102
  const [panel, setPanel] = useState<PanelType>();
101
103
 
@@ -138,6 +140,22 @@ const render = ({ id }: { id: number }) => {
138
140
  <Planet weight='fill' className={getSize(6)} />
139
141
  </Button>
140
142
  {/* </Tooltip> */}
143
+ {/* <ToolTip content='Toggle Network'> */}
144
+ <Button
145
+ onClick={() =>
146
+ client.mesh.setConnectionState(
147
+ networkStatus === ConnectionState.ONLINE ? ConnectionState.OFFLINE : ConnectionState.ONLINE
148
+ )
149
+ }
150
+ data-testid='invitations.toggle-network'
151
+ >
152
+ {networkStatus === ConnectionState.ONLINE ? (
153
+ <WifiHigh weight='fill' className={getSize(6)} />
154
+ ) : (
155
+ <WifiSlash weight='fill' className={getSize(6)} />
156
+ )}
157
+ </Button>
158
+ {/* </ToolTip> */}
141
159
  </ButtonGroup>
142
160
  );
143
161
 
@@ -152,7 +170,11 @@ const render = ({ id }: { id: number }) => {
152
170
  return (
153
171
  <div className='flex flex-col p-4 flex-1 min-w-0' data-testid={`peer-${id}`}>
154
172
  <Group label={{ children: header }} className='mbe-2'>
155
- {identity ? <IdentityListItem identity={identity} /> : <div className='text-center'>No identity</div>}
173
+ {identity ? (
174
+ <IdentityListItem identity={identity} presence={networkStatus as unknown as SpaceMember.PresenceState} />
175
+ ) : (
176
+ <div className='text-center'>No identity</div>
177
+ )}
156
178
  </Group>
157
179
  {identity || panel ? <Panel id={id} panel={panel} setPanel={setPanel} /> : null}
158
180
  </div>
@@ -13,8 +13,14 @@ export class ShellManager {
13
13
  return (scope || this.page).getByTestId(`${type === 'device' ? 'halo' : 'space'}-auth-code-input`).isVisible();
14
14
  }
15
15
 
16
- invitationFailed(scope?: Scope) {
17
- return (scope || this.page).getByTestId('invitation-rescuer-reset').isVisible();
16
+ async invitationFailed(scope?: Scope, timeout = 3000) {
17
+ const peer = scope || this.page;
18
+ try {
19
+ await peer.locator('[data-testid=invitation-rescuer-reset]:not([disabled])').waitFor({ timeout });
20
+ return true;
21
+ } catch {
22
+ return false;
23
+ }
18
24
  }
19
25
 
20
26
  async inputInvitation(type: 'device' | 'space', invitation: string, scope?: Scope) {
@@ -36,6 +42,18 @@ export class ShellManager {
36
42
  }
37
43
  }
38
44
 
45
+ async readyToAuthenticate(type: 'device' | 'space', scope?: Scope, timeout = 3000) {
46
+ const peer = scope || this.page;
47
+ try {
48
+ await peer
49
+ .locator(`[data-testid=${type === 'device' ? 'halo' : 'space'}-auth-code-input]:not([disabled])`)
50
+ .waitFor({ timeout });
51
+ return true;
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
39
57
  async authenticateInvitation(type: 'device' | 'space', authCode: string, scope?: Scope) {
40
58
  const peer = scope || this.page;
41
59
  // TODO(wittjosiah): Update ids.