Compare commits

...

42 Commits

Author SHA1 Message Date
Trent Larson b8181f6ae3 fix error sharing image and failing to upload, fix upload in webkit/safari, and test it 1 month ago
Jose Olarte III bcc0fac0a8 Playwright: added ID to spinbutton 1 month ago
Trent Larson f724476ed6 tweak instructions for minimal test data 1 month ago
Jose Olarte III 6ca5bf754b (Switch back to test server) 1 month ago
Jose Olarte III 25eaff62d8 Playwright: expended contact test 1 month ago
Jose Olarte III dd1532e2f4 Playwright: test against created records 2 months ago
Jose Olarte III 7033d259e1 Playwright: added import 2 months ago
Jose Olarte III aae2e62177 Playwright: removed redundant tests 2 months ago
Jose Olarte III 9a9c2b1813 Playwright: combined no-ID tests 2 months ago
Jose Olarte III ee75576cda Playwright: implemented importUser 2 months ago
Jose Olarte III 88efa36542 Playwright: importUser function 2 months ago
Trent Larson fe1cd32be1 bump version and add "-beta" 2 months ago
Trent Larson c8f0f2c2b1 bump to version 0.3.15, fix a README instruction 2 months ago
Trent Larson 7aaf981b71 remove unused ethr-did-resolver (since it has vulerabilities and we're not using it and we can use the local one) 2 months ago
Trent Larson ca8da9fd5e add 'isRegistered' check to guard against many buttons 2 months ago
Trent Larson ff3d397150 move pointers to other projects up in the project view 2 months ago
Trent Larson 052d5c5bd1 add a test for empty ID, fix some linting 2 months ago
Trent Larson 9213ad1f4a remove unused code 2 months ago
Trent Larson 98f4665465 comment out a breaking test on local data & enhance those instructions 2 months ago
Jose Olarte III c5f5e81f2c Playwright: check ID generation 2 months ago
Jose Olarte III 26f6eb66fe Playwright: additional checks to add contact 2 months ago
Jose Olarte III 28dd602d9f ID-specific locators 2 months ago
Jose Olarte III fb0a64c0ab Mirrored browser selection 2 months ago
Jose Olarte III 88f41b885c Added IDs for Playwright targeting 2 months ago
Trent Larson bbf621fb18 make instructions for an Endorser server started from scratch 2 months ago
Jose Olarte III a0adc1517c Playwright: check usage limits (no-ID and with-ID) 2 months ago
Jose Olarte III 812c8a418e Playwright: confirm contact appears on home feed 2 months ago
Jose Olarte III 2f0326f182 Corrected some test labels 2 months ago
Trent Larson 1fbd1da87d add visibility flag set, refactor to see results, and add copy icons for contact info 2 months ago
Trent Larson d96770a351 move copy icon for DIDs on contact screen 2 months ago
Trent Larson 8d684f1b29 tweak verbiage and make other UI tweaks 2 months ago
Trent Larson 6272b3045b fix where it doesn't remove the plan when editing and removing it 2 months ago
Trent Larson 375d6ddbe2 fix problem detecting plans when editing gifts 2 months ago
Trent Larson f497c53294 hide the details of a claim by default 2 months ago
Trent Larson cb8aeeac1b show full contact details, plus other tweaks 2 months ago
Trent Larson 6191a4893f add a config for local testing, plus add mobile testing and some instructions 2 months ago
Trent Larson 791a35d97c fix one linting error 2 months ago
Trent Larson 5647c4627f import & update selected contacts 2 months ago
Jose Olarte III 7dfc377610 Playwright: check no-ID messaging 2 months ago
Jose Olarte III 11a3e981a6 Playwright: check test API 2 months ago
Trent Larson cd04f35224 remove example test file 2 months ago
Jose Olarte III 59f97ffc28 Optimize tests 2 months ago
  1. 2
      .env.production
  2. 22
      CHANGELOG.md
  3. 27
      README.md
  4. 134
      package-lock.json
  5. 3
      package.json
  6. 98
      playwright.config-local.ts
  7. 25
      playwright.config.ts
  8. 1
      src/components/GiftedDialog.vue
  9. 1
      src/components/PhotoDialog.vue
  10. 3
      src/db/tables/temp.ts
  11. 19
      src/libs/endorserServer.ts
  12. 35
      src/libs/util.ts
  13. 5
      src/router/index.ts
  14. 109
      src/views/AccountViewView.vue
  15. 3
      src/views/ClaimAddRawView.vue
  16. 20
      src/views/ClaimView.vue
  17. 42
      src/views/ConfirmGiftView.vue
  18. 190
      src/views/ContactImportView.vue
  19. 94
      src/views/ContactsView.vue
  20. 22
      src/views/DIDView.vue
  21. 2
      src/views/DiscoverView.vue
  22. 9
      src/views/GiftedDetails.vue
  23. 10
      src/views/HomeView.vue
  24. 178
      src/views/ProjectViewView.vue
  25. 16
      src/views/ProjectsView.vue
  26. 18
      src/views/SharedPhotoView.vue
  27. 12
      src/views/TestView.vue
  28. 12
      test-playwright/00-check-activity-feed.spec.ts
  29. 81
      test-playwright/00-noid-tests.spec.ts
  30. 12
      test-playwright/01-check-discover-results.spec.ts
  31. 18
      test-playwright/10-check-usage-limits.spec.ts
  32. 19
      test-playwright/10-create-user-00.spec.ts
  33. 19
      test-playwright/11-create-user-01.spec.ts
  34. 35
      test-playwright/20-create-project.spec.ts
  35. 63
      test-playwright/21-create-project-search.spec.ts
  36. 18
      test-playwright/30-record-gift.spec.ts
  37. 40
      test-playwright/35-record-gift-from-image-share.spec.ts
  38. 89
      test-playwright/40-add-contact.spec.ts
  39. 32
      test-playwright/testUtils.js
  40. 437
      tests-examples/demo-todo-app.spec.ts

2
.env.production

@ -1,4 +1,4 @@
# Only the variables that start with VITE_ are seen in the application process.env in Vue.
# Only the variables that start with VITE_ are seen in the application import.meta.env in Vue.
VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01GXYPFF7FA03NXKPYY142PY4H
VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app

22
CHANGELOG.md

@ -6,7 +6,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.3.14]
## [0.3.15] - 2024.06.22 - c8f0f2c2b16b9f0b4b47d40f7bf29058c7baa68e
### Added
- Edit gives
- Page to edit claim JSON before submitting
- Update of imported contacts
- Improve messaging on give dialog
- Section for gives provided by plan
- Deletion of an identity
- UI for choosing a passkey creation (not enabled on prod)
- Cache signatures for reports for passkey-signed requests
- Refactor: consolidate alternative signing, eg. for passkeys & did:peer
- Playwright tests
### Changed
- Linked projects display below description (instead of at bottom)
### Fixed
- Visibility toggle appearance
### Changed in DB or environment
- Nothing
## [0.3.14] - 2024.06.22 - 1611d22892f683f43856d2503eee7f391b6bbce8
### Added
- Clearer give-confirmation screen
- BX currency https://thebx.medium.com/

27
README.md

