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 4 months ago
Jose Olarte III bcc0fac0a8 Playwright: added ID to spinbutton 4 months ago
Trent Larson f724476ed6 tweak instructions for minimal test data 4 months ago
Jose Olarte III 6ca5bf754b (Switch back to test server) 4 months ago
Jose Olarte III 25eaff62d8 Playwright: expended contact test 4 months ago
Jose Olarte III dd1532e2f4 Playwright: test against created records 4 months ago
Jose Olarte III 7033d259e1 Playwright: added import 4 months ago
Jose Olarte III aae2e62177 Playwright: removed redundant tests 4 months ago
Jose Olarte III 9a9c2b1813 Playwright: combined no-ID tests 4 months ago
Jose Olarte III ee75576cda Playwright: implemented importUser 4 months ago
Jose Olarte III 88efa36542 Playwright: importUser function 4 months ago
Trent Larson fe1cd32be1 bump version and add "-beta" 4 months ago
Trent Larson c8f0f2c2b1 bump to version 0.3.15, fix a README instruction 4 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) 4 months ago
Trent Larson ca8da9fd5e add 'isRegistered' check to guard against many buttons 4 months ago
Trent Larson ff3d397150 move pointers to other projects up in the project view 4 months ago
Trent Larson 052d5c5bd1 add a test for empty ID, fix some linting 4 months ago
Trent Larson 9213ad1f4a remove unused code 4 months ago
Trent Larson 98f4665465 comment out a breaking test on local data & enhance those instructions 4 months ago
Jose Olarte III c5f5e81f2c Playwright: check ID generation 4 months ago
Jose Olarte III 26f6eb66fe Playwright: additional checks to add contact 4 months ago
Jose Olarte III 28dd602d9f ID-specific locators 4 months ago
Jose Olarte III fb0a64c0ab Mirrored browser selection 4 months ago
Jose Olarte III 88f41b885c Added IDs for Playwright targeting 4 months ago
Trent Larson bbf621fb18 make instructions for an Endorser server started from scratch 4 months ago
Jose Olarte III a0adc1517c Playwright: check usage limits (no-ID and with-ID) 4 months ago
Jose Olarte III 812c8a418e Playwright: confirm contact appears on home feed 4 months ago
Jose Olarte III 2f0326f182 Corrected some test labels 4 months ago
Trent Larson 1fbd1da87d add visibility flag set, refactor to see results, and add copy icons for contact info 4 months ago
Trent Larson d96770a351 move copy icon for DIDs on contact screen 4 months ago
Trent Larson 8d684f1b29 tweak verbiage and make other UI tweaks 4 months ago
Trent Larson 6272b3045b fix where it doesn't remove the plan when editing and removing it 4 months ago
Trent Larson 375d6ddbe2 fix problem detecting plans when editing gifts 4 months ago
Trent Larson f497c53294 hide the details of a claim by default 4 months ago
Trent Larson cb8aeeac1b show full contact details, plus other tweaks 4 months ago
Trent Larson 6191a4893f add a config for local testing, plus add mobile testing and some instructions 4 months ago
Trent Larson 791a35d97c fix one linting error 4 months ago
Trent Larson 5647c4627f import & update selected contacts 4 months ago
Jose Olarte III 7dfc377610 Playwright: check no-ID messaging 4 months ago
Jose Olarte III 11a3e981a6 Playwright: check test API 4 months ago
Trent Larson cd04f35224 remove example test file 4 months ago
Jose Olarte III 59f97ffc28 Optimize tests 4 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. 21
      playwright.config.ts
  8. 1
      src/components/GiftedDialog.vue
  9. 1
      src/components/PhotoDialog.vue
  10. 3
      src/db/tables/temp.ts
  11. 9
      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. 96
      src/views/ProjectViewView.vue
  25. 12
      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_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01GXYPFF7FA03NXKPYY142PY4H
VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app 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). 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 ### Added
- Clearer give-confirmation screen - Clearer give-confirmation screen
- BX currency https://thebx.medium.com/ - 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.) # (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. # 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 * Production
@ -56,7 +56,7 @@ TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https:
npm run build 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` * `rsync -azvu -e "ssh -i ~/.ssh/..." dist ubuntutest@test.timesafari.app:time-safari`
@ -71,8 +71,31 @@ npm run build
### Automated ### Automated
Using the global test Endorser (ledger) server:
`npx playwright test` `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 ### Register new user on test server
On the test server, User #0 has rights to register others, so you can start 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", "name": "TimeSafari",
"version": "0.3.15-beta", "version": "0.3.16-beta",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "TimeSafari", "name": "TimeSafari",
"version": "0.3.15-beta", "version": "0.3.16-beta",
"dependencies": { "dependencies": {
"@dicebear/collection": "^5.4.1", "@dicebear/collection": "^5.4.1",
"@dicebear/core": "^5.4.1", "@dicebear/core": "^5.4.1",
@ -39,7 +39,6 @@
"did-jwt": "^7.4.7", "did-jwt": "^7.4.7",
"ethereum-cryptography": "^2.1.3", "ethereum-cryptography": "^2.1.3",
"ethereumjs-util": "^7.1.5", "ethereumjs-util": "^7.1.5",
"ethr-did-resolver": "^8.1.2",
"jdenticon": "^3.2.0", "jdenticon": "^3.2.0",
"js-generate-password": "^0.1.9", "js-generate-password": "^0.1.9",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
@ -3209,31 +3208,6 @@
"node": ">=14" "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": { "node_modules/@ethersproject/abstract-provider": {
"version": "5.7.0", "version": "5.7.0",
"funding": [ "funding": [
@ -3387,32 +3361,6 @@
"@ethersproject/bignumber": "^5.7.0" "@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": { "node_modules/@ethersproject/hash": {
"version": "5.7.0", "version": "5.7.0",
"funding": [ "funding": [
@ -3550,60 +3498,6 @@
"@ethersproject/logger": "^5.7.0" "@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": { "node_modules/@ethersproject/rlp": {
"version": "5.7.0", "version": "5.7.0",
"funding": [ "funding": [
@ -10483,10 +10377,6 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/bech32": {
"version": "1.1.4",
"license": "MIT"
},
"node_modules/better-opn": { "node_modules/better-opn": {
"version": "3.0.2", "version": "3.0.2",
"license": "MIT", "license": "MIT",
@ -12831,24 +12721,6 @@
"ethr-did-resolver": "10.1.5" "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": { "node_modules/ethr-did/node_modules/@noble/ciphers": {
"version": "0.5.1", "version": "0.5.1",
"license": "MIT", "license": "MIT",
@ -22349,6 +22221,8 @@
"node_modules/ws": { "node_modules/ws": {
"version": "7.4.6", "version": "7.4.6",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=8.3.0" "node": ">=8.3.0"
}, },

3
package.json

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

21
playwright.config.ts

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

1
src/components/GiftedDialog.vue

@ -24,6 +24,7 @@
<fa icon="chevron-left" /> <fa icon="chevron-left" />
</div> </div>
<input <input
id="inputGivenAmount"
type="number" type="number"
class="border border-r-0 border-slate-400 px-2 py-2 text-center w-20" class="border border-r-0 border-slate-400 px-2 py-2 text-center w-20"
v-model="amountInput" 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 token = await accessToken(this.activeDid);
const headers = { const headers = {
Authorization: "Bearer " + token, Authorization: "Bearer " + token,
// axios fills in Content-Type of multipart/form-data
}; };
const formData = new FormData(); const formData = new FormData();
if (!this.blob) { if (!this.blob) {

3
src/db/tables/temp.ts

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

9
src/libs/endorserServer.ts

@ -604,19 +604,19 @@ export function hydrateGive(
} }
// ... and replace or add each element, ending with Trade or Donate // ... 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. // I realize this doesn't change any elements that are not PlanAction or Offer or Trade/Action.
if (fulfillsProjectHandleId) {
vcClaim.fulfills = vcClaim.fulfills.filter( vcClaim.fulfills = vcClaim.fulfills.filter(
(elem) => elem["@type"] !== "PlanAction", (elem) => elem["@type"] !== "PlanAction",
); );
if (fulfillsProjectHandleId) {
vcClaim.fulfills.push({ vcClaim.fulfills.push({
"@type": "PlanAction", "@type": "PlanAction",
identifier: fulfillsProjectHandleId, identifier: fulfillsProjectHandleId,
}); });
} }
if (fulfillsOfferHandleId) {
vcClaim.fulfills = vcClaim.fulfills.filter( vcClaim.fulfills = vcClaim.fulfills.filter(
(elem) => elem["@type"] !== "Offer", (elem) => elem["@type"] !== "Offer",
); );
if (fulfillsOfferHandleId) {
vcClaim.fulfills.push({ vcClaim.fulfills.push({
"@type": "Offer", "@type": "Offer",
identifier: fulfillsOfferHandleId, identifier: fulfillsOfferHandleId,
@ -1135,8 +1135,11 @@ export async function setVisibilityUtil(
try { try {
const resp = await axios.post(url, payload, { headers }); const resp = await axios.post(url, payload, { headers });
if (resp.status === 200) { if (resp.status === 200) {
const success = resp.data.success;
if (success) {
db.contacts.update(contact.did, { seesMe: visibility }); db.contacts.update(contact.did, { seesMe: visibility });
return { success: true }; }
return { success };
} else { } else {
console.error( console.error(
"Got some bad server response when setting visibility: ", "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 = 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."; "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 */ /* eslint-disable prettier/prettier */
export const UNIT_SHORT: Record<string, string> = { 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 * @param veriClaim is expected to have fields: claim, claimType, and issuer
*/ */
export const isGiveRecordTheUserCanConfirm = ( export const isGiveRecordTheUserCanConfirm = (
isRegistered: boolean,
veriClaim: GenericCredWrapper<GenericVerifiableCredential>, veriClaim: GenericCredWrapper<GenericVerifiableCredential>,
activeDid: string, activeDid: string,
confirmerIdList: string[] = [], confirmerIdList: string[] = [],
) => { ) => {
return ( return (
isRegistered &&
isGiveAction(veriClaim) && isGiveAction(veriClaim) &&
!confirmerIdList.includes(activeDid) && !confirmerIdList.includes(activeDid) &&
veriClaim.issuer !== 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 * @returns the DID of the person who offered, or undefined if hidden
* @param veriClaim is expected to have fields: claim and issuer * @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", name: "contact-gift",
component: () => import("../views/ContactGiftingView.vue"), component: () => import("../views/ContactGiftingView.vue"),
}, },
{
path: "/contact-import",
name: "contact-import",
component: () => import("../views/ContactImportView.vue"),
},
{ {
path: "/contact-qr", path: "/contact-qr",
name: "contact-qr", name: "contact-qr",

109
src/views/AccountViewView.vue

@ -37,6 +37,7 @@
<!-- ID notice --> <!-- ID notice -->
<div <div
v-if="!activeDid" 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" 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"> <p class="mb-4">
@ -52,7 +53,10 @@
</div> </div>
<!-- Identity Details --> <!-- 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"> <div v-if="givenName">
<h2 class="text-xl font-semibold mb-2"> <h2 class="text-xl font-semibold mb-2">
{{ givenName }} {{ 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. --> <!-- 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 <div
v-if="!loadingLimits && !endorserLimits?.nextWeekBeginDateTime" 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" 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"> <p class="mb-4">
@ -175,7 +180,10 @@
</router-link> </router-link>
</div> </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 --> <!-- label -->
<div class="mb-2 font-bold">Notifications</div> <div class="mb-2 font-bold">Notifications</div>
<div <div
@ -211,12 +219,15 @@
</router-link> </router-link>
</div> </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 --> <!-- label -->
<div class="mb-2 font-bold">Location</div> <div class="mb-2 font-bold">Location for Searches</div>
<router-link <router-link
:to="{ name: 'search-area' }" :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 Set Search Area
<!-- If already set, change button label to "Change Search Area" --> <!-- If already set, change button label to "Change Search Area" -->
@ -225,6 +236,7 @@
<div <div
v-if="activeDid" v-if="activeDid"
id="sectionUsageLimits"
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8" 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> <div class="mb-2 font-bold">Usage Limits</div>
@ -277,19 +289,22 @@
</button> </button>
</div> </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> <div class="mb-2 font-bold">Data Export</div>
<router-link <router-link
:to="{ name: 'seed-backup' }" :to="{ name: 'seed-backup' }"
v-if="activeDid" 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 Backup Identifier Seed
</router-link> </router-link>
<button <button
v-bind:class="computedStartDownloadLinkClassNames()" 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()" @click="exportDatabase()"
> >
Download Settings & Contacts Download Settings & Contacts
@ -303,19 +318,20 @@
> >
If no download happened yet, click again here to download now. If no download happened yet, click again here to download now.
</a> </a>
<div v-if="downloadUrl"> <div>
<p> <p>
After the download, you can save the file in your preferred storage After the download, you can save the file in your preferred storage
location. location.
</p> </p>
<ul> <ul>
<li class="list-disc list-outside ml-4"> <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. and save to another location.
</li> </li>
<li class="list-disc list-outside ml-4"> <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" /> <fa icon="share-nodes" class="fa-fw" />
to your prefered place.
</li> </li>
</ul> </ul>
</div> </div>
@ -329,7 +345,7 @@
> >
Advanced Advanced
</h3> </h3>
<div v-if="showAdvanced || showGeneralAdvanced"> <div id="sectionAdvanced" v-if="showAdvanced || showGeneralAdvanced">
<p class="text-rose-600 mb-8"> <p class="text-rose-600 mb-8">
Beware: the features here can be confusing and even change data in ways Beware: the features here can be confusing and even change data in ways
you do not expect. But we support your freedom! you do not expect. But we support your freedom!
@ -339,7 +355,10 @@
<span class="text-slate-500 text-sm font-bold mb-2"> <span class="text-slate-500 text-sm font-bold mb-2">
Deep Identifier Details Deep Identifier Details
</span> </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-slate-500 text-sm font-bold">Public Key (base 64)</div>
<div <div
class="text-sm text-slate-500 flex justify-start items-center mb-1" class="text-sm text-slate-500 flex justify-start items-center mb-1"
@ -408,22 +427,29 @@
Switch Identifier Switch Identifier
</router-link> </router-link>
<div class="mt-4"> <div id="sectionImportContactsSettings" class="mt-4">
<h2 class="text-slate-500 text-sm font-bold"> <h2 class="text-slate-500 text-sm font-bold">
Contacts & Settings Database Import Contacts & Settings Database
</h2> </h2>
<div class="ml-4 mt-2"> <div class="ml-4 mt-2">
Import
<input type="file" @change="uploadImportFile" class="ml-2" /> <input type="file" @change="uploadImportFile" class="ml-2" />
<div v-if="showContactImport()"> <div v-if="showContactImport()" class="mt-4">
<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" 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()" @click="confirmSubmitImportFile()"
> >
Import Settings & Contacts Overwrite Settings & Contacts
<br /> <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> </button>
</div> </div>
</div> </div>
@ -455,7 +481,7 @@
</div> </div>
</label> </label>
<div> <div id="sectionClaimServer">
<h2 class="text-slate-500 text-sm font-bold mt-4">Claim Server</h2> <h2 class="text-slate-500 text-sm font-bold mt-4">Claim Server</h2>
<div class="px-4 py-4"> <div class="px-4 py-4">
<input <input
@ -534,7 +560,7 @@
<h2 class="text-slate-500 text-sm font-bold mb-2"> <h2 class="text-slate-500 text-sm font-bold mb-2">
Notification Push Server Notification Push Server
</h2> </h2>
<div class="px-3 py-4"> <div id="sectionNotificationPushServer" class="px-3 py-4">
<input <input
type="text" type="text"
class="block w-full rounded border border-slate-400 px-3 py-2" class="block w-full rounded border border-slate-400 px-3 py-2"
@ -572,7 +598,7 @@
{{ DEFAULT_PUSH_SERVER }} {{ DEFAULT_PUSH_SERVER }}
</span> </span>
<div class="mt-2"> <div id="sectionImageServerURL" class="mt-2">
<span class="text-slate-500 text-sm font-bold">Image Server URL</span> <span class="text-slate-500 text-sm font-bold">Image Server URL</span>
&nbsp; &nbsp;
<span class="text-sm">{{ DEFAULT_IMAGE_API_SERVER }}</span> <span class="text-sm">{{ DEFAULT_IMAGE_API_SERVER }}</span>
@ -637,7 +663,7 @@
</button> </button>
</div> </div>
<div class="flex justify-between"> <div id="sectionPasskeyExpiration" class="flex justify-between">
<span> <span>
<span class="text-slate-500 text-sm font-bold mb-2"> <span class="text-slate-500 text-sm font-bold mb-2">
Passkey Expiration Minutes Passkey Expiration Minutes
@ -692,9 +718,11 @@ import { Buffer } from "buffer/";
import Dexie from "dexie"; import Dexie from "dexie";
import "dexie-export-import"; import "dexie-export-import";
import { ImportProgress } from "dexie-export-import/dist/import"; import { ImportProgress } from "dexie-export-import/dist/import";
import * as R from "ramda";
import { IIdentifier } from "@veramo/core"; import { IIdentifier } from "@veramo/core";
import { ref } from "vue"; import { ref } from "vue";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { useClipboard } from "@vueuse/core"; import { useClipboard } from "@vueuse/core";
import EntityIcon from "@/components/EntityIcon.vue"; import EntityIcon from "@/components/EntityIcon.vue";
@ -710,6 +738,7 @@ import {
} from "@/constants/app"; } from "@/constants/app";
import { db, accountsDB } from "@/db/index"; import { db, accountsDB } from "@/db/index";
import { Account } from "@/db/tables/accounts"; import { Account } from "@/db/tables/accounts";
import { Contact } from "@/db/tables/contacts";
import { import {
DEFAULT_PASSKEY_EXPIRATION_MINUTES, DEFAULT_PASSKEY_EXPIRATION_MINUTES,
MASTER_SETTINGS_KEY, 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) { private progressCallback(progress: ImportProgress) {
console.log( console.log(
`Import progress: ${progress.completedRows} of ${progress.totalRows} rows completed.`, `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 { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router"; import { Router } from "vue-router";
import GiftedDialog from "@/components/GiftedDialog.vue";
import { NotificationIface } from "@/constants/app"; import { NotificationIface } from "@/constants/app";
import { db } from "@/db/index"; import { db } from "@/db/index";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings"; 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"; import QuickNav from "@/components/QuickNav.vue";
@Component({ @Component({
components: { GiftedDialog, QuickNav }, components: { QuickNav },
}) })
export default class ClaimAddRawView extends Vue { export default class ClaimAddRawView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $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" 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=" v-if="
libsUtil.isGiveRecordTheUserCanConfirm( libsUtil.isGiveRecordTheUserCanConfirm(
isRegistered,
veriClaim, veriClaim,
activeDid, activeDid,
confirmerIdList, confirmerIdList,
@ -299,13 +300,15 @@
<a @click="onClickShareClaim()" class="text-blue-500" <a @click="onClickShareClaim()" class="text-blue-500"
>click to send them this info</a >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>
<span v-else> <span v-else>
If you'd like to ask any of your contacts to take a look and see if If you'd like to ask any of your contacts to take a look and see if
their contacts can see more details, their contacts can see more details,
<a <a
@click="copyToClipboard('Location', windowLocation)" @click="copyToClipboard('This page location', windowLocation)"
class="text-blue-500" class="text-blue-500"
>share this page with them</a >share this page with them</a
> >
@ -379,10 +382,17 @@
</div> </div>
</div> </div>
<span v-if="isEditedGlobalId" class="mt-2"> <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> </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. --> <!-- Keep the dump contents directly between > and < to avoid weird spacing. -->
<pre <pre
v-if="showVeriClaimDump"
class="text-sm overflow-x-scroll px-4 py-3 bg-slate-100 rounded-md" class="text-sm overflow-x-scroll px-4 py-3 bg-slate-100 rounded-md"
>{{ veriClaimDump }}</pre >{{ veriClaimDump }}</pre
> >
@ -463,9 +473,11 @@ export default class ClaimView extends Vue {
fullClaimDump = ""; fullClaimDump = "";
fullClaimMessage = ""; fullClaimMessage = "";
isEditedGlobalId = false; isEditedGlobalId = false;
isRegistered = false;
numConfsNotVisible = 0; // number of hidden DIDs in the confirmerIdList, minus the issuer if they aren't visible numConfsNotVisible = 0; // number of hidden DIDs in the confirmerIdList, minus the issuer if they aren't visible
showDidCopy = false; showDidCopy = false;
showIdCopy = false; showIdCopy = false;
showVeriClaimDump = false;
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD; veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
veriClaimDump = ""; veriClaimDump = "";
veriClaimDidsVisible = {}; veriClaimDidsVisible = {};
@ -486,6 +498,7 @@ export default class ClaimView extends Vue {
this.fullClaimDump = ""; this.fullClaimDump = "";
this.fullClaimMessage = ""; this.fullClaimMessage = "";
this.isEditedGlobalId = false; this.isEditedGlobalId = false;
this.isRegistered = false;
this.numConfsNotVisible = 0; this.numConfsNotVisible = 0;
this.veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD; this.veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
this.veriClaimDump = ""; this.veriClaimDump = "";
@ -497,6 +510,7 @@ export default class ClaimView extends Vue {
this.activeDid = settings?.activeDid || ""; this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || ""; this.apiServer = settings?.apiServer || "";
this.allContacts = await db.contacts.toArray(); this.allContacts = await db.contacts.toArray();
this.isRegistered = settings?.isRegistered || false;
await accountsDB.open(); await accountsDB.open();
const accounts = accountsDB.accounts; const accounts = accountsDB.accounts;

42
src/views/ConfirmGiftView.vue

@ -15,6 +15,7 @@
<span <span
v-if=" v-if="
libsUtil.isGiveRecordTheUserCanConfirm( libsUtil.isGiveRecordTheUserCanConfirm(
isRegistered,
veriClaim, veriClaim,
activeDid, activeDid,
confirmerIdList, 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" 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=" v-if="
libsUtil.isGiveRecordTheUserCanConfirm( libsUtil.isGiveRecordTheUserCanConfirm(
isRegistered,
veriClaim, veriClaim,
activeDid, activeDid,
confirmerIdList, confirmerIdList,
@ -52,6 +54,7 @@
<fa icon="circle-check" class="ml-2 text-white cursor-pointer" /> <fa icon="circle-check" class="ml-2 text-white cursor-pointer" />
</button> </button>
<a <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" 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" :href="urlForNewGive"
> >
@ -400,7 +403,6 @@ import { Component, Vue } from "vue-facing-decorator";
import { useClipboard } from "@vueuse/core"; import { useClipboard } from "@vueuse/core";
import { Router } from "vue-router"; import { Router } from "vue-router";
import GiftedDialog from "@/components/GiftedDialog.vue";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app"; import { NotificationIface } from "@/constants/app";
import { accountsDB, db } from "@/db/index"; import { accountsDB, db } from "@/db/index";
@ -408,18 +410,13 @@ import { Account } from "@/db/tables/accounts";
import { Contact } from "@/db/tables/contacts"; import { Contact } from "@/db/tables/contacts";
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings"; import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
import * as serverUtil from "@/libs/endorserServer"; import * as serverUtil from "@/libs/endorserServer";
import { import { displayAmount } from "@/libs/endorserServer";
displayAmount,
GenericCredWrapper,
GiverReceiverInputInfo,
OfferVerifiableCredential,
} from "@/libs/endorserServer";
import * as libsUtil from "@/libs/util"; import * as libsUtil from "@/libs/util";
import { isGiveAction } from "@/libs/util"; import { isGiveAction } from "@/libs/util";
@Component({ @Component({
methods: { displayAmount }, methods: { displayAmount },
components: { GiftedDialog, QuickNav }, components: { QuickNav },
}) })
export default class ClaimView extends Vue { export default class ClaimView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
@ -437,6 +434,7 @@ export default class ClaimView extends Vue {
giverName = ""; giverName = "";
issuerName = ""; issuerName = "";
isLoading = false; isLoading = false;
isRegistered = false;
numConfsNotVisible = 0; // number of hidden DIDs in the confirmerIdList, minus the issuer if they aren't visible numConfsNotVisible = 0; // number of hidden DIDs in the confirmerIdList, minus the issuer if they aren't visible
recipientName = ""; recipientName = "";
showDetails = false; showDetails = false;
@ -456,6 +454,7 @@ export default class ClaimView extends Vue {
this.confsVisibleErrorMessage = ""; this.confsVisibleErrorMessage = "";
this.confsVisibleToIdList = []; this.confsVisibleToIdList = [];
this.giveDetails = null; this.giveDetails = null;
this.isRegistered = false;
this.numConfsNotVisible = 0; this.numConfsNotVisible = 0;
this.urlForNewGive = ""; this.urlForNewGive = "";
this.veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD; this.veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
@ -469,6 +468,7 @@ export default class ClaimView extends Vue {
this.activeDid = settings?.activeDid || ""; this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || ""; this.apiServer = settings?.apiServer || "";
this.allContacts = await db.contacts.toArray(); this.allContacts = await db.contacts.toArray();
this.isRegistered = settings?.isRegistered || false;
await accountsDB.open(); await accountsDB.open();
const accounts = accountsDB.accounts; 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) { copyToClipboard(name: string, text: string) {
useClipboard() useClipboard()
.copy(text) .copy(text)
@ -802,7 +788,17 @@ export default class ClaimView extends Vue {
} }
notifyWhyCannotConfirm() { 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( this.$notify(
{ {
group: "alert", 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> </div>
<!-- New Contact --> <!-- New Contact -->
<div class="mt-4 mb-4 flex items-stretch"> <div id="formAddNewContact" class="mt-4 mb-4 flex items-stretch">
<router-link <router-link
:to="{ name: 'contact-qr' }" :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" 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> </div>
<!-- Results List --> <!-- 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 <li
class="border-b border-slate-300 pt-2.5 pb-4" class="border-b border-slate-300 pt-2.5 pb-4"
v-for="contact in contacts" v-for="contact in contacts"
@ -113,7 +117,7 @@
</router-link> </router-link>
</h2> </h2>
<div class="text-sm truncate"> <div class="text-sm truncate">
{{ contact.did }} Identifier:
<button <button
@click=" @click="
libsUtil.doCopyTwoSecRedo( libsUtil.doCopyTwoSecRedo(
@ -121,24 +125,51 @@
() => (showDidCopy = !showDidCopy), () => (showDidCopy = !showDidCopy),
) )
" "
class="ml-2 mr-2"
> >
<fa icon="copy" class="text-slate-400 fa-fw"></fa> <fa icon="copy" class="text-slate-400 fa-fw"></fa>
</button> </button>
<span v-show="showDidCopy">Copied DID</span> <span v-show="showDidCopy" class="text-green-500">Copied DID</span>
{{ contact.did }}
</div> </div>
<div class="text-sm truncate" v-if="contact.publicKeyBase64"> <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>
<div class="text-sm truncate" v-if="contact.nextPubKeyHashB64"> <div class="text-sm truncate" v-if="contact.nextPubKeyHashB64">
Next Public Key Hash (base 64): 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 }} {{ contact.nextPubKeyHashB64 }}
</div> </div>
<div id="ContactActions" class="flex gap-1.5 mt-2"> <div id="ContactActions" class="flex gap-1.5 mt-2">
<div v-if="activeDid"> <div v-if="activeDid">
<button <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" 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)" @click="confirmSetVisibility(contact, false)"
title="They can see you" title="They can see you"
@ -146,25 +177,31 @@
<fa icon="eye" class="fa-fw" /> <fa icon="eye" class="fa-fw" />
</button> </button>
<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" 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)" @click="confirmSetVisibility(contact, true)"
title="They cannot see you" title="They cannot see you"
> >
<fa icon="eye-slash" class="fa-fw" /> <fa icon="eye-slash" class="fa-fw" />
</button> </button>
<!-- otherwise it's this user so hide it -->
<fa v-else icon="eye" class="text-white mx-2.5" />
<button <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" 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)" @click="checkVisibility(contact)"
title="Check Visibility" title="Check Visibility"
v-if="activeDid" v-if="contact.did !== activeDid"
> >
<fa icon="rotate" class="fa-fw" /> <fa icon="rotate" class="fa-fw" />
</button> </button>
<!-- otherwise it's this user so hide it -->
<fa v-else icon="rotate" class="text-white mx-2.5" />
<button <button
@click="confirmRegister(contact)" @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" 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" title="Registration"
> >
<fa <fa
@ -174,6 +211,8 @@
/> />
<fa v-else icon="person-circle-question" class="fa-fw" /> <fa v-else icon="person-circle-question" class="fa-fw" />
</button> </button>
<!-- otherwise it's this user so hide it -->
<fa v-else icon="rotate" class="text-white ml-6 px-2.5" />
</div> </div>
<button <button
@ -301,6 +340,7 @@
<script lang="ts"> <script lang="ts">
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import { Buffer } from "buffer/";
import { IndexableType } from "dexie"; import { IndexableType } from "dexie";
import * as R from "ramda"; import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
@ -327,8 +367,6 @@ import EntityIcon from "@/components/EntityIcon.vue";
import GiftedDialog from "@/components/GiftedDialog.vue"; import GiftedDialog from "@/components/GiftedDialog.vue";
import OfferDialog from "@/components/OfferDialog.vue"; import OfferDialog from "@/components/OfferDialog.vue";
import { Buffer } from "buffer/";
@Component({ @Component({
components: { GiftedDialog, EntityIcon, OfferDialog, QuickNav }, components: { GiftedDialog, EntityIcon, OfferDialog, QuickNav },
}) })
@ -356,6 +394,8 @@ export default class ContactsView extends Vue {
hideRegisterPromptOnNewContact = false; hideRegisterPromptOnNewContact = false;
isRegistered = false; isRegistered = false;
showDidCopy = false; showDidCopy = false;
showPubKeyCopy = false;
showPubKeyHashCopy = false;
showGiveNumbers = false; showGiveNumbers = false;
showGiveTotals = true; showGiveTotals = true;
showGiveConfirmed = true; showGiveConfirmed = true;
@ -803,7 +843,7 @@ export default class ContactsView extends Vue {
); );
if (regResult.success) { if (regResult.success) {
contact.registered = true; contact.registered = true;
db.contacts.update(contact.did, { registered: true }); await db.contacts.update(contact.did, { registered: true });
this.$notify( this.$notify(
{ {
@ -867,7 +907,10 @@ export default class ContactsView extends Vue {
title: "Set Visibility", title: "Set Visibility",
text: visibilityPrompt, text: visibilityPrompt,
onYes: async () => { 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, -1,
@ -888,6 +931,8 @@ export default class ContactsView extends Vue {
visibility, visibility,
); );
if (result.success) { 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) { if (showSuccessAlert) {
this.$notify( this.$notify(
{ {
@ -903,18 +948,21 @@ export default class ContactsView extends Vue {
3000, 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( this.$notify(
{ {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Error Setting Visibility", title: "Error Setting Visibility",
text: result.error as string, text: message,
}, },
5000, 5000,
); );
} else { return false;
console.error("Got strange result from setting visibility:", result);
} }
} }
@ -942,14 +990,8 @@ export default class ContactsView extends Vue {
if (resp.status === 200) { if (resp.status === 200) {
const visibility = resp.data; const visibility = resp.data;
contact.seesMe = visibility; contact.seesMe = visibility;
console.log( //console.log("Visi check:", visibility, contact.seesMe, contact.did);
"Visibility checked:", await db.contacts.update(contact.did, { seesMe: visibility });
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 });
this.$notify( this.$notify(
{ {

22
src/views/DIDView.vue

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

2
src/views/DiscoverView.vue

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

9
src/views/GiftedDetails.vue

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

10
src/views/HomeView.vue

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

96
src/views/ProjectViewView.vue

@ -115,7 +115,51 @@
</button> </button>
</div> </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"> <div class="text-center">
<button <button
@click="openOfferDialog()" @click="openOfferDialog()"
@ -127,7 +171,7 @@
</div> </div>
<OfferDialog ref="customOfferDialog" :projectId="this.projectId" /> <OfferDialog ref="customOfferDialog" :projectId="this.projectId" />
<div v-if="activeDid"> <div v-if="activeDid && isRegistered">
<div class="text-center"> <div class="text-center">
<p class="mt-2 mt-4 text-center">Record a contribution from:</p> <p class="mt-2 mt-4 text-center">Record a contribution from:</p>
</div> </div>
@ -313,7 +357,8 @@
v-if="givesProvidedByThis.length > 0" v-if="givesProvidedByThis.length > 0"
class="bg-slate-100 px-4 py-3 rounded-md" class="bg-slate-100 px-4 py-3 rounded-md"
> >
<h3 class="text-sm uppercase font-semibold mb-3 border-b"> <div>
<h3 class="text-sm font-semibold border-b">
Individuals Getting Contributions From This Individuals Getting Contributions From This
</h3> </h3>
<!-- similar to gift display above --> <!-- similar to gift display above -->
@ -358,43 +403,6 @@
<button @click="loadGivesProvidedBy()">Load More</button> <button @click="loadGivesProvidedBy()">Load More</button>
</div> </div>
</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"
>
{{ plan.name }}
</button>
</div>
<div v-if="fulfillersToHitLimit" class="text-center">
<button @click="loadPlanFulfillersTo()">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> </div>
</div> </div>
@ -459,6 +467,7 @@ export default class ProjectViewView extends Vue {
givesProvidedByThis: Array<GiveSummaryRecord> = []; givesProvidedByThis: Array<GiveSummaryRecord> = [];
givesProvidedByHitLimit = false; givesProvidedByHitLimit = false;
imageUrl = ""; imageUrl = "";
isRegistered = false;
issuer = ""; issuer = "";
latitude = 0; latitude = 0;
longitude = 0; longitude = 0;
@ -481,6 +490,7 @@ export default class ProjectViewView extends Vue {
this.activeDid = settings?.activeDid || ""; this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || ""; this.apiServer = settings?.apiServer || "";
this.allContacts = await db.contacts.toArray(); this.allContacts = await db.contacts.toArray();
this.isRegistered = !!settings?.isRegistered;
await accountsDB.open(); await accountsDB.open();
const accounts = accountsDB.accounts; const accounts = accountsDB.accounts;
@ -931,7 +941,11 @@ export default class ProjectViewView extends Vue {
claimType: "GiveAction", claimType: "GiveAction",
issuer: give.agentDid, issuer: give.agentDid,
}; };
return libsUtil.isGiveRecordTheUserCanConfirm(giveDetails, this.activeDid); return libsUtil.isGiveRecordTheUserCanConfirm(
this.isRegistered,
giveDetails,
this.activeDid,
);
} }
confirmConfirmClaim(give: GiveSummaryRecord) { confirmConfirmClaim(give: GiveSummaryRecord) {

12
src/views/ProjectsView.vue

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

18
src/views/SharedPhotoView.vue

@ -66,7 +66,8 @@ import {
} from "@/constants/app"; } from "@/constants/app";
import { db } from "@/db/index"; import { db } from "@/db/index";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; 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 } }) @Component({ components: { PhotoDialog, QuickNav } })
export default class SharedPhotoView extends Vue { 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); const settings = await db.settings.get(MASTER_SETTINGS_KEY);
this.activeDid = settings?.activeDid as string; 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) { if (temp) {
this.imageBlob = temp.blob; this.imageBlob = base64ToBlob(imageB64);
// clear the temp image // clear the temp image
db.temp.delete("shared-photo"); db.temp.delete(SHARED_PHOTO_BASE64_KEY);
this.imageFileName = (this.$route as Router).query[ this.imageFileName = (this.$route as Router).query[
"fileName" "fileName"
] as string; ] as string;
} else {
console.error("No appropriate image found in temp storage.", temp);
} }
} catch (err: unknown) { } catch (err: unknown) {
console.error("Got an error loading an identifier:", err); console.error("Got an error loading an identifier:", err);
@ -156,7 +160,11 @@ export default class SharedPhotoView extends Vue {
let result; let result;
try { try {
// send the image to the server // 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(); const formData = new FormData();
formData.append( formData.append(
"image", "image",

12
src/views/TestView.vue

@ -157,7 +157,7 @@
<div class="mt-8"> <div class="mt-8">
<h2 class="text-xl font-bold mb-4">Image Sharing</h2> <h2 class="text-xl font-bold mb-4">Image Sharing</h2>
Populates the "shared-photo" view as if they used "share_target". 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 <router-link
v-if="showFileNextStep()" v-if="showFileNextStep()"
:to="{ :to="{
@ -165,6 +165,7 @@
query: { fileName }, 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" 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 Go to Shared Page
</router-link> </router-link>
@ -257,8 +258,10 @@ import {
} from "@/libs/crypto/vc/passkeyDidPeer"; } from "@/libs/crypto/vc/passkeyDidPeer";
import { import {
AccountKeyInfo, AccountKeyInfo,
blobToBase64,
getAccount, getAccount,
registerAndSavePasskey, registerAndSavePasskey,
SHARED_PHOTO_BASE64_KEY,
} from "@/libs/util"; } from "@/libs/util";
const inputFileNameRef = ref<Blob>(); const inputFileNameRef = ref<Blob>();
@ -320,12 +323,13 @@ export default class Help extends Vue {
const blob = new Blob([new Uint8Array(data)], { const blob = new Blob([new Uint8Array(data)], {
type: file.type, type: file.type,
}); });
const blobB64 = await blobToBase64(blob);
this.fileName = file.name as string; 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) { if (temp) {
await db.temp.update("shared-photo", { blob }); await db.temp.update(SHARED_PHOTO_BASE64_KEY, { blobB64 });
} else { } 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 { 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 // Generate a random string of 16 characters
let randomString = Math.random().toString(36).substring(2, 18); let randomString = Math.random().toString(36).substring(2, 18);
@ -18,18 +19,8 @@ test('Create new project', async ({ page }) => {
const finalTitle = standardTitle + finalRandomString; const finalTitle = standardTitle + finalRandomString;
const finalDescription = standardDescription + finalRandomString; const finalDescription = standardDescription + finalRandomString;
// Create new ID using seed phrase "rigid shrug mobile…" // Import user 00
await page.goto('./start'); await importUser(page, '00');
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 // Pause for 5 seconds
await page.waitForTimeout(5000); // I have to wait, otherwise the (+) button to add a new project doesn't appear 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.goto('./projects');
await page.getByRole('link', { name: 'Projects', exact: true }).click(); await page.getByRole('link', { name: 'Projects', exact: true }).click();
await page.getByRole('button').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('Idea Name').fill(finalTitle); // Add random suffix
await page.getByPlaceholder('Description').click();
await page.getByPlaceholder('Description').fill(finalDescription); await page.getByPlaceholder('Description').fill(finalDescription);
await page.getByPlaceholder('Website').click();
await page.getByPlaceholder('Website').fill('https://example.com'); 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 Date').fill('2025-12-01');
await page.getByPlaceholder('Start Time').click();
await page.getByPlaceholder('Start Time').fill('12:00'); await page.getByPlaceholder('Start Time').fill('12:00');
await page.getByRole('button', { name: 'Save Project' }).click(); await page.getByRole('button', { name: 'Save Project' }).click();
// Check texts // Check texts
await expect(page.locator('h2')).toContainText(finalTitle); await expect(page.locator('h2')).toContainText(finalTitle);
await expect(page.locator('#Content')).toContainText(finalDescription); 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 { test, expect } from '@playwright/test';
import { importUser } from './testUtils';
test('Record something given', async ({ page }) => { test('Record something given', async ({ page }) => {
// Generate a random string of 16 characters // Generate a random string of 16 characters
@ -19,25 +20,14 @@ test('Record something given', async ({ page }) => {
// Combine title prefix with the random string // Combine title prefix with the random string
const finalTitle = standardTitle + finalRandomString; const finalTitle = standardTitle + finalRandomString;
// Create new ID using seed phrase "rigid shrug mobile…" // Import user 00
await page.goto('./start'); await importUser(page, '00');
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();
// Record something given // Record something given
await page.goto('./'); await page.goto('./');
await page.getByRole('heading', { name: 'Unnamed/Unknown' }).click(); 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.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 page.getByRole('button', { name: 'Sign & Send' }).click();
await expect(page.getByText('That gift was recorded.')).toBeVisible(); 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 { test, expect } from '@playwright/test';
import { importUser } from './testUtils';
test('Create new ID from seed', async ({ page }) => { test('Add contact, record gift, confirm gift', async ({ page }) => {
// Create new ID using seed phrase "rigid shrug mobile…" // Generate a random string of 16 characters
await page.goto('./start'); let randomString = Math.random().toString(36).substring(2, 18);
await page.getByText('You have a seed').click();
await page.getByPlaceholder('Seed Phrase').click(); // In case the string is shorter than 16 characters, generate more characters until it is 16 characters long
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'); while (randomString.length < 16) {
await page.getByRole('button', { name: 'Import' }).click(); 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 // Standard title prefix
await page.getByRole('link', { name: 'Set Your Name' }).click(); const standardTitle = "Gift ";
await page.getByPlaceholder('Name').click();
await page.getByPlaceholder('Name').fill('User Zero');
await page.getByRole('button', { name: 'Save Changes' }).click();
// 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.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 page.locator('button > svg.fa-plus').click();
await expect(page.locator('div[role="alert"]')).toBeVisible(); await expect(page.locator('div[role="alert"]')).toBeVisible();
// Why doesn't the alert box come up every time? // Why doesn't the alert box come up every time?
// await page.locator('div[role="alert"] button:has-text("Yes")').click(); // 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