@ -47,7 +47,7 @@ npm run lint
```
# (Let's replace this with a .env.development or .env.staging file.)
# The test BVC_MEETUPS_PROJECT_CLAIM_ID does not resolve as a URL because it's only in the test DB and the prod redirect won't redirect there.
TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HNTZYJJXTGT0EZS3VEJGX7AK VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app PASSKEYS_ENABLED=yep npm run build
TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HNTZYJJXTGT0EZS3VEJGX7AK VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_PASSKEYS_ENABLED=yep npm run build
```
* Production
@ -56,7 +56,7 @@ TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https:
npm run build
```
* Get on the server and back up 3 DBs and the time-safari folder.
* Get on the server and back up the time-safari/dist folder.
* `rsync -azvu -e "ssh -i ~/.ssh/..." dist ubuntutest@test.timesafari.app:time-safari`
@ -71,8 +71,31 @@ npm run build
### Automated
Using the global test Endorser (ledger) server:
`npx playwright test`
Using the locally running Endorser server:
* Clone and set up https://github.com/trentlarson/endorser-ch and run the following in that directory:
```
test/test.sh
NODE_ENV=test-local npm run dev
```
Alternatively, you can run the following which starts with the bare minimum test data:
```
rm ../endorser-ch-test-local.sqlite3
NODE_ENV=test-local npm run flyway migrate
NODE_ENV=test-local npm run test test/controller0
NODE_ENV=test-local npm run dev
```
* Now run the local tests:
```
npx playwright test -c playwright.config-local.ts
```
### Register new user on test server
On the test server, User #0 has rights to register others, so you can start

134
package-lock.json

@ -1,12 +1,12 @@
{
"name": "TimeSafari",
"version": "0.3.15-beta",
"version": "0.3.16-beta",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "TimeSafari",
"version": "0.3.15-beta",
"version": "0.3.16-beta",
"dependencies": {
"@dicebear/collection": "^5.4.1",
"@dicebear/core": "^5.4.1",
@ -39,7 +39,6 @@
"did-jwt": "^7.4.7",
"ethereum-cryptography": "^2.1.3",
"ethereumjs-util": "^7.1.5",
"ethr-did-resolver": "^8.1.2",
"jdenticon": "^3.2.0",
"js-generate-password": "^0.1.9",
"js-yaml": "^4.1.0",
@ -3209,31 +3208,6 @@
"node": ">=14"
}
},
"node_modules/@ethersproject/abi": {
"version": "5.7.0",
"funding": [
{
"type": "individual",
"url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2"
},
{
"type": "individual",
"url": "https://www.buymeacoffee.com/ricmoo"
}
],
"license": "MIT",
"dependencies": {
"@ethersproject/address": "^5.7.0",
"@ethersproject/bignumber": "^5.7.0",
"@ethersproject/bytes": "^5.7.0",
"@ethersproject/constants": "^5.7.0",
"@ethersproject/hash": "^5.7.0",
"@ethersproject/keccak256": "^5.7.0",
"@ethersproject/logger": "^5.7.0",
"@ethersproject/properties": "^5.7.0",
"@ethersproject/strings": "^5.7.0"
}
},
"node_modules/@ethersproject/abstract-provider": {
"version": "5.7.0",
"funding": [
@ -3387,32 +3361,6 @@
"@ethersproject/bignumber": "^5.7.0"
}
},
"node_modules/@ethersproject/contracts": {
"version": "5.7.0",
"funding": [
{
"type": "individual",
"url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2"
},
{
"type": "individual",
"url": "https://www.buymeacoffee.com/ricmoo"
}
],
"license": "MIT",
"dependencies": {
"@ethersproject/abi": "^5.7.0",
"@ethersproject/abstract-provider": "^5.7.0",
"@ethersproject/abstract-signer": "^5.7.0",
"@ethersproject/address": "^5.7.0",
"@ethersproject/bignumber": "^5.7.0",
"@ethersproject/bytes": "^5.7.0",
"@ethersproject/constants": "^5.7.0",
"@ethersproject/logger": "^5.7.0",
"@ethersproject/properties": "^5.7.0",
"@ethersproject/transactions": "^5.7.0"
}
},
"node_modules/@ethersproject/hash": {
"version": "5.7.0",
"funding": [
@ -3550,60 +3498,6 @@
"@ethersproject/logger": "^5.7.0"
}
},
"node_modules/@ethersproject/providers": {
"version": "5.7.2",
"funding": [
{
"type": "individual",
"url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2"
},
{
"type": "individual",
"url": "https://www.buymeacoffee.com/ricmoo"
}
],
"license": "MIT",
"dependencies": {
"@ethersproject/abstract-provider": "^5.7.0",
"@ethersproject/abstract-signer": "^5.7.0",
"@ethersproject/address": "^5.7.0",
"@ethersproject/base64": "^5.7.0",
"@ethersproject/basex": "^5.7.0",
"@ethersproject/bignumber": "^5.7.0",
"@ethersproject/bytes": "^5.7.0",
"@ethersproject/constants": "^5.7.0",
"@ethersproject/hash": "^5.7.0",
"@ethersproject/logger": "^5.7.0",
"@ethersproject/networks": "^5.7.0",
"@ethersproject/properties": "^5.7.0",
"@ethersproject/random": "^5.7.0",
"@ethersproject/rlp": "^5.7.0",
"@ethersproject/sha2": "^5.7.0",
"@ethersproject/strings": "^5.7.0",
"@ethersproject/transactions": "^5.7.0",
"@ethersproject/web": "^5.7.0",
"bech32": "1.1.4",
"ws": "7.4.6"
}
},
"node_modules/@ethersproject/random": {
"version": "5.7.0",
"funding": [
{
"type": "individual",
"url": "https://gitcoin.co/grants/13/ethersjs-complete-simple-and-tiny-2"
},
{
"type": "individual",
"url": "https://www.buymeacoffee.com/ricmoo"
}
],
"license": "MIT",
"dependencies": {
"@ethersproject/bytes": "^5.7.0",
"@ethersproject/logger": "^5.7.0"
}
},
"node_modules/@ethersproject/rlp": {
"version": "5.7.0",
"funding": [
@ -10483,10 +10377,6 @@
"node": ">=14"
}
},
"node_modules/bech32": {
"version": "1.1.4",
"license": "MIT"
},
"node_modules/better-opn": {
"version": "3.0.2",
"license": "MIT",
@ -12831,24 +12721,6 @@
"ethr-did-resolver": "10.1.5"
}
},
"node_modules/ethr-did-resolver": {
"version": "8.1.2",
"license": "Apache-2.0",
"dependencies": {
"@ethersproject/abi": "^5.6.3",
"@ethersproject/abstract-signer": "^5.6.2",
"@ethersproject/address": "^5.6.1",
"@ethersproject/basex": "^5.6.1",
"@ethersproject/bignumber": "^5.6.2",
"@ethersproject/bytes": "^5.6.1",
"@ethersproject/contracts": "^5.6.2",
"@ethersproject/keccak256": "^5.6.1",
"@ethersproject/providers": "^5.6.8",
"@ethersproject/signing-key": "^5.6.2",
"@ethersproject/transactions": "^5.6.2",
"did-resolver": "^4.0.1"
}
},
"node_modules/ethr-did/node_modules/@noble/ciphers": {
"version": "0.5.1",
"license": "MIT",
@ -22349,6 +22221,8 @@
"node_modules/ws": {
"version": "7.4.6",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=8.3.0"
},

3
package.json

@ -1,6 +1,6 @@
{
"name": "TimeSafari",
"version": "0.3.15-beta",
"version": "0.3.16-beta",
"scripts": {
"dev": "vite",
"serve": "vite preview",
@ -41,7 +41,6 @@
"did-jwt": "^7.4.7",
"ethereum-cryptography": "^2.1.3",
"ethereumjs-util": "^7.1.5",
"ethr-did-resolver": "^8.1.2",
"jdenticon": "^3.2.0",
"js-generate-password": "^0.1.9",
"js-yaml": "^4.1.0",

98
playwright.config-local.ts

@ -0,0 +1,98 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// import dotenv from 'dotenv';
// dotenv.config({ path: path.resolve(__dirname, '.env') });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './test-playwright',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://localhost:8080',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
permissions: ["clipboard-read"],
},
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
/* Test against mobile viewports. */
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
},
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
{
name: 'Google Chrome',
use: { ...devices['Desktop Chrome'], channel: 'chrome' },
},
],
/* Configure global timeout */
// the image upload will often not succeed at 5 seconds
//timeout: 7000,
/* Run your local dev server before starting the tests */
/**
* This could be an array of servers, meaning we could start the Endorser server as well:
* {
* command: "cd ../endorser-ch; NODE_ENV=test-local npm run dev",
* url: 'http://localhost:3000',
* reuseExistingServer: !process.env.CI,
* },
*
* But if we do then the testInfo.config.webServer is null and the API-setting test 00 fails.
* It is worth considering a change such that Time Safari's default Endorer API server is NOT set
* in the user's settings so that it can be blanked out and the default is used.
*/
webServer: {
command:
"VITE_PASSKEYS_ENABLED=true VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000 npm run dev",
url: "http://localhost:8080",
reuseExistingServer: !process.env.CI,
},
});

25
playwright.config.ts

@ -35,10 +35,10 @@ export default defineConfig({
projects: [
{
name: 'chromium',
use: {
use: {
...devices['Desktop Chrome'],
permissions: ["clipboard-read"],
},
},
},
{
@ -52,14 +52,14 @@ export default defineConfig({
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
},
/* Test against branded browsers. */
// {
@ -74,8 +74,9 @@ export default defineConfig({
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// url: 'http://127.0.0.1:3000',
// command:
// "VITE_PASSKEYS_ENABLED=true VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000 npm run dev",
// url: "http://localhost:8080",
// reuseExistingServer: !process.env.CI,
// },
});

1
src/components/GiftedDialog.vue

@ -24,6 +24,7 @@
<fa icon="chevron-left" />
</div>
<input
id="inputGivenAmount"
type="number"
class="border border-r-0 border-slate-400 px-2 py-2 text-center w-20"
v-model="amountInput"

1
src/components/PhotoDialog.vue

@ -350,6 +350,7 @@ export default class PhotoDialog extends Vue {
const token = await accessToken(this.activeDid);
const headers = {
Authorization: "Bearer " + token,
// axios fills in Content-Type of multipart/form-data
};
const formData = new FormData();
if (!this.blob) {

3
src/db/tables/temp.ts

@ -2,7 +2,8 @@
export type Temp = {
id: string;
blob?: Blob;
blob?: Blob; // deprecated because webkit (Safari) does not support Blob
blobB64?: string; // base64-encoded blob
};
/**

19
src/libs/endorserServer.ts

@ -604,19 +604,19 @@ export function hydrateGive(
}
// ... and replace or add each element, ending with Trade or Donate
// I realize this doesn't change any elements that are not PlanAction or Offer or Trade/Action.
vcClaim.fulfills = vcClaim.fulfills.filter(
(elem) => elem["@type"] !== "PlanAction",
);
if (fulfillsProjectHandleId) {
vcClaim.fulfills = vcClaim.fulfills.filter(
(elem) => elem["@type"] !== "PlanAction",
);
vcClaim.fulfills.push({
"@type": "PlanAction",
identifier: fulfillsProjectHandleId,
});
}
vcClaim.fulfills = vcClaim.fulfills.filter(
(elem) => elem["@type"] !== "Offer",
);
if (fulfillsOfferHandleId) {
vcClaim.fulfills = vcClaim.fulfills.filter(
(elem) => elem["@type"] !== "Offer",
);
vcClaim.fulfills.push({
"@type": "Offer",
identifier: fulfillsOfferHandleId,
@ -1135,8 +1135,11 @@ export async function setVisibilityUtil(
try {
const resp = await axios.post(url, payload, { headers });
if (resp.status === 200) {
db.contacts.update(contact.did, { seesMe: visibility });
return { success: true };
const success = resp.data.success;
if (success) {
db.contacts.update(contact.did, { seesMe: visibility });
}
return { success };
} else {
console.error(
"Got some bad server response when setting visibility: ",

35
src/libs/util.ts

@ -26,6 +26,7 @@ import { createPeerDid } from "@/libs/crypto/vc/didPeer";
export const PRIVACY_MESSAGE =
"The data you send will be visible to the world -- except: your IDs and the IDs of anyone you tag will stay private, only visible to them and others you explicitly allow.";
export const SHARED_PHOTO_BASE64_KEY = "shared-photo-base64";
/* eslint-disable prettier/prettier */
export const UNIT_SHORT: Record<string, string> = {
@ -102,11 +103,13 @@ export const doCopyTwoSecRedo = (text: string, fn: () => void) => {
* @param veriClaim is expected to have fields: claim, claimType, and issuer
*/
export const isGiveRecordTheUserCanConfirm = (
isRegistered: boolean,
veriClaim: GenericCredWrapper<GenericVerifiableCredential>,
activeDid: string,
confirmerIdList: string[] = [],
) => {
return (
isRegistered &&
isGiveAction(veriClaim) &&
!confirmerIdList.includes(activeDid) &&
veriClaim.issuer !== activeDid &&
@ -114,6 +117,38 @@ export const isGiveRecordTheUserCanConfirm = (
);
};
export async function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string); // potential problem if it returns an ArrayBuffer?
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
export function base64ToBlob(base64DataUrl: string, sliceSize = 512) {
// Extract the content type and the Base64 data
const [metadata, base64] = base64DataUrl.split(",");
const contentTypeMatch = metadata.match(/data:(.*?);base64/);
const contentType = contentTypeMatch ? contentTypeMatch[1] : "";
const byteCharacters = atob(base64);
const byteArrays = [];
for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
const slice = byteCharacters.slice(offset, offset + sliceSize);
const byteNumbers = new Array(slice.length);
for (let i = 0; i < slice.length; i++) {
byteNumbers[i] = slice.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
byteArrays.push(byteArray);
}
return new Blob(byteArrays, { type: contentType });
}
/**
* @returns the DID of the person who offered, or undefined if hidden
* @param veriClaim is expected to have fields: claim and issuer

5
src/router/index.ts

@ -63,6 +63,11 @@ const routes: Array<RouteRecordRaw> = [
name: "contact-gift",
component: () => import("../views/ContactGiftingView.vue"),
},
{
path: "/contact-import",
name: "contact-import",
component: () => import("../views/ContactImportView.vue"),
},
{
path: "/contact-qr",
name: "contact-qr",

109
src/views/AccountViewView.vue

@ -37,6 +37,7 @@
<!-- ID notice -->
<div
v-if="!activeDid"
id="noticeBeforeShare"
class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3 mb-4"
>
<p class="mb-4">
@ -52,7 +53,10 @@
</div>
<!-- Identity Details -->
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
<div
id="sectionIdentityDetails"
class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4"
>
<div v-if="givenName">
<h2 class="text-xl font-semibold mb-2">
{{ givenName }}
@ -161,6 +165,7 @@
<!-- We won't show any loading indicator because it usually doesn't change anything. We'll just pop the message in only if we discover that they need it. -->
<div
v-if="!loadingLimits && !endorserLimits?.nextWeekBeginDateTime"
id="noticeBeforeAnnounce"
class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3 mb-4"
>
<p class="mb-4">
@ -175,7 +180,10 @@
</router-link>
</div>
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8">
<div
id="sectionNotifications"
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
>
<!-- label -->
<div class="mb-2 font-bold">Notifications</div>
<div
@ -211,12 +219,15 @@
</router-link>
</div>
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8">
<div
id="sectionSearchLocation"
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
>
<!-- label -->
<div class="mb-2 font-bold">Location</div>
<div class="mb-2 font-bold">Location for Searches</div>
<router-link
:to="{ name: 'search-area' }"
class="block w-full text-center text-m bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2 mt-6"
class="block w-full text-center text-m bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2 mt-6"
>
Set Search Area
<!-- If already set, change button label to "Change Search Area" -->
@ -225,6 +236,7 @@
<div
v-if="activeDid"
id="sectionUsageLimits"
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
>
<div class="mb-2 font-bold">Usage Limits</div>
@ -277,19 +289,22 @@
</button>
</div>
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8">
<div
id="sectionDataExport"
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
>
<div class="mb-2 font-bold">Data Export</div>
<router-link
:to="{ name: 'seed-backup' }"
v-if="activeDid"
class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2 mt-2"
class="block w-full text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2 mt-2"
>
Backup Identifier Seed
</router-link>
<button
v-bind:class="computedStartDownloadLinkClassNames()"
class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
class="block w-full text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
@click="exportDatabase()"
>
Download Settings & Contacts
@ -303,19 +318,20 @@
>
If no download happened yet, click again here to download now.
</a>
<div v-if="downloadUrl">
<div>
<p>
After the download, you can save the file in your preferred storage
location.
</p>
<ul>
<li class="list-disc list-outside ml-4">
On iOS: Choose "More..." and select anyplace in iCloud, or go "Back"
On iOS: Choose "More..." and select a place in iCloud, or go "Back"
and save to another location.
</li>
<li class="list-disc list-outside ml-4">
On Android: Choose "Open" and then share to your prefered place.
On Android: Choose "Open" and then share
<fa icon="share-nodes" class="fa-fw" />
to your prefered place.
</li>
</ul>
</div>
@ -329,7 +345,7 @@
>
Advanced
</h3>
<div v-if="showAdvanced || showGeneralAdvanced">
<div id="sectionAdvanced" v-if="showAdvanced || showGeneralAdvanced">
<p class="text-rose-600 mb-8">
Beware: the features here can be confusing and even change data in ways
you do not expect. But we support your freedom!
@ -339,7 +355,10 @@
<span class="text-slate-500 text-sm font-bold mb-2">
Deep Identifier Details
</span>
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
<div
id="sectionDeepIdentifier"
class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4"
>
<div class="text-slate-500 text-sm font-bold">Public Key (base 64)</div>
<div
class="text-sm text-slate-500 flex justify-start items-center mb-1"
@ -408,22 +427,29 @@
Switch Identifier
</router-link>
<div class="mt-4">
<div id="sectionImportContactsSettings" class="mt-4">
<h2 class="text-slate-500 text-sm font-bold">
Contacts & Settings Database
Import Contacts & Settings Database
</h2>
<div class="ml-4 mt-2">
Import
<input type="file" @change="uploadImportFile" class="ml-2" />
<div v-if="showContactImport()">
<div v-if="showContactImport()" class="mt-4">
<button
class="block text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6"
@click="confirmSubmitImportFile()"
>
Import Settings & Contacts
Overwrite Settings & Contacts
<br />
(excluding Identifier Data)
(which doesn't include Identifier Data)
</button>
<button
class="block text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6"
@click="checkContactImports()"
>
Import Contacts
<br />
after comparing
</button>
</div>
</div>
@ -455,7 +481,7 @@
</div>
</label>
<div>
<div id="sectionClaimServer">
<h2 class="text-slate-500 text-sm font-bold mt-4">Claim Server</h2>
<div class="px-4 py-4">
<input
@ -534,7 +560,7 @@
<h2 class="text-slate-500 text-sm font-bold mb-2">
Notification Push Server
</h2>
<div class="px-3 py-4">
<div id="sectionNotificationPushServer" class="px-3 py-4">
<input
type="text"
class="block w-full rounded border border-slate-400 px-3 py-2"
@ -572,7 +598,7 @@
{{ DEFAULT_PUSH_SERVER }}
</span>
<div class="mt-2">
<div id="sectionImageServerURL" class="mt-2">
<span class="text-slate-500 text-sm font-bold">Image Server URL</span>
&nbsp;
<span class="text-sm">{{ DEFAULT_IMAGE_API_SERVER }}</span>
@ -637,7 +663,7 @@
</button>
</div>
<div class="flex justify-between">
<div id="sectionPasskeyExpiration" class="flex justify-between">
<span>
<span class="text-slate-500 text-sm font-bold mb-2">
Passkey Expiration Minutes
@ -692,9 +718,11 @@ import { Buffer } from "buffer/";
import Dexie from "dexie";
import "dexie-export-import";
import { ImportProgress } from "dexie-export-import/dist/import";
import * as R from "ramda";
import { IIdentifier } from "@veramo/core";
import { ref } from "vue";
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { useClipboard } from "@vueuse/core";
import EntityIcon from "@/components/EntityIcon.vue";
@ -710,6 +738,7 @@ import {
} from "@/constants/app";
import { db, accountsDB } from "@/db/index";
import { Account } from "@/db/tables/accounts";
import { Contact } from "@/db/tables/contacts";
import {
DEFAULT_PASSKEY_EXPIRATION_MINUTES,
MASTER_SETTINGS_KEY,
@ -1146,6 +1175,40 @@ export default class AccountViewView extends Vue {
}
}
async checkContactImports() {
const reader = new FileReader();
reader.onload = (event) => {
const fileContent: string = (event.target?.result as string) || "{}";
try {
const contents = JSON.parse(fileContent);
const contactTableRows: Array<Contact> = (
contents.data?.data as [{ tableName: string; rows: Array<Contact> }]
)?.find((table) => table.tableName === "contacts")
?.rows as Array<Contact>;
const contactRows = contactTableRows.map(
// @ts-expect-error for omitting this field that is found in the Dexie format
(contact) => R.omit(["$types"], contact) as Contact,
);
(this.$router as Router).push({
name: "contact-import",
query: { contacts: JSON.stringify(contactRows) },
});
} catch (error) {
console.error("Error checking contact imports:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Importing",
text: "There was an error reading that Dexie file.",
},
3000,
);
}
};
reader.readAsText(inputImportFileNameRef.value as Blob);
}
private progressCallback(progress: ImportProgress) {
console.log(
`Import progress: ${progress.completedRows} of ${progress.totalRows} rows completed.`,

3
src/views/ClaimAddRawView.vue

@ -32,7 +32,6 @@
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import GiftedDialog from "@/components/GiftedDialog.vue";
import { NotificationIface } from "@/constants/app";
import { db } from "@/db/index";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
@ -40,7 +39,7 @@ import * as serverUtil from "@/libs/endorserServer";
import QuickNav from "@/components/QuickNav.vue";
@Component({
components: { GiftedDialog, QuickNav },
components: { QuickNav },
})
export default class ClaimAddRawView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;

20
src/views/ClaimView.vue

@ -136,6 +136,7 @@
class="col-span-1 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
v-if="
libsUtil.isGiveRecordTheUserCanConfirm(
isRegistered,
veriClaim,
activeDid,
confirmerIdList,
@ -299,13 +300,15 @@
<a @click="onClickShareClaim()" class="text-blue-500"
>click to send them this info</a
>
and see if they are willing to make an introduction.
and see if they are willing to make an introduction. They are surely
connected to someone; if you don't know who to ask, you might try the
person who registered you.
</span>
<span v-else>
If you'd like to ask any of your contacts to take a look and see if
their contacts can see more details,
<a
@click="copyToClipboard('Location', windowLocation)"
@click="copyToClipboard('This page location', windowLocation)"
class="text-blue-500"
>share this page with them</a
>
@ -379,10 +382,17 @@
</div>
</div>
<span v-if="isEditedGlobalId" class="mt-2">
This record is an edited version. The latest version is being shown.
This record is an edited version. The latest version is here.
</span>
<br />
<button @click="showVeriClaimDump = !showVeriClaimDump" class="ml-2">
Details
<fa v-if="showVeriClaimDump" icon="chevron-up" class="text-blue-400" />
<fa v-else icon="chevron-down" class="text-blue-400" />
</button>
<!-- Keep the dump contents directly between > and < to avoid weird spacing. -->
<pre
v-if="showVeriClaimDump"
class="text-sm overflow-x-scroll px-4 py-3 bg-slate-100 rounded-md"
>{{ veriClaimDump }}</pre
>
@ -463,9 +473,11 @@ export default class ClaimView extends Vue {
fullClaimDump = "";
fullClaimMessage = "";
isEditedGlobalId = false;
isRegistered = false;
numConfsNotVisible = 0; // number of hidden DIDs in the confirmerIdList, minus the issuer if they aren't visible
showDidCopy = false;
showIdCopy = false;
showVeriClaimDump = false;
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
veriClaimDump = "";
veriClaimDidsVisible = {};
@ -486,6 +498,7 @@ export default class ClaimView extends Vue {
this.fullClaimDump = "";
this.fullClaimMessage = "";
this.isEditedGlobalId = false;
this.isRegistered = false;
this.numConfsNotVisible = 0;
this.veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
this.veriClaimDump = "";
@ -497,6 +510,7 @@ export default class ClaimView extends Vue {
this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || "";
this.allContacts = await db.contacts.toArray();
this.isRegistered = settings?.isRegistered || false;
await accountsDB.open();
const accounts = accountsDB.accounts;

42
src/views/ConfirmGiftView.vue

@ -15,6 +15,7 @@
<span
v-if="
libsUtil.isGiveRecordTheUserCanConfirm(
isRegistered,
veriClaim,
activeDid,
confirmerIdList,
@ -33,6 +34,7 @@
class="col-span-1 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
v-if="
libsUtil.isGiveRecordTheUserCanConfirm(
isRegistered,
veriClaim,
activeDid,
confirmerIdList,
@ -52,6 +54,7 @@
<fa icon="circle-check" class="ml-2 text-white cursor-pointer" />
</button>
<a
v-if="isRegistered"
class="col-span-1 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-2 px-4 py-2 rounded-md"
:href="urlForNewGive"
>
@ -400,7 +403,6 @@ import { Component, Vue } from "vue-facing-decorator";
import { useClipboard } from "@vueuse/core";
import { Router } from "vue-router";
import GiftedDialog from "@/components/GiftedDialog.vue";
import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index";
@ -408,18 +410,13 @@ import { Account } from "@/db/tables/accounts";
import { Contact } from "@/db/tables/contacts";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import * as serverUtil from "@/libs/endorserServer";
import {
displayAmount,
GenericCredWrapper,
GiverReceiverInputInfo,
OfferVerifiableCredential,
} from "@/libs/endorserServer";
import { displayAmount } from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import { isGiveAction } from "@/libs/util";
@Component({
methods: { displayAmount },
components: { GiftedDialog, QuickNav },
components: { QuickNav },
})
export default class ClaimView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
@ -437,6 +434,7 @@ export default class ClaimView extends Vue {
giverName = "";
issuerName = "";
isLoading = false;
isRegistered = false;
numConfsNotVisible = 0; // number of hidden DIDs in the confirmerIdList, minus the issuer if they aren't visible
recipientName = "";
showDetails = false;
@ -456,6 +454,7 @@ export default class ClaimView extends Vue {
this.confsVisibleErrorMessage = "";
this.confsVisibleToIdList = [];
this.giveDetails = null;
this.isRegistered = false;
this.numConfsNotVisible = 0;
this.urlForNewGive = "";
this.veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
@ -469,6 +468,7 @@ export default class ClaimView extends Vue {
this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || "";
this.allContacts = await db.contacts.toArray();
this.isRegistered = settings?.isRegistered || false;
await accountsDB.open();
const accounts = accountsDB.accounts;
@ -771,20 +771,6 @@ export default class ClaimView extends Vue {
});
}
openFulfillGiftDialog() {
const giver: GiverReceiverInputInfo = {
did: libsUtil.offerGiverDid(
this.veriClaim as GenericCredWrapper<OfferVerifiableCredential>,
),
};
(this.$refs.customGiveDialog as GiftedDialog).open(
giver,
undefined,
this.giveDetails.handleId,
"Offer fulfilled by " + (giver?.name || "someone not named"),
);
}
copyToClipboard(name: string, text: string) {
useClipboard()
.copy(text)
@ -802,7 +788,17 @@ export default class ClaimView extends Vue {
}
notifyWhyCannotConfirm() {
if (!isGiveAction(this.veriClaim)) {
if (!this.isRegistered) {
this.$notify(
{
group: "alert",
type: "info",
title: "Not Registered",
text: "Someone needs to register you before you can contribute.",
},
3000,
);
} else if (!isGiveAction(this.veriClaim)) {
this.$notify(
{
group: "alert",

190
src/views/ContactImportView.vue

@ -0,0 +1,190 @@
<template>
<QuickNav selected="Contacts"></QuickNav>
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Back -->
<div class="text-lg text-center font-light relative px-7">
<h1
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<fa icon="chevron-left" class="fa-fw"></fa>
</h1>
</div>
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
Contact Import
</h1>
<span>
Note that you will have to make them visible one-by-one in the list of
Contacts.
</span>
<div v-if="sameCount > 0">
{{ sameCount }} contact{{ sameCount == 1 ? "" : "s" }} are the same as
existing contacts.
</div>
<!-- Results List -->
<ul v-if="contactsImporting.length > 0" class="border-t border-slate-300">
<li v-for="(contact, index) in contactsImporting" :key="contact.did">
<div
v-if="
!contactsExisting[contact.did] ||
!R.isEmpty(contactDifferences[contact.did])
"
class="grow overflow-hidden border-b border-slate-300 pt-2.5 pb-4"
>
<h2 class="text-base font-semibold">
<input type="checkbox" v-model="contactsSelected[index]" />
{{ contact.name || AppString.NO_CONTACT_NAME }}
-
<span v-if="contactsExisting[contact.did]" class="text-orange-500"
>Existing</span
>
<span v-else class="text-green-500">New</span>
</h2>
<div class="text-sm truncate">
{{ contact.did }}
</div>
<div v-if="contactDifferences[contact.did]">
<div>
<div class="grid grid-cols-3 gap-2">
<div class="font-bold">Field</div>
<div class="font-bold">Old Value</div>
<div class="font-bold">New Value</div>
</div>
<div
v-for="(value, contactField) in contactDifferences[contact.did]"
:key="contactField"
class="grid grid-cols-3 border"
>
<div class="border p-1">{{ contactField }}</div>
<div class="border p-1">{{ value.old }}</div>
<div class="border p-1">{{ value.new }}</div>
</div>
</div>
</div>
</div>
</li>
<fa icon="spinner" v-if="importing" class="animate-spin" />
<button
v-else
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-sm text-white mt-2 px-2 py-1.5 rounded"
@click="importContacts"
>
Import Selected Contacts
</button>
</ul>
<p v-else>There are no contacts to import.</p>
</section>
</template>
<script lang="ts">
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { AppString, NotificationIface } from "@/constants/app";
import { db } from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import * as libsUtil from "@/libs/util";
import QuickNav from "@/components/QuickNav.vue";
import EntityIcon from "@/components/EntityIcon.vue";
import OfferDialog from "@/components/OfferDialog.vue";
@Component({
components: { EntityIcon, OfferDialog, QuickNav },
})
export default class ContactImportView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
AppString = AppString;
libsUtil = libsUtil;
R = R;
contactsExisting: Record<string, Contact> = {}; // user's contacts already in the system, keyed by DID
contactsImporting: Array<Contact> = []; // contacts from the import
contactsSelected: Array<boolean> = []; // whether each contact in contactsImporting is selected
contactDifferences: Record<
string,
Record<string, { new: string; old: string }>
> = {}; // for existing contacts, it shows the difference between imported and existing contacts for each key
importing = false;
sameCount = 0;
async created() {
// Retrieve the imported contacts from the query parameter
const importedContacts =
((this.$route as Router).query["contacts"] as string) || "[]";
this.contactsImporting = JSON.parse(importedContacts);
this.contactsSelected = new Array(this.contactsImporting.length).fill(
false,
);
await db.open();
const baseContacts = await db.contacts.toArray();
// set the existing contacts, keyed by DID, if they exist in contactsImporting
for (let i = 0; i < this.contactsImporting.length; i++) {
const contactIn = this.contactsImporting[i];
const existingContact = baseContacts.find(
(contact) => contact.did === contactIn.did,
);
if (existingContact) {
this.contactsExisting[contactIn.did] = existingContact;
const differences: Record<string, { new: string; old: string }> = {};
Object.keys(contactIn).forEach((key) => {
if (contactIn[key] !== existingContact[key]) {
differences[key] = {
old: existingContact[key],
new: contactIn[key],
};
}
});
this.contactDifferences[contactIn.did] = differences;
if (R.isEmpty(differences)) {
this.sameCount++;
}
} else {
// automatically import new data
this.contactsSelected[i] = true;
}
}
}
async importContacts() {
this.importing = true;
let importedCount = 0,
updatedCount = 0;
for (let i = 0; i < this.contactsImporting.length; i++) {
if (this.contactsSelected[i]) {
const contact = this.contactsImporting[i];
const existingContact = this.contactsExisting[contact.did];
if (existingContact) {
await db.contacts.update(contact.did, contact);
updatedCount++;
} else {
// without explicit clone on the Proxy, we get: DataCloneError: Failed to execute 'add' on 'IDBObjectStore': #<Object> could not be cloned.
await db.contacts.add(R.clone(contact));
importedCount++;
}
}
}
this.importing = false;
this.$notify(
{
group: "alert",
type: "success",
title: "Import Success",
text:
`${importedCount} contact${importedCount == 1 ? "" : "s"} imported.` +
(updatedCount ? ` ${updatedCount} updated.` : ""),
},
3000,
);
(this.$router as Router).push({ name: "contacts" });
}
}
</script>

94
src/views/ContactsView.vue

@ -20,7 +20,7 @@
</div>
<!-- New Contact -->
<div class="mt-4 mb-4 flex items-stretch">
<div id="formAddNewContact" class="mt-4 mb-4 flex items-stretch">
<router-link
:to="{ name: 'contact-qr' }"
class="flex items-center bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 mr-1 rounded-md"
@ -79,7 +79,11 @@
</div>
<!-- Results List -->
<ul v-if="contacts.length > 0" class="border-t border-slate-300">
<ul
id="listContacts"
v-if="contacts.length > 0"
class="border-t border-slate-300"
>
<li
class="border-b border-slate-300 pt-2.5 pb-4"
v-for="contact in contacts"
@ -113,7 +117,7 @@
</router-link>
</h2>
<div class="text-sm truncate">
{{ contact.did }}
Identifier:
<button
@click="
libsUtil.doCopyTwoSecRedo(
@ -121,24 +125,51 @@
() => (showDidCopy = !showDidCopy),
)
"
class="ml-2 mr-2"
>
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
</button>
<span v-show="showDidCopy">Copied DID</span>
<span v-show="showDidCopy" class="text-green-500">Copied DID</span>
{{ contact.did }}
</div>
<div class="text-sm truncate" v-if="contact.publicKeyBase64">
Public Key (base 64): {{ contact.publicKeyBase64 }}
Public Key (base 64):
<button
@click="
libsUtil.doCopyTwoSecRedo(
contact.publicKeyBase64,
() => (showPubKeyCopy = !showPubKeyCopy),
)
"
>
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
</button>
<span v-show="showPubKeyCopy" class="text-green-500"
>Copied Key</span
>
{{ contact.publicKeyBase64 }}
</div>
<div class="text-sm truncate" v-if="contact.nextPubKeyHashB64">
Next Public Key Hash (base 64):
<button
@click="
libsUtil.doCopyTwoSecRedo(
contact.nextPubKeyHashB64,
() => (showPubKeyHashCopy = !showPubKeyHashCopy),
)
"
>
<fa icon="copy" class="text-slate-400 fa-fw"></fa>
</button>
<span v-show="showPubKeyHashCopy" class="text-green-500"
>Copied Hash</span
>
{{ contact.nextPubKeyHashB64 }}
</div>
<div id="ContactActions" class="flex gap-1.5 mt-2">
<div v-if="activeDid">
<button
v-if="contact.seesMe"
v-if="contact.seesMe && contact.did !== activeDid"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
@click="confirmSetVisibility(contact, false)"
title="They can see you"
@ -146,25 +177,31 @@
<fa icon="eye" class="fa-fw" />
</button>
<button
v-else
v-else-if="!contact.seesMe && contact.did !== activeDid"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
@click="confirmSetVisibility(contact, true)"
title="They cannot see you"
>
<fa icon="eye-slash" class="fa-fw" />
</button>
<!-- otherwise it's this user so hide it -->
<fa v-else icon="eye" class="text-white mx-2.5" />
<button
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
@click="checkVisibility(contact)"
title="Check Visibility"
v-if="activeDid"
v-if="contact.did !== activeDid"
>
<fa icon="rotate" class="fa-fw" />
</button>
<!-- otherwise it's this user so hide it -->
<fa v-else icon="rotate" class="text-white mx-2.5" />
<button
@click="confirmRegister(contact)"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-6 px-2 py-1.5 rounded-md"
v-if="activeDid"
v-if="contact.did !== activeDid"
title="Registration"
>
<fa
@ -174,6 +211,8 @@
/>
<fa v-else icon="person-circle-question" class="fa-fw" />
</button>
<!-- otherwise it's this user so hide it -->
<fa v-else icon="rotate" class="text-white ml-6 px-2.5" />
</div>
<button
@ -301,6 +340,7 @@
<script lang="ts">
import { AxiosError } from "axios";
import { Buffer } from "buffer/";
import { IndexableType } from "dexie";
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
@ -327,8 +367,6 @@ import EntityIcon from "@/components/EntityIcon.vue";
import GiftedDialog from "@/components/GiftedDialog.vue";
import OfferDialog from "@/components/OfferDialog.vue";
import { Buffer } from "buffer/";
@Component({
components: { GiftedDialog, EntityIcon, OfferDialog, QuickNav },
})
@ -356,6 +394,8 @@ export default class ContactsView extends Vue {
hideRegisterPromptOnNewContact = false;
isRegistered = false;
showDidCopy = false;
showPubKeyCopy = false;
showPubKeyHashCopy = false;
showGiveNumbers = false;
showGiveTotals = true;
showGiveConfirmed = true;
@ -803,7 +843,7 @@ export default class ContactsView extends Vue {
);
if (regResult.success) {
contact.registered = true;
db.contacts.update(contact.did, { registered: true });
await db.contacts.update(contact.did, { registered: true });
this.$notify(
{
@ -867,7 +907,10 @@ export default class ContactsView extends Vue {
title: "Set Visibility",
text: visibilityPrompt,
onYes: async () => {
await this.setVisibility(contact, visibility, true);
const success = await this.setVisibility(contact, visibility, true);
if (success) {
contact.seesMe = visibility; // didn't work inside setVisibility
}
},
},
-1,
@ -888,6 +931,8 @@ export default class ContactsView extends Vue {
visibility,
);
if (result.success) {
//contact.seesMe = visibility; // why doesn't it affect the UI from here?
//console.log("Set result & seesMe", result, contact.seesMe, contact.did);
if (showSuccessAlert) {
this.$notify(
{
@ -903,18 +948,21 @@ export default class ContactsView extends Vue {
3000,
);
}
} else if (result.error) {
return true;
} else {
console.error("Got strange result from setting visibility:", result);
const message =
(result.error as string) || "Could not set visibility on the server.";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Setting Visibility",
text: result.error as string,
text: message,
},
5000,
);
} else {
console.error("Got strange result from setting visibility:", result);
return false;
}
}
@ -942,14 +990,8 @@ export default class ContactsView extends Vue {
if (resp.status === 200) {
const visibility = resp.data;
contact.seesMe = visibility;
console.log(
"Visibility checked:",
visibility,
contact.did,
contact.name,
); // eslint-disable-line no-console
console.log(this.contacts); // eslint-disable-line no-console
db.contacts.update(contact.did, { seesMe: visibility });
//console.log("Visi check:", visibility, contact.seesMe, contact.did);
await db.contacts.update(contact.did, { seesMe: visibility });
this.$notify(
{

22
src/views/DIDView.vue

@ -27,9 +27,17 @@
.displayName
}}
</h2>
<span class="mt-2 text-xl font-semibold break-words">
{{ viewingDid }}
</span>
<button @click="showDidDetails = !showDidDetails" class="ml-2 mr-2">
Details
<fa v-if="showDidDetails" icon="chevron-up" class="text-blue-400" />
<fa v-else icon="chevron-down" class="text-blue-400" />
</button>
<!-- Keep the dump contents directly between > and < to avoid weird spacing. -->
<pre
v-if="showDidDetails"
class="text-sm overflow-x-scroll px-4 py-3 bg-slate-100 rounded-md"
>{{ contactYaml }}</pre
>
</div>
<div class="flex justify-center mt-4">
<span v-if="contact?.profileImageUrl" class="flex justify-between">
@ -126,6 +134,7 @@
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import * as yaml from "js-yaml";
import QuickNav from "@/components/QuickNav.vue";
import InfiniteScroll from "@/components/InfiniteScroll.vue";
@ -144,6 +153,7 @@ import {
GiveVerifiableCredential,
OfferVerifiableCredential,
} from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util";
import EntityIcon from "@/components/EntityIcon.vue";
@Component({
@ -157,14 +167,19 @@ import EntityIcon from "@/components/EntityIcon.vue";
export default class DIDView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
libsUtil = libsUtil;
yaml = yaml;
activeDid = "";
allMyDids: Array<string> = [];
apiServer = "";
claims: Array<GenericCredWrapper<GenericVerifiableCredential>> = [];
contact?: Contact;
contactYaml = "";
hitEnd = false;
isLoading = false;
searchBox: { name: string; bbox: BoundingBox } | null = null;
showDidDetails = false;
showLargeIdenticonId?: string;
showLargeIdenticonUrl?: string;
viewingDid?: string;
@ -183,6 +198,7 @@ export default class DIDView extends Vue {
if (pathParam) {
this.viewingDid = decodeURIComponent(pathParam);
this.contact = await db.contacts.get(this.viewingDid);
this.contactYaml = yaml.dump(this.contact);
await this.loadClaimsAbout();
} else {
this.$notify(

2
src/views/DiscoverView.vue

@ -92,7 +92,7 @@
<!-- Results List -->
<InfiniteScroll @reached-bottom="loadMoreData">
<ul>
<ul id="listDiscoverResults">
<li
class="border-b border-slate-300"
v-for="project in projects"

9
src/views/GiftedDetails.vue

@ -64,7 +64,7 @@
</div>
</div>
<div class="flex justify-center mt-4">
<div class="flex justify-center mt-4" data-testid="imagery">
<span v-if="imageUrl" class="flex justify-between">
<a :href="imageUrl" target="_blank" class="text-blue-500 ml-4">
<img :src="imageUrl" class="h-24 rounded-xl" />
@ -249,9 +249,10 @@ export default class GiftedDetails extends Vue {
);
}
const prevAmount = this.prevCredToEdit?.claim?.object?.amountOfThisGood;
this.amountInput =
(this.$route as Router).query["amountInput"] ||
String(this.prevCredToEdit?.claim?.object?.amountOfThisGood) ||
(prevAmount ? String(prevAmount) : "") ||
this.amountInput;
this.description =
(this.$route as Router).query["description"] ||
@ -275,13 +276,13 @@ export default class GiftedDetails extends Vue {
: fulfills
? [fulfills]
: [];
const offer = fulfillsArray.find((rec) => rec.claimType === "Offer");
const offer = fulfillsArray.find((rec) => rec["@type"] === "Offer");
this.offerId = ((this.$route as Router).query["offerId"] ||
offer?.identifier ||
this.offerId) as string;
// find any project ID
const project = fulfillsArray.find((rec) => rec.claimType === "PlanAction");
const project = fulfillsArray.find((rec) => rec["@type"] === "PlanAction");
this.projectId = ((this.$route as Router).query["projectId"] ||
project?.identifier ||
this.projectId) as string;

10
src/views/HomeView.vue

@ -81,6 +81,7 @@
<div class="mb-4">
<div
v-if="!isRegistered"
id="noticeSomeoneMustRegisterYou"
class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4"
>
<!-- activeDid && !isRegistered -->
@ -102,7 +103,7 @@
</div>
</div>
<div v-else>
<div v-else id="sectionRecordSomethingGiven">
<!-- activeDid && isRegistered -->
<!-- show the actions for recognizing a give -->
@ -188,7 +189,7 @@
</button>
</div>
<InfiniteScroll @reached-bottom="loadMoreGives">
<ul class="border-t border-slate-300">
<ul id="listLatestActivity" class="border-t border-slate-300">
<li
class="border-b border-slate-300 py-2"
v-for="record in feedData"
@ -276,7 +277,10 @@
</router-link>
</span>
</div>
<div v-if="record.image" class="flex justify-center">
<div
v-if="record.image"
class="flex justify-center"
>
<a :href="record.image" target="_blank">
<img :src="record.image" class="h-24 mt-2 rounded-xl" />
</a>

178
src/views/ProjectViewView.vue

@ -115,7 +115,51 @@
</button>
</div>
<div v-if="activeDid" class="mt-4">
<div class="grid items-start grid-cols-1 sm:grid-cols-2 gap-4 mt-4">
<div>
<div
v-if="fulfillersToThis.length > 0"
class="bg-slate-100 px-4 py-3 rounded-md"
>
<h3 class="text-sm uppercase font-semibold mt-3">
Projects That Contribute To This
</h3>
<!-- centering because long, wrapped project names didn't left align with blank or "text-left" -->
<div class="text-center">
<div v-for="plan in fulfillersToThis" :key="plan.handleId">
<button
@click="onClickLoadProject(plan.handleId)"
class="text-blue-500"
>
{{ plan.name }}
</button>
</div>
<div v-if="fulfillersToHitLimit" class="text-center">
<button @click="loadPlanFulfillersTo()">Load More</button>
</div>
</div>
</div>
</div>
<div>
<div v-if="fulfilledByThis" class="bg-slate-100 px-4 py-3 rounded-md">
<h3 class="text-sm uppercase font-semibold mb-3">
Projects Getting Contributions From This
</h3>
<!-- centering because long, wrapped project names didn't left align with blank or "text-left" -->
<div class="text-center">
<button
@click="onClickLoadProject(fulfilledByThis.handleId)"
class="text-blue-500"
>
{{ fulfilledByThis.name }}
</button>
</div>
</div>
</div>
</div>
<div v-if="activeDid && isRegistered" class="mt-4">
<div class="text-center">
<button
@click="openOfferDialog()"
@ -127,7 +171,7 @@
</div>
<OfferDialog ref="customOfferDialog" :projectId="this.projectId" />
<div v-if="activeDid">
<div v-if="activeDid && isRegistered">
<div class="text-center">
<p class="mt-2 mt-4 text-center">Record a contribution from:</p>
</div>
@ -313,89 +357,53 @@
v-if="givesProvidedByThis.length > 0"
class="bg-slate-100 px-4 py-3 rounded-md"
>
<h3 class="text-sm uppercase font-semibold mb-3 border-b">
Individuals Getting Contributions From This
</h3>
<!-- similar to gift display above -->
<ul class="text-sm border-t border-slate-300">
<li
v-for="give in givesProvidedByThis"
:key="give.id"
class="py-1.5 border-b border-slate-300"
>
<div class="flex justify-between gap-4">
<span>
{{
serverUtil.didInfo(
give.agentDid,
activeDid,
allMyDids,
allContacts,
)
}}
</span>
<span v-if="give.amount" class="whitespace-nowrap">
<fa
:icon="libsUtil.iconForUnitCode(give.unit)"
class="fa-fw text-slate-400"
/>{{ give.amount }}
</span>
</div>
<div class="text-slate-500">
<fa icon="calendar" class="fa-fw text-slate-400" />
{{ give.issuedAt?.substring(0, 10) }}
</div>
<div v-if="give.description" class="text-slate-500">
<fa icon="comment" class="fa-fw text-slate-400" />
{{ give.description }}
</div>
<a @click="onClickLoadClaim(give.jwtId)">
<fa icon="file-lines" class="text-blue-500 cursor-pointer" />
</a>
</li>
</ul>
<div v-if="givesProvidedByHitLimit" class="text-center">
<button @click="loadGivesProvidedBy()">Load More</button>
</div>
</div>
<div
v-if="fulfillersToThis.length > 0"
class="bg-slate-100 px-4 py-3 rounded-md"
>
<h3 class="text-sm uppercase font-semibold mb-3">
Projects That Contribute To This
</h3>
<!-- centering because long, wrapped project names didn't left align with blank or "text-left" -->
<div class="text-center">
<div v-for="plan in fulfillersToThis" :key="plan.handleId">
<button
@click="onClickLoadProject(plan.handleId)"
class="text-blue-500"
<div>
<h3 class="text-sm font-semibold border-b">
Individuals Getting Contributions From This
</h3>
<!-- similar to gift display above -->
<ul class="text-sm border-t border-slate-300">
<li
v-for="give in givesProvidedByThis"
:key="give.id"
class="py-1.5 border-b border-slate-300"
>
{{ plan.name }}
</button>
</div>
<div v-if="fulfillersToHitLimit" class="text-center">
<button @click="loadPlanFulfillersTo()">Load More</button>
<div class="flex justify-between gap-4">
<span>
{{
serverUtil.didInfo(
give.agentDid,
activeDid,
allMyDids,
allContacts,
)
}}
</span>
<span v-if="give.amount" class="whitespace-nowrap">
<fa
:icon="libsUtil.iconForUnitCode(give.unit)"
class="fa-fw text-slate-400"
/>{{ give.amount }}
</span>
</div>
<div class="text-slate-500">
<fa icon="calendar" class="fa-fw text-slate-400" />
{{ give.issuedAt?.substring(0, 10) }}
</div>
<div v-if="give.description" class="text-slate-500">
<fa icon="comment" class="fa-fw text-slate-400" />
{{ give.description }}
</div>
<a @click="onClickLoadClaim(give.jwtId)">
<fa icon="file-lines" class="text-blue-500 cursor-pointer" />
</a>
</li>
</ul>
<div v-if="givesProvidedByHitLimit" class="text-center">
<button @click="loadGivesProvidedBy()">Load More</button>
</div>
</div>
</div>
<div v-if="fulfilledByThis" class="bg-slate-100 px-4 py-3 rounded-md">
<h3 class="text-sm uppercase font-semibold mb-3">
Projects Getting Contributions From This
</h3>
<!-- centering because long, wrapped project names didn't left align with blank or "text-left" -->
<div class="text-center">
<button
@click="onClickLoadProject(fulfilledByThis.handleId)"
class="text-blue-500"
>
{{ fulfilledByThis.name }}
</button>
</div>
</div>
</div>
</div>
</section>
@ -459,6 +467,7 @@ export default class ProjectViewView extends Vue {
givesProvidedByThis: Array<GiveSummaryRecord> = [];
givesProvidedByHitLimit = false;
imageUrl = "";
isRegistered = false;
issuer = "";
latitude = 0;
longitude = 0;
@ -481,6 +490,7 @@ export default class ProjectViewView extends Vue {
this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || "";
this.allContacts = await db.contacts.toArray();
this.isRegistered = !!settings?.isRegistered;
await accountsDB.open();
const accounts = accountsDB.accounts;
@ -931,7 +941,11 @@ export default class ProjectViewView extends Vue {
claimType: "GiveAction",
issuer: give.agentDid,
};
return libsUtil.isGiveRecordTheUserCanConfirm(giveDetails, this.activeDid);
return libsUtil.isGiveRecordTheUserCanConfirm(
this.isRegistered,
giveDetails,
this.activeDid,
);
}
confirmConfirmClaim(give: GiveSummaryRecord) {

16
src/views/ProjectsView.vue

@ -86,7 +86,7 @@
Look for projects worth some of your time.
</router-link>
</div>
<ul class="border-t border-slate-300">
<ul id="listOffers" class="border-t border-slate-300">
<li
class="border-b border-slate-300"
v-for="offer in offers"
@ -189,12 +189,16 @@
<InfiniteScroll v-if="showProjects" @reached-bottom="loadMoreProjectData">
<div v-if="projects.length === 0" class="text-center py-4">
You have not announced any projects.
<br />
Hit the big
<fa icon="plus" class="bg-blue-600 text-white px-1 py-1 rounded-full" />
button. You'll never know until you try.
<div v-if="isRegistered">
Hit the big
<fa
icon="plus"
class="bg-blue-600 text-white px-1 py-1 rounded-full"
/>
button. You'll never know until you try.
</div>
</div>
<ul class="border-t border-slate-300">
<ul id="listProjects" class="border-t border-slate-300">
<li
class="border-b border-slate-300"
v-for="project in projects"

18
src/views/SharedPhotoView.vue

@ -66,7 +66,8 @@ import {
} from "@/constants/app";
import { db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
import { getHeaders } from "@/libs/endorserServer";
import { accessToken } from "@/libs/crypto";
import { base64ToBlob, SHARED_PHOTO_BASE64_KEY } from "@/libs/util";
@Component({ components: { PhotoDialog, QuickNav } })
export default class SharedPhotoView extends Vue {
@ -86,16 +87,19 @@ export default class SharedPhotoView extends Vue {
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.activeDid = settings?.activeDid as string;
const temp = await db.temp.get("shared-photo");
const temp = await db.temp.get(SHARED_PHOTO_BASE64_KEY);
const imageB64 = temp?.blobB64 as string;
if (temp) {
this.imageBlob = temp.blob;
this.imageBlob = base64ToBlob(imageB64);
// clear the temp image
db.temp.delete("shared-photo");
db.temp.delete(SHARED_PHOTO_BASE64_KEY);
this.imageFileName = (this.$route as Router).query[
"fileName"
] as string;
} else {
console.error("No appropriate image found in temp storage.", temp);
}
} catch (err: unknown) {
console.error("Got an error loading an identifier:", err);
@ -156,7 +160,11 @@ export default class SharedPhotoView extends Vue {
let result;
try {
// send the image to the server
const headers = await getHeaders(this.activeDid);
const token = await accessToken(this.activeDid);
const headers = {
Authorization: "Bearer " + token,
// axios fills in Content-Type of multipart/form-data
};
const formData = new FormData();
formData.append(
"image",

12
src/views/TestView.vue

@ -157,7 +157,7 @@
<div class="mt-8">
<h2 class="text-xl font-bold mb-4">Image Sharing</h2>
Populates the "shared-photo" view as if they used "share_target".
<input type="file" @change="uploadFile" />
<input type="file" data-testid="fileInput" @change="uploadFile" />
<router-link
v-if="showFileNextStep()"
:to="{
@ -165,6 +165,7 @@
query: { fileName },
}"
class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2 mt-2"
data-testid="fileUploadButton"
>
Go to Shared Page
</router-link>
@ -257,8 +258,10 @@ import {
} from "@/libs/crypto/vc/passkeyDidPeer";
import {
AccountKeyInfo,
blobToBase64,
getAccount,
registerAndSavePasskey,
SHARED_PHOTO_BASE64_KEY,
} from "@/libs/util";
const inputFileNameRef = ref<Blob>();
@ -320,12 +323,13 @@ export default class Help extends Vue {
const blob = new Blob([new Uint8Array(data)], {
type: file.type,
});
const blobB64 = await blobToBase64(blob);
this.fileName = file.name as string;
const temp = await db.temp.get("shared-photo");
const temp = await db.temp.get(SHARED_PHOTO_BASE64_KEY);
if (temp) {
await db.temp.update("shared-photo", { blob });
await db.temp.update(SHARED_PHOTO_BASE64_KEY, { blobB64 });
} else {
await db.temp.add({ id: "shared-photo", blob });
await db.temp.add({ id: SHARED_PHOTO_BASE64_KEY, blobB64 });
}
}
};

12
test-playwright/00-check-activity-feed.spec.ts

@ -1,12 +0,0 @@
import { test, expect } from '@playwright/test';
test('Check activity feed', async ({ page }) => {
// Load app homepage
await page.goto('./');
// Check that initial 10 activities have been loaded
await page.locator('li:nth-child(10)');
// Scroll down a bit to trigger loading additional activities
await page.locator('li:nth-child(50)').scrollIntoViewIfNeeded();
});

81
test-playwright/00-noid-tests.spec.ts

@ -0,0 +1,81 @@
import { test, expect } from '@playwright/test';
test('Confirm usage of test API', async ({ page }, testInfo) => {
// Load account view
await page.goto('./account');
await page.getByRole('heading', { name: 'Advanced' }).click();
// look into the config file: if it starts Time Safari, it might say which server it should set by default
const webServer = testInfo.config.webServer;
const endorserWords = webServer?.command.split(' ');
const ENDORSER_ENV_NAME = 'VITE_DEFAULT_ENDORSER_API_SERVER';
const endorserTerm = endorserWords?.find(word => word.startsWith(ENDORSER_ENV_NAME + '='));
const endorserTermInConfig = endorserTerm?.substring(ENDORSER_ENV_NAME.length + 1);
const endorserServer = endorserTermInConfig || 'https://test-api.endorser.ch';
await expect(page.getByRole('textbox').nth(1)).toHaveValue(endorserServer);
});
test('Check activity feed', async ({ page }) => {
// Load app homepage
await page.goto('./');
// Check that initial 10 activities have been loaded
await page.locator('ul#listLatestActivity li:nth-child(10)');
// Scroll down a bit to trigger loading additional activities
await page.locator('ul#listLatestActivity li:nth-child(50)').scrollIntoViewIfNeeded();
});
test('Check discover results', async ({ page }) => {
// Load Discover view
await page.goto('./discover');
// Check that initial 10 projects have been loaded
await page.locator('ul#listDiscoverResults li.border-b:nth-child(10)');
// Scroll down a bit to trigger loading additional projects
await page.locator('ul#listDiscoverResults li.border-b:nth-child(20)').scrollIntoViewIfNeeded();
});
test('Check no-ID messaging in homepage', async ({ page }) => {
// Load app homepage
await page.goto('./');
// Check 'someone must register you' notice
await expect(page.getByText('To share, someone must register you.')).toBeVisible();
});
test('Check no-ID messaging in account', async ({ page }) => {
// Load account view
await page.goto('./account');
// Check 'someone must register you' notice
await expect(page.locator('#noticeBeforeShare')).toBeVisible();
// Check 'a friend needs to register you' notice
await expect(page.locator('#noticeBeforeAnnounce')).toBeVisible();
// Check that there is no ID
await expect(page.locator('#sectionIdentityDetails code.truncate')).toBeEmpty();
});
test('Check ID generation', async ({ page }) => {
// Load Account view
await page.goto('./account');
// Check that ID is empty
await expect(page.locator('#sectionIdentityDetails code.truncate')).toBeEmpty();
// Load homepage to trigger ID generation (?)
await page.goto('./');
// Wait for activity feed to start loading, as a delay
await expect(page.locator('ul#listLatestActivity li:nth-child(10)')).toBeVisible();
// Go back to Account view
await page.goto('./account');
// Check that ID is now generated
await expect(page.locator('#sectionIdentityDetails code.truncate')).toContainText('did:ethr:');
});

12
test-playwright/01-check-discover-results.spec.ts

@ -1,12 +0,0 @@
import { test, expect } from '@playwright/test';
test('Check discover results', async ({ page }) => {
// Load Discover view
await page.goto('./discover');
// Check that initial 10 projects have been loaded
await page.locator('section#Content li.border-b:nth-child(10)');
// Scroll down a bit to trigger loading additional projects
await page.locator('section#Content li.border-b:nth-child(20)').scrollIntoViewIfNeeded();
});

18
test-playwright/10-check-usage-limits.spec.ts

@ -0,0 +1,18 @@
import { test, expect } from '@playwright/test';
import { importUser } from './testUtils';
test('Check usage limits', async ({ page }) => {
// Check without ID first
await page.goto('./account');
await expect(page.locator('div.bg-slate-100.rounded-md').filter({ hasText: 'Usage Limits' })).toBeHidden();
// Import user 01
await importUser(page, '01');
// Verify that "Usage Limits" section is visible
await expect(page.locator('#sectionUsageLimits')).toBeVisible();
await expect(page.getByText('Your claims counter resets')).toBeVisible();
await expect(page.getByText('Your registration counter resets')).toBeVisible();
await expect(page.getByText('Your image counter resets')).toBeVisible();
await expect(page.getByRole('button', { name: 'Recheck Limits' })).toBeVisible();
});

19
test-playwright/10-create-user-00.spec.ts

@ -1,19 +0,0 @@
import { test, expect } from '@playwright/test';
test('Create new ID from seed', async ({ page }) => {
// Create new ID using seed phrase "rigid shrug mobile…"
await page.goto('./start');
await page.getByText('You have a seed').click();
await page.getByPlaceholder('Seed Phrase').click();
await page.getByPlaceholder('Seed Phrase').fill('rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage');
await page.getByRole('button', { name: 'Import' }).click();
// Set name
await page.getByRole('link', { name: 'Set Your Name' }).click();
await page.getByPlaceholder('Name').click();
await page.getByPlaceholder('Name').fill('User Zero');
await page.getByRole('button', { name: 'Save Changes' }).click();
// Check DID
await expect(page.getByRole('code')).toContainText('did:ethr:0x0000694B58C2cC69658993A90D3840C560f2F51F');
});

19
test-playwright/11-create-user-01.spec.ts

@ -1,19 +0,0 @@
import { test, expect } from '@playwright/test';
test('Validate copy contact info to clipboard', async ({ page }) => {
// Create new ID using seed phrase "island fever beef…"
await page.goto('./start');
await page.getByText('You have a seed').click();
await page.getByPlaceholder('Seed Phrase').click();
await page.getByPlaceholder('Seed Phrase').fill('island fever beef wine urban aim vacant quit afford total poem flame service calm better adult neither color gaze forum month sister imitate excite');
await page.getByRole('button', { name: 'Import' }).click();
// Set name
await page.getByRole('link', { name: 'Set Your Name' }).click();
await page.getByPlaceholder('Name').click();
await page.getByPlaceholder('Name').fill('User One');
await page.getByRole('button', { name: 'Save Changes' }).click();
// Check DID
await expect(page.getByRole('code')).toContainText('did:ethr:0x111d15564f824D56C7a07b913aA7aDd03382aA39');
});

35
test-playwright/20-create-project.spec.ts

@ -1,6 +1,7 @@
import { test, expect } from '@playwright/test';
import { importUser } from './testUtils';
test('Create new project', async ({ page }) => {
test('Create new project, then search for it', async ({ page }) => {
// Generate a random string of 16 characters
let randomString = Math.random().toString(36).substring(2, 18);
@ -18,18 +19,8 @@ test('Create new project', async ({ page }) => {
const finalTitle = standardTitle + finalRandomString;
const finalDescription = standardDescription + finalRandomString;
// Create new ID using seed phrase "rigid shrug mobile…"
await page.goto('./start');
await page.getByText('You have a seed').click();
await page.getByPlaceholder('Seed Phrase').click();
await page.getByPlaceholder('Seed Phrase').fill('rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage');
await page.getByRole('button', { name: 'Import' }).click();
// Set name
await page.getByRole('link', { name: 'Set Your Name' }).click();
await page.getByPlaceholder('Name').click();
await page.getByPlaceholder('Name').fill('User Zero');
await page.getByRole('button', { name: 'Save Changes' }).click();
// Import user 00
await importUser(page, '00');
// Pause for 5 seconds
await page.waitForTimeout(5000); // I have to wait, otherwise the (+) button to add a new project doesn't appear
@ -38,19 +29,27 @@ test('Create new project', async ({ page }) => {
await page.goto('./projects');
await page.getByRole('link', { name: 'Projects', exact: true }).click();
await page.getByRole('button').click();
await page.getByPlaceholder('Idea Name').click();
await page.getByPlaceholder('Idea Name').fill(finalTitle); // Add random suffix
await page.getByPlaceholder('Description').click();
await page.getByPlaceholder('Description').fill(finalDescription);
await page.getByPlaceholder('Website').click();
await page.getByPlaceholder('Website').fill('https://example.com');
await page.getByPlaceholder('Start Date').click();
await page.getByPlaceholder('Start Date').fill('2025-12-01');
await page.getByPlaceholder('Start Time').click();
await page.getByPlaceholder('Start Time').fill('12:00');
await page.getByRole('button', { name: 'Save Project' }).click();
// Check texts
await expect(page.locator('h2')).toContainText(finalTitle);
await expect(page.locator('#Content')).toContainText(finalDescription);
// Search for newly-created project in /projects
await page.goto('./projects');
await page.getByRole('link', { name: 'Projects', exact: true }).click();
await page.waitForTimeout(3000); // Wait for a bit
await expect(page.locator('ul#listProjects li.border-b:nth-child(1)')).toContainText(finalRandomString); // Assumes newest project always appears first in the Projects tab list
// Search for newly-created project in /discover
await page.goto('./discover');
await page.waitForTimeout(3000); // Wait for a bit
await page.getByPlaceholder('Search…').fill(finalRandomString);
await page.locator('#QuickSearch button').click();
await expect(page.locator('ul#listDiscoverResults li.border-b:nth-child(1)')).toContainText(finalRandomString);
});

63
test-playwright/21-create-project-search.spec.ts

@ -1,63 +0,0 @@
import { test, expect } from '@playwright/test';
test('Create new project, then search for it', async ({ page }) => {
// Generate a random string of 16 characters
let randomString = Math.random().toString(36).substring(2, 18);
// In case the string is shorter than 16 characters, generate more characters until it is 16 characters long
while (randomString.length < 16) {
randomString += Math.random().toString(36).substring(2, 18);
}
const finalRandomString = randomString.substring(0, 16);
// Standard texts
const standardTitle = "Idea ";
const standardDescription = "Description of Idea ";
// Combine texts with the random string
const finalTitle = standardTitle + finalRandomString;
const finalDescription = standardDescription + finalRandomString;
// Create new ID using seed phrase "rigid shrug mobile…"
await page.goto('./start');
await page.getByText('You have a seed').click();
await page.getByPlaceholder('Seed Phrase').click();
await page.getByPlaceholder('Seed Phrase').fill('rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage');
await page.getByRole('button', { name: 'Import' }).click();
// Set name
await page.getByRole('link', { name: 'Set Your Name' }).click();
await page.getByPlaceholder('Name').click();
await page.getByPlaceholder('Name').fill('User Zero');
await page.getByRole('button', { name: 'Save Changes' }).click();
// Pause for 5 seconds
await page.waitForTimeout(5000); // I have to wait, otherwise the (+) button to add a new project doesn't appear
// Create new project
await page.goto('./projects');
await page.getByRole('link', { name: 'Projects', exact: true }).click();
await page.getByRole('button').click();
await page.getByPlaceholder('Idea Name').click();
await page.getByPlaceholder('Idea Name').fill(finalTitle); // Add random suffix
await page.getByPlaceholder('Description').click();
await page.getByPlaceholder('Description').fill(finalDescription);
await page.getByPlaceholder('Website').click();
await page.getByPlaceholder('Website').fill('https://example.com');
await page.getByPlaceholder('Start Date').click();
await page.getByPlaceholder('Start Date').fill('2025-12-01');
await page.getByPlaceholder('Start Time').click();
await page.getByPlaceholder('Start Time').fill('12:00');
await page.getByRole('button', { name: 'Save Project' }).click();
// Check texts
await expect(page.locator('h2')).toContainText(finalTitle);
await expect(page.locator('#Content')).toContainText(finalDescription);
// Search for project that was just created
await page.goto('./discover');
await page.waitForTimeout(5000); // Wait for a bit
await page.getByPlaceholder('Search…').fill(finalRandomString);
await page.locator('#QuickSearch button').click();
await expect(page.locator('section#Content li.border-b:nth-child(1)')).toContainText(finalRandomString);
});

18
test-playwright/30-record-gift.spec.ts

@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test';
import { importUser } from './testUtils';
test('Record something given', async ({ page }) => {
// Generate a random string of 16 characters
@ -19,25 +20,14 @@ test('Record something given', async ({ page }) => {
// Combine title prefix with the random string
const finalTitle = standardTitle + finalRandomString;
// Create new ID using seed phrase "rigid shrug mobile…"
await page.goto('./start');
await page.getByText('You have a seed').click();
await page.getByPlaceholder('Seed Phrase').click();
await page.getByPlaceholder('Seed Phrase').fill('rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage');
await page.getByRole('button', { name: 'Import' }).click();
// Set name
await page.getByRole('link', { name: 'Set Your Name' }).click();
await page.getByPlaceholder('Name').click();
await page.getByPlaceholder('Name').fill('User Zero');
await page.getByRole('button', { name: 'Save Changes' }).click();
// Import user 00
await importUser(page, '00');
// Record something given
await page.goto('./');
await page.getByRole('heading', { name: 'Unnamed/Unknown' }).click();
await page.getByPlaceholder('What was given').click();
await page.getByPlaceholder('What was given').fill(finalTitle);
await page.getByRole('spinbutton').fill(randomNonZeroNumber.toString());
await page.getByRole('spinbutton', { id: 'inputGivenAmount' }).fill(randomNonZeroNumber.toString());
await page.getByRole('button', { name: 'Sign & Send' }).click();
await expect(page.getByText('That gift was recorded.')).toBeVisible();

40
test-playwright/35-record-gift-from-image-share.spec.ts

@ -0,0 +1,40 @@
import path from 'path';
import { test, expect } from '@playwright/test';
test('Record item given from image-share', async ({ page }) => {
let randomString = Math.random().toString(36).substring(2, 8);
// Combine title prefix with the random string
const finalTitle = `Gift ${randomString} from image-share`;
// Create new ID using seed phrase "rigid shrug mobile…"
await page.goto('./start');
await page.getByText('You have a seed').click();
await page.getByPlaceholder('Seed Phrase').fill('rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage');
await page.getByRole('button', { name: 'Import' }).click();
// Record something given
await page.goto('./test');
const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByTestId('fileInput').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, '..', 'public', 'img', 'icons', 'android-chrome-192x192.png'));
await page.getByTestId('fileUploadButton').click();
// on shared photo page, choose the gift option
await page.getByRole('button').filter({ hasText: /gift/i }).click();
await page.getByTestId('imagery').getByRole('img').isVisible();
await page.getByPlaceholder('What was received').fill(finalTitle);
await page.getByRole('spinbutton').fill('2');
await page.getByRole('button', { name: 'Sign & Send' }).click();
await expect(page.getByText('That gift was recorded.')).toBeVisible();
// Refresh home view and check gift
await page.goto('./');
const item1 = page.locator('li').filter({ hasText: finalTitle });
await expect(item1.getByRole('img')).toBeVisible();
});

89
test-playwright/40-add-contact.spec.ts

@ -1,27 +1,86 @@
import { test, expect } from '@playwright/test';
import { importUser } from './testUtils';
test('Create new ID from seed', async ({ page }) => {
// Create new ID using seed phrase "rigid shrug mobile…"
await page.goto('./start');
await page.getByText('You have a seed').click();
await page.getByPlaceholder('Seed Phrase').click();
await page.getByPlaceholder('Seed Phrase').fill('rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage');
await page.getByRole('button', { name: 'Import' }).click();
test('Add contact, record gift, confirm gift', async ({ page }) => {
// Generate a random string of 16 characters
let randomString = Math.random().toString(36).substring(2, 18);
// In case the string is shorter than 16 characters, generate more characters until it is 16 characters long
while (randomString.length < 16) {
randomString += Math.random().toString(36).substring(2, 18);
}
const finalRandomString = randomString.substring(0, 16);
// Generate a random non-zero single-digit number
const randomNonZeroNumber = Math.floor(Math.random() * 99) + 1;
// Set name
await page.getByRole('link', { name: 'Set Your Name' }).click();
await page.getByPlaceholder('Name').click();
await page.getByPlaceholder('Name').fill('User Zero');
await page.getByRole('button', { name: 'Save Changes' }).click();
// Standard title prefix
const standardTitle = "Gift ";
// Add new contact 01
// Combine title prefix with the random string
const finalTitle = standardTitle + finalRandomString;
// Contact name
const contactName = 'Contact 00';
// Import user 01
await importUser(page, '01');
// Add new contact 00
await page.goto('./contacts');
await page.getByPlaceholder('URL or DID, Name, Public Key').fill('did:ethr:0x111d15564f824D56C7a07b913aA7aDd03382aA39');
await page.getByPlaceholder('URL or DID, Name, Public Key').fill('did:ethr:0x0000694B58C2cC69658993A90D3840C560f2F51F');
await page.locator('button > svg.fa-plus').click();
await expect(page.locator('div[role="alert"]')).toBeVisible();
// Why doesn't the alert box come up every time?
// await page.locator('div[role="alert"] button:has-text("Yes")').click();
await expect(page.locator('li.border-b')).toContainText('did:ethr:0x111d15564f824D56C7a07b913aA7aDd03382aA39');
// Verify added contact
await expect(page.locator('li.border-b')).toContainText('did:ethr:0x0000694B58C2cC69658993A90D3840C560f2F51F');
// Rename contact
await page.locator('li.border-b h2 > button[title="Edit"]').click();
await expect(page.locator('div.dialog-overlay > div.dialog').filter({ hasText: 'Edit Name' })).toBeVisible();
await page.getByPlaceholder('Name', { exact: true }).fill(contactName);
await page.locator('.dialog > .flex > button').first().click();
// Confirm that home shows contact in "Record Something…"
await page.goto('./');
await expect(page.locator('#sectionRecordSomethingGiven ul li').filter({ hasText: contactName }).nth(0)).toBeVisible();
// Record something given by new contact
await page.getByRole('heading', { name: contactName }).click();
await page.getByPlaceholder('What was given').fill(finalTitle);
await page.getByRole('spinbutton').fill(randomNonZeroNumber.toString());
await page.getByRole('button', { name: 'Sign & Send' }).click();
await expect(page.getByText('That gift was recorded.')).toBeVisible();
// Refresh home view and check gift
await page.goto('./');
await page.locator('li').filter({ hasText: finalTitle }).locator('a').click();
await expect(page.getByRole('heading', { name: 'Verifiable Claim Details' })).toBeVisible();
await expect(page.getByText(finalTitle, { exact: true })).toBeVisible();
// Switch to user 00
await page.goto('./account');
await page.getByRole('heading', { name: 'Advanced' }).click();
await page.getByRole('link', { name: 'Switch Identifier' }).click();
await page.getByRole('link', { name: 'Add Another Identity…' }).click();
await page.getByText('You have a seed').click();
await page.getByPlaceholder('Seed Phrase').fill('rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage');
await page.getByRole('button', { name: 'Import' }).click();
// Go to home view and look for gift
await page.goto('./');
await page.locator('li').filter({ hasText: finalTitle }).locator('a').click();
// Confirm gift as user 00
await page.getByRole('button', { name: 'Confirm' }).click();
await page.getByRole('button', { name: 'Yes' }).click();
await expect(page.getByText('Confirmation submitted.')).toBeVisible();
// Refresh claim page, Confirm button should be hidden
await page.reload();
await expect(page.getByRole('button', { name: 'Confirm' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Confirm' })).toBeHidden();
});

32
test-playwright/testUtils.js

@ -0,0 +1,32 @@
import { expect } from '@playwright/test';
export async function importUser(page, id) {
let seedPhrase, userName, did;
// Set seed phrase and DID based on user ID
switch(id) {
case '01':
seedPhrase = 'island fever beef wine urban aim vacant quit afford total poem flame service calm better adult neither color gaze forum month sister imitate excite';
userName = 'User One';
did = 'did:ethr:0x111d15564f824D56C7a07b913aA7aDd03382aA39';
break;
default: // to user 00
seedPhrase = 'rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage';
userName = 'User Zero';
did = 'did:ethr:0x0000694B58C2cC69658993A90D3840C560f2F51F';
}
// Import ID
await page.goto('./start');
await page.getByText('You have a seed').click();
await page.getByPlaceholder('Seed Phrase').fill(seedPhrase);
await page.getByRole('button', { name: 'Import' }).click();
// Set name
await page.getByRole('link', { name: 'Set Your Name' }).click();
await page.getByPlaceholder('Name').fill(userName);
await page.getByRole('button', { name: 'Save Changes' }).click();
// Check DID
await expect(page.getByRole('code')).toContainText(did);
}

437
tests-examples/demo-todo-app.spec.ts

@ -1,437 +0,0 @@
import { test, expect, type Page } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.goto('https://demo.playwright.dev/todomvc');
});
const TODO_ITEMS = [
'buy some cheese',
'feed the cat',
'book a doctors appointment'
] as const;
test.describe('New Todo', () => {
test('should allow me to add todo items', async ({ page }) => {
// create a new todo locator
const newTodo = page.getByPlaceholder('What needs to be done?');
// Create 1st todo.
await newTodo.fill(TODO_ITEMS[0]);
await newTodo.press('Enter');
// Make sure the list only has one todo item.
await expect(page.getByTestId('todo-title')).toHaveText([
TODO_ITEMS[0]
]);
// Create 2nd todo.
await newTodo.fill(TODO_ITEMS[1]);
await newTodo.press('Enter');
// Make sure the list now has two todo items.
await expect(page.getByTestId('todo-title')).toHaveText([
TODO_ITEMS[0],
TODO_ITEMS[1]
]);
await checkNumberOfTodosInLocalStorage(page, 2);
});
test('should clear text input field when an item is added', async ({ page }) => {
// create a new todo locator
const newTodo = page.getByPlaceholder('What needs to be done?');
// Create one todo item.
await newTodo.fill(TODO_ITEMS[0]);
await newTodo.press('Enter');
// Check that input is empty.
await expect(newTodo).toBeEmpty();
await checkNumberOfTodosInLocalStorage(page, 1);
});
test('should append new items to the bottom of the list', async ({ page }) => {
// Create 3 items.
await createDefaultTodos(page);
// create a todo count locator
const todoCount = page.getByTestId('todo-count')
// Check test using different methods.
await expect(page.getByText('3 items left')).toBeVisible();
await expect(todoCount).toHaveText('3 items left');
await expect(todoCount).toContainText('3');
await expect(todoCount).toHaveText(/3/);
// Check all items in one call.
await expect(page.getByTestId('todo-title')).toHaveText(TODO_ITEMS);
await checkNumberOfTodosInLocalStorage(page, 3);
});
});
test.describe('Mark all as completed', () => {
test.beforeEach(async ({ page }) => {
await createDefaultTodos(page);
await checkNumberOfTodosInLocalStorage(page, 3);
});
test.afterEach(async ({ page }) => {
await checkNumberOfTodosInLocalStorage(page, 3);
});
test('should allow me to mark all items as completed', async ({ page }) => {
// Complete all todos.
await page.getByLabel('Mark all as complete').check();
// Ensure all todos have 'completed' class.
await expect(page.getByTestId('todo-item')).toHaveClass(['completed', 'completed', 'completed']);
await checkNumberOfCompletedTodosInLocalStorage(page, 3);
});
test('should allow me to clear the complete state of all items', async ({ page }) => {
const toggleAll = page.getByLabel('Mark all as complete');
// Check and then immediately uncheck.
await toggleAll.check();
await toggleAll.uncheck();
// Should be no completed classes.
await expect(page.getByTestId('todo-item')).toHaveClass(['', '', '']);
});
test('complete all checkbox should update state when items are completed / cleared', async ({ page }) => {
const toggleAll = page.getByLabel('Mark all as complete');
await toggleAll.check();
await expect(toggleAll).toBeChecked();
await checkNumberOfCompletedTodosInLocalStorage(page, 3);
// Uncheck first todo.
const firstTodo = page.getByTestId('todo-item').nth(0);
await firstTodo.getByRole('checkbox').uncheck();
// Reuse toggleAll locator and make sure its not checked.
await expect(toggleAll).not.toBeChecked();
await firstTodo.getByRole('checkbox').check();
await checkNumberOfCompletedTodosInLocalStorage(page, 3);
// Assert the toggle all is checked again.
await expect(toggleAll).toBeChecked();
});
});
test.describe('Item', () => {
test('should allow me to mark items as complete', async ({ page }) => {
// create a new todo locator
const newTodo = page.getByPlaceholder('What needs to be done?');
// Create two items.
for (const item of TODO_ITEMS.slice(0, 2)) {
await newTodo.fill(item);
await newTodo.press('Enter');
}
// Check first item.
const firstTodo = page.getByTestId('todo-item').nth(0);
await firstTodo.getByRole('checkbox').check();
await expect(firstTodo).toHaveClass('completed');
// Check second item.
const secondTodo = page.getByTestId('todo-item').nth(1);
await expect(secondTodo).not.toHaveClass('completed');
await secondTodo.getByRole('checkbox').check();
// Assert completed class.
await expect(firstTodo).toHaveClass('completed');
await expect(secondTodo).toHaveClass('completed');
});
test('should allow me to un-mark items as complete', async ({ page }) => {
// create a new todo locator
const newTodo = page.getByPlaceholder('What needs to be done?');
// Create two items.
for (const item of TODO_ITEMS.slice(0, 2)) {
await newTodo.fill(item);
await newTodo.press('Enter');
}
const firstTodo = page.getByTestId('todo-item').nth(0);
const secondTodo = page.getByTestId('todo-item').nth(1);
const firstTodoCheckbox = firstTodo.getByRole('checkbox');
await firstTodoCheckbox.check();
await expect(firstTodo).toHaveClass('completed');
await expect(secondTodo).not.toHaveClass('completed');
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
await firstTodoCheckbox.uncheck();
await expect(firstTodo).not.toHaveClass('completed');
await expect(secondTodo).not.toHaveClass('completed');
await checkNumberOfCompletedTodosInLocalStorage(page, 0);
});
test('should allow me to edit an item', async ({ page }) => {
await createDefaultTodos(page);
const todoItems = page.getByTestId('todo-item');
const secondTodo = todoItems.nth(1);
await secondTodo.dblclick();
await expect(secondTodo.getByRole('textbox', { name: 'Edit' })).toHaveValue(TODO_ITEMS[1]);
await secondTodo.getByRole('textbox', { name: 'Edit' }).fill('buy some sausages');
await secondTodo.getByRole('textbox', { name: 'Edit' }).press('Enter');
// Explicitly assert the new text value.
await expect(todoItems).toHaveText([
TODO_ITEMS[0],
'buy some sausages',
TODO_ITEMS[2]
]);
await checkTodosInLocalStorage(page, 'buy some sausages');
});
});
test.describe('Editing', () => {
test.beforeEach(async ({ page }) => {
await createDefaultTodos(page);
await checkNumberOfTodosInLocalStorage(page, 3);
});
test('should hide other controls when editing', async ({ page }) => {
const todoItem = page.getByTestId('todo-item').nth(1);
await todoItem.dblclick();
await expect(todoItem.getByRole('checkbox')).not.toBeVisible();
await expect(todoItem.locator('label', {
hasText: TODO_ITEMS[1],
})).not.toBeVisible();
await checkNumberOfTodosInLocalStorage(page, 3);
});
test('should save edits on blur', async ({ page }) => {
const todoItems = page.getByTestId('todo-item');
await todoItems.nth(1).dblclick();
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages');
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).dispatchEvent('blur');
await expect(todoItems).toHaveText([
TODO_ITEMS[0],
'buy some sausages',
TODO_ITEMS[2],
]);
await checkTodosInLocalStorage(page, 'buy some sausages');
});
test('should trim entered text', async ({ page }) => {
const todoItems = page.getByTestId('todo-item');
await todoItems.nth(1).dblclick();
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill(' buy some sausages ');
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter');
await expect(todoItems).toHaveText([
TODO_ITEMS[0],
'buy some sausages',
TODO_ITEMS[2],
]);
await checkTodosInLocalStorage(page, 'buy some sausages');
});
test('should remove the item if an empty text string was entered', async ({ page }) => {
const todoItems = page.getByTestId('todo-item');
await todoItems.nth(1).dblclick();
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('');
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter');
await expect(todoItems).toHaveText([
TODO_ITEMS[0],
TODO_ITEMS[2],
]);
});
test('should cancel edits on escape', async ({ page }) => {
const todoItems = page.getByTestId('todo-item');
await todoItems.nth(1).dblclick();
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages');
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Escape');
await expect(todoItems).toHaveText(TODO_ITEMS);
});
});
test.describe('Counter', () => {
test('should display the current number of todo items', async ({ page }) => {
// create a new todo locator
const newTodo = page.getByPlaceholder('What needs to be done?');
// create a todo count locator
const todoCount = page.getByTestId('todo-count')
await newTodo.fill(TODO_ITEMS[0]);
await newTodo.press('Enter');
await expect(todoCount).toContainText('1');
await newTodo.fill(TODO_ITEMS[1]);
await newTodo.press('Enter');
await expect(todoCount).toContainText('2');
await checkNumberOfTodosInLocalStorage(page, 2);
});
});
test.describe('Clear completed button', () => {
test.beforeEach(async ({ page }) => {
await createDefaultTodos(page);
});
test('should display the correct text', async ({ page }) => {
await page.locator('.todo-list li .toggle').first().check();
await expect(page.getByRole('button', { name: 'Clear completed' })).toBeVisible();
});
test('should remove completed items when clicked', async ({ page }) => {
const todoItems = page.getByTestId('todo-item');
await todoItems.nth(1).getByRole('checkbox').check();
await page.getByRole('button', { name: 'Clear completed' }).click();
await expect(todoItems).toHaveCount(2);
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
});
test('should be hidden when there are no items that are completed', async ({ page }) => {
await page.locator('.todo-list li .toggle').first().check();
await page.getByRole('button', { name: 'Clear completed' }).click();
await expect(page.getByRole('button', { name: 'Clear completed' })).toBeHidden();
});
});
test.describe('Persistence', () => {
test('should persist its data', async ({ page }) => {
// create a new todo locator
const newTodo = page.getByPlaceholder('What needs to be done?');
for (const item of TODO_ITEMS.slice(0, 2)) {
await newTodo.fill(item);
await newTodo.press('Enter');
}
const todoItems = page.getByTestId('todo-item');
const firstTodoCheck = todoItems.nth(0).getByRole('checkbox');
await firstTodoCheck.check();
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]);
await expect(firstTodoCheck).toBeChecked();
await expect(todoItems).toHaveClass(['completed', '']);
// Ensure there is 1 completed item.
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
// Now reload.
await page.reload();
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]);
await expect(firstTodoCheck).toBeChecked();
await expect(todoItems).toHaveClass(['completed', '']);
});
});
test.describe('Routing', () => {
test.beforeEach(async ({ page }) => {
await createDefaultTodos(page);
// make sure the app had a chance to save updated todos in storage
// before navigating to a new view, otherwise the items can get lost :(
// in some frameworks like Durandal
await checkTodosInLocalStorage(page, TODO_ITEMS[0]);
});
test('should allow me to display active items', async ({ page }) => {
const todoItem = page.getByTestId('todo-item');
await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
await page.getByRole('link', { name: 'Active' }).click();
await expect(todoItem).toHaveCount(2);
await expect(todoItem).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
});
test('should respect the back button', async ({ page }) => {
const todoItem = page.getByTestId('todo-item');
await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
await test.step('Showing all items', async () => {
await page.getByRole('link', { name: 'All' }).click();
await expect(todoItem).toHaveCount(3);
});
await test.step('Showing active items', async () => {
await page.getByRole('link', { name: 'Active' }).click();
});
await test.step('Showing completed items', async () => {
await page.getByRole('link', { name: 'Completed' }).click();
});
await expect(todoItem).toHaveCount(1);
await page.goBack();
await expect(todoItem).toHaveCount(2);
await page.goBack();
await expect(todoItem).toHaveCount(3);
});
test('should allow me to display completed items', async ({ page }) => {
await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
await page.getByRole('link', { name: 'Completed' }).click();
await expect(page.getByTestId('todo-item')).toHaveCount(1);
});
test('should allow me to display all items', async ({ page }) => {
await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
await page.getByRole('link', { name: 'Active' }).click();
await page.getByRole('link', { name: 'Completed' }).click();
await page.getByRole('link', { name: 'All' }).click();
await expect(page.getByTestId('todo-item')).toHaveCount(3);
});
test('should highlight the currently applied filter', async ({ page }) => {
await expect(page.getByRole('link', { name: 'All' })).toHaveClass('selected');
//create locators for active and completed links
const activeLink = page.getByRole('link', { name: 'Active' });
const completedLink = page.getByRole('link', { name: 'Completed' });
await activeLink.click();
// Page change - active items.
await expect(activeLink).toHaveClass('selected');
await completedLink.click();
// Page change - completed items.
await expect(completedLink).toHaveClass('selected');
});
});
async function createDefaultTodos(page: Page) {
// create a new todo locator
const newTodo = page.getByPlaceholder('What needs to be done?');
for (const item of TODO_ITEMS) {
await newTodo.fill(item);
await newTodo.press('Enter');
}
}
async function checkNumberOfTodosInLocalStorage(page: Page, expected: number) {
return await page.waitForFunction(e => {
return JSON.parse(localStorage['react-todos']).length === e;
}, expected);
}
async function checkNumberOfCompletedTodosInLocalStorage(page: Page, expected: number) {
return await page.waitForFunction(e => {
return JSON.parse(localStorage['react-todos']).filter((todo: any) => todo.completed).length === e;
}, expected);
}
async function checkTodosInLocalStorage(page: Page, title: string) {
return await page.waitForFunction(t => {
return JSON.parse(localStorage['react-todos']).map((todo: any) => todo.title).includes(t);
}, title);
}
Loading…
Cancel
Save