Compare commits

...

7 Commits

  1. 10
      CHANGELOG.md
  2. 6
      README.md
  3. 4
      package-lock.json
  4. 4
      package.json
  5. 81
      src/components/MembersList.vue
  6. 19
      src/components/UserNameDialog.vue
  7. 1
      src/views/ClaimView.vue
  8. 112
      src/views/NewEditProjectView.vue
  9. 158
      src/views/OnboardMeetingMembersView.vue
  10. 9
      src/views/OnboardMeetingSetupView.vue
  11. 15
      src/views/ProjectViewView.vue
  12. 1
      test-playwright/20-create-project.spec.ts
  13. 2
      test-playwright/25-create-project-x10.spec.ts
  14. 2
      test-playwright/50-record-offer.spec.ts

10
CHANGELOG.md

@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.3.57] - 2025.02.11
### Added
- Automatic user creation in onboarding meetings
## [0.3.55] - 2025.02.07
### Added
- End time for projects
## [0.3.54] - 2025.02.06
### Added
- Group onboarding meetings

6
README.md

@ -50,7 +50,7 @@ Look below for the "test-all" instructions.
* Put the commit hash in the changelog (which will help you remember to bump the version later).
* Tag with the new version, [online](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases) or `git tag 0.3.36` && `git push origin 0.3.36`.
* Tag with the new version, [online](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases) or `git tag 0.3.55 && git push origin 0.3.55`.
* For test, build the app (because test server is not yet set up to build):
@ -70,11 +70,11 @@ TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.
* `pkgx +npm sh`
* `cd crowd-funder-for-time-pwa && git checkout master && git pull && git checkout 0.3.36 && npm install && npm run build && cd -`
* `cd crowd-funder-for-time-pwa && git checkout master && git pull && git checkout 0.3.55 && npm install && npm run build && cd -`
(The plain `npm run build` uses the .env.production file.)
* Back up the time-safari/dist folder, then `mv time-safari/dist time-safari-dist-prev.0 && mv crowd-funder-for-time-pwa/dist time-safari/`
* Back up the time-safari/dist folder & deploy: `mv time-safari/dist time-safari-dist-prev.0 && mv crowd-funder-for-time-pwa/dist time-safari/`
* Record the new hash in the changelog. Edit package.json to increment version & add "-beta", `npm install`, and commit. Also record what version is on production.

4
package-lock.json

@ -1,12 +1,12 @@
{
"name": "TimeSafari",
"version": "0.3.54",
"version": "0.3.57",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "TimeSafari",
"version": "0.3.54",
"version": "0.3.57",
"dependencies": {
"@capacitor/android": "^6.1.2",
"@capacitor/cli": "^6.1.2",

4
package.json

@ -1,6 +1,6 @@
{
"name": "TimeSafari",
"version": "0.3.54",
"version": "0.3.57",
"scripts": {
"dev": "vite",
"serve": "vite preview",
@ -58,7 +58,7 @@
"lru-cache": "^10.2.0",
"luxon": "^3.4.4",
"merkletreejs": "^0.3.11",
"nostr-tools": "^2.7.2",
"nostr-tools": "^2.10.4",
"notiwind": "^2.0.2",
"papaparse": "^5.4.1",
"pina": "^0.20.2204228",

81
src/components/MembersList.vue

@ -1,40 +1,41 @@
<template>
<div class="space-y-4">
<!-- Loading State -->
<div v-if="isLoading" class="flex justify-center items-center py-8">
<div
v-if="isLoading"
class="mt-16 text-center text-4xl bg-slate-400 text-white w-14 py-2.5 rounded-full mx-auto"
>
<fa icon="spinner" class="fa-spin-pulse" />
</div>
<!-- Members List -->
<p
v-if="decryptedMembers.length < members.length"
class="text-center text-red-600 py-4"
>
{{
decryptFailureMessage ||
"Your password failed. Please go back and try again."
}}
</p>
<div v-else>
<div class="text-center text-red-600 py-4">
{{ decryptionErrorMessage() }}
</div>
<div v-else class="space-y-4">
<div v-if="missingMyself" class="py-4 text-red-600">
You are not admitted. The organizer will admit you.
You are not currently admitted by the organizer.
</div>
<div v-if="!firstName" class="py-4 text-red-600">
Your name is not set, so others may not recognize you. Reload this page
to set it.
</div>
<div>
<span
v-if="showOrganizerTools && isOrganizer"
v-if="membersToShow().length > 0 && showOrganizerTools && isOrganizer"
class="inline-flex items-center flex-wrap"
>
<span class="inline-flex items-center">
Use
&bull; Click
<span
class="mx-2 min-w-[24px] min-h-[24px] w-6 h-6 flex items-center justify-center rounded-full bg-blue-100 text-blue-600"
>
<fa icon="plus" class="text-sm" />
</span>
and
/
<span
class="mx-2 min-w-[24px] min-h-[24px] w-6 h-6 flex items-center justify-center rounded-full bg-blue-100 text-blue-600"
>
@ -45,8 +46,11 @@
</span>
</div>
<div>
<span class="inline-flex items-center">
Use
<span
v-if="membersToShow().length > 0"
class="inline-flex items-center"
>
&bull; Click
<span
class="mx-2 w-8 h-8 flex items-center justify-center rounded-full bg-green-100 text-green-600"
>
@ -56,7 +60,8 @@
</span>
</div>
<div v-if="members.length > 0" class="flex justify-center">
<div class="flex justify-center">
<!-- always have at least one refresh button even without members in case the organizer changes the password -->
<button
@click="fetchMembers"
class="w-8 h-8 flex items-center justify-center rounded-full bg-blue-100 text-blue-600 hover:bg-blue-200 hover:text-blue-800 transition-colors"
@ -68,7 +73,7 @@
<div
v-for="member in membersToShow()"
:key="member.member.memberId"
class="p-4 bg-gray-50 rounded-lg"
class="mt-2 p-4 bg-gray-50 rounded-lg"
>
<div class="flex items-center justify-between">
<div class="flex items-center">
@ -131,7 +136,7 @@
{{ member.did }}
</p>
</div>
<div v-if="members.length > 0" class="flex justify-center mt-4">
<div v-if="membersToShow().length > 0" class="flex justify-center mt-4">
<button
@click="fetchMembers"
class="w-8 h-8 flex items-center justify-center rounded-full bg-blue-100 text-blue-600 hover:bg-blue-200 hover:text-blue-800 transition-colors"
@ -187,16 +192,15 @@ export default class MembersList extends Vue {
libsUtil = libsUtil;
@Prop({ required: true }) password!: string;
@Prop({ default: "Your password failed. Please go back and try again." })
decryptFailureMessage!: string;
@Prop({ default: false }) showOrganizerTools!: boolean;
decryptedMembers: DecryptedMember[] = [];
missingPassword = false;
missingMyself = false;
isLoading = false;
firstName = "";
isLoading = true;
isOrganizer = false;
members: Member[] = [];
missingPassword = false;
missingMyself = false;
activeDid = "";
apiServer = "";
contacts: Array<Contact> = [];
@ -205,13 +209,14 @@ export default class MembersList extends Vue {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.firstName = settings.firstName || "";
await this.fetchMembers();
await this.loadContacts();
}
async fetchMembers() {
this.isLoading = true;
try {
this.isLoading = true;
const headers = await getHeaders(this.activeDid);
const response = await this.axios.get(
`${this.apiServer}/api/partner/groupOnboardMembers`,
@ -274,6 +279,28 @@ export default class MembersList extends Vue {
this.missingMyself = !foundMyself;
}
decryptionErrorMessage(): string {
if (this.isOrganizer) {
if (this.decryptedMembers.length < this.members.length) {
return "Some members have data that cannot be decrypted with that password.";
} else {
// the lists must be equal
return "";
}
} else {
// non-organizers should only see problems if the first (organizer) member is not decrypted
if (
this.decryptedMembers.length === 0 ||
this.decryptedMembers[0].member.memberId !== this.members[0].memberId
) {
return "Your password is not the same as the organizer. Reload or have them check their password.";
} else {
// the first (organizer) member was decrypted OK
return "";
}
}
}
membersToShow(): DecryptedMember[] {
if (this.isOrganizer) {
if (this.showOrganizerTools) {
@ -356,7 +383,7 @@ export default class MembersList extends Vue {
group: "modal",
type: "confirm",
title: "Continue Without Adding?",
text: "Are you sure you want to proceed with admission even though they are not a contact?",
text: "Are you sure you want to proceed with admission? If they are not a contact, you will not know their name after this meeting.",
yesText: "Continue",
onYes: async () => {
await this.toggleAdmission(decrMember);

19
src/components/UserNameDialog.vue

@ -4,8 +4,7 @@
<div class="dialog">
<h1 class="text-xl font-bold text-center mb-4">Set Your Name</h1>
This is not sent to servers. It is only shared with people when you send
it to them.
{{ sharingExplanation }}
<input
type="text"
placeholder="Name"
@ -36,7 +35,7 @@
</template>
<script lang="ts">
import { Vue, Component } from "vue-facing-decorator";
import { Vue, Component, Prop } from "vue-facing-decorator";
import { NotificationIface } from "@/constants/app";
import { db, retrieveSettingsForActiveAccount } from "@/db/index";
@ -46,14 +45,21 @@ import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
export default class UserNameDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
callback: (name: string) => void = () => {};
@Prop({
default:
"This is not sent to servers. It is only shared with people when you send it to them.",
})
sharingExplanation!: string;
@Prop({ default: false }) callbackOnCancel!: boolean;
callback: (name?: string) => void = () => {};
givenName = "";
visible = false;
/**
* @param aCallback - callback function for name, which may be ""
*/
async open(aCallback?: (name: string) => void) {
async open(aCallback?: (name?: string) => void) {
this.callback = aCallback || this.callback;
const settings = await retrieveSettingsForActiveAccount();
this.givenName = settings.firstName || "";
@ -70,6 +76,9 @@ export default class UserNameDialog extends Vue {
onClickCancel() {
this.visible = false;
if (this.callbackOnCancel) {
this.callback();
}
}
}
</script>

1
src/views/ClaimView.vue

@ -74,6 +74,7 @@
</div>
<div>
<fa icon="calendar" class="fa-fw text-slate-400" />
Recorded
{{ veriClaim.issuedAt?.replace(/T/, " ").replace(/Z/, " UTC") }}
</div>
<div v-if="veriClaim.claim.image" class="flex justify-center">

112
src/views/NewEditProjectView.vue

@ -71,17 +71,17 @@
<textarea
placeholder="Description"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
class="block w-full rounded border border-slate-400 px-3 py-2"
rows="5"
v-model="fullClaim.description"
maxlength="5000"
></textarea>
<div class="text-xs text-slate-500 italic -mt-3 mb-4">
<div class="text-xs text-slate-500 italic">
If you want to be contacted, be sure to include your contact information
-- just remember that this information is public and saved in a public
history.
</div>
<div class="text-xs text-slate-500 italic -mt-3 mb-4">
<div class="text-xs text-slate-500 italic">
{{ fullClaim.description?.length }}/5000 max. characters
</div>
@ -89,28 +89,55 @@
v-model="fullClaim.url"
placeholder="Website"
autocapitalize="none"
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
class="block w-full rounded border border-slate-400 mt-4 px-3 py-2"
/>
<div class="flex mb-4 columns-3 w-full">
<input
v-model="startDateInput"
placeholder="Start Date"
type="date"
class="col-span-1 w-full rounded border border-slate-400 px-3 py-2"
/>
<input
:disabled="!startDateInput"
placeholder="Start Time"
v-model="startTimeInput"
type="time"
class="col-span-1 w-full rounded border border-slate-400 ml-2 px-3 py-2"
/>
<span class="col-span-1 w-full flex justify-center">{{ zoneName }}</span>
<div>
<div class="flex items-center mt-4">
<span class="mr-2">Starts At</span>
<input
v-model="startDateInput"
placeholder="Start Date"
type="date"
class="rounded border border-slate-400 px-3 py-2"
/>
<input
:disabled="!startDateInput"
placeholder="Start Time"
v-model="startTimeInput"
type="time"
class="rounded border border-slate-400 ml-2 px-3 py-2"
/>
</div>
<div class="flex w-full justify-end items-center">
<span class="w-full flex justify-end items-center">
{{ zoneName }} time zone
</span>
</div>
<div class="flex items-center">
<div class="mr-2">
<span>Ends at</span>
</div>
<input
v-model="endDateInput"
placeholder="End Date"
type="date"
class="ml-2 rounded border border-slate-400 px-3 py-2"
/>
<input
:disabled="!endDateInput"
placeholder="End Time"
v-model="endTimeInput"
type="time"
class="rounded border border-slate-400 ml-2 px-3 py-2"
/>
</div>
</div>
<div
class="flex items-center mb-4"
class="flex items-center mt-4"
@click="includeLocation = !includeLocation"
>
<input type="checkbox" class="mr-2" v-model="includeLocation" />
@ -203,17 +230,7 @@ import "leaflet/dist/leaflet.css";
import { AxiosError, AxiosRequestHeaders } from "axios";
import { DateTime } from "luxon";
import { hexToBytes } from "@noble/hashes/utils";
// these core imports could also be included as "import type ..."
import {
EventTemplate,
UnsignedEvent,
VerifiedEvent,
} from "nostr-tools/lib/types/core";
import {
accountFromExtendedKey,
extendedKeysFromSeedWords,
} from "nostr-tools/nip06";
import { finalizeEvent, serializeEvent } from "nostr-tools/pure";
import { finalizeEvent, serializeEvent } from "nostr-tools";
import { Component, Vue } from "vue-facing-decorator";
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
@ -251,6 +268,8 @@ export default class NewEditProjectView extends Vue {
activeDid = "";
agentDid = "";
apiServer = "";
endDateInput?: string;
endTimeInput?: string;
errorMessage = "";
fullClaim: PlanVerifiableCredential = {
"@context": "https://schema.org",
@ -325,6 +344,13 @@ export default class NewEditProjectView extends Vue {
this.startDateInput = localDateTime.toFormat("yyyy-MM-dd");
this.startTimeInput = localDateTime.toFormat("HH:mm");
}
if (this.fullClaim.endTime) {
const localDateTime = DateTime.fromISO(
this.fullClaim.endTime as string,
).toLocal();
this.endDateInput = localDateTime.toFormat("yyyy-MM-dd");
this.endTimeInput = localDateTime.toFormat("HH:mm");
}
}
} catch (error) {
console.error("Got error retrieving that project", error);
@ -460,7 +486,7 @@ export default class NewEditProjectView extends Vue {
group: "alert",
type: "danger",
title: "Date Error",
text: "The date was invalid so it was not set.",
text: "The start date was invalid so it was not set.",
},
5000,
);
@ -468,6 +494,28 @@ export default class NewEditProjectView extends Vue {
} else {
delete vcClaim.startTime;
}
if (this.endDateInput) {
try {
const endTimeFull = this.endTimeInput || "23:59:59";
const fullTimeString = this.endDateInput + " " + endTimeFull;
// throw an error on an invalid date or time string
vcClaim.endTime = new Date(fullTimeString).toISOString(); // ensure timezone is part of it
} catch {
// it's not a valid date so erase it and tell the user
delete vcClaim.endTime;
this.$notify(
{
group: "alert",
type: "danger",
title: "Date Error",
text: "The end date was invalid so it was not set.",
},
5000,
);
}
} else {
delete vcClaim.endTime;
}
const vcJwt = await createEndorserJwtVcFromClaim(this.activeDid, vcClaim);
// Make the xhr request payload

158
src/views/OnboardMeetingMembersView.vue

@ -8,8 +8,16 @@
Meeting Members
</h1>
<!-- Loading Animation -->
<div
class="mt-16 text-center text-4xl bg-slate-400 text-white w-14 py-2.5 rounded-full mx-auto"
v-if="isLoading"
>
<fa icon="spinner" class="fa-spin-pulse"></fa>
</div>
<!-- Error State -->
<div v-if="errorMessage">
<div v-else-if="errorMessage">
<div class="text-center text-red-600 py-8">
{{ errorMessage }}
</div>
@ -19,13 +27,14 @@
</div>
<!-- Members List -->
<MembersList
v-else
:password="password"
:decrypt-failure-message="'That password failed. You may be in the wrong meeting. Go back and try again.'"
@error="handleError"
/>
<MembersList v-else :password="password" @error="handleError" />
</section>
<UserNameDialog
ref="userNameDialog"
:callback-on-cancel="true"
sharing-explanation="This is encrypted and shared only with people in this meeting."
/>
</template>
<script lang="ts">
@ -35,16 +44,35 @@ import { RouteLocation } from "vue-router";
import QuickNav from "@/components/QuickNav.vue";
import TopMessage from "@/components/TopMessage.vue";
import MembersList from "@/components/MembersList.vue";
import UserNameDialog from "@/components/UserNameDialog.vue";
import { logConsoleAndDb, retrieveSettingsForActiveAccount } from "@/db/index";
import { encryptMessage } from "@/libs/crypto";
import {
errorStringForLog,
getHeaders,
serverMessageForUser,
} from "@/libs/endorserServer";
import { generateSaveAndActivateIdentity } from "@/libs/util";
@Component({
components: {
QuickNav,
TopMessage,
MembersList,
UserNameDialog,
},
})
export default class OnboardMeetingMembersView extends Vue {
activeDid = "";
apiServer = "";
errorMessage = "";
firstName = "";
isRegistered = false;
isLoading = true;
$refs!: {
userNameDialog: InstanceType<typeof UserNameDialog>;
};
get groupId(): string {
return (this.$route as RouteLocation).params.groupId as string;
@ -63,6 +91,122 @@ export default class OnboardMeetingMembersView extends Vue {
this.errorMessage = "The password is missing. Go back and try again.";
return;
}
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.firstName = settings.firstName || "";
this.isRegistered = settings.isRegistered || false;
try {
if (!this.activeDid) {
this.activeDid = await generateSaveAndActivateIdentity();
this.isRegistered = false;
}
const headers = await getHeaders(this.activeDid);
const response = await this.axios.get(
`${this.apiServer}/api/partner/groupOnboardMember`,
{ headers },
);
const member = response.data?.data;
if (!member) {
if (!this.firstName) {
this.$refs.userNameDialog.open(this.addMemberToMeeting);
// addMemberToMeeting sets isLoading to false
} else {
await this.addMemberToMeeting(this.firstName);
// addMemberToMeeting sets isLoading to false
}
} else if (String(member.groupId) !== this.groupId) {
this.errorMessage =
"You are already in a different meeting. Reload or go back and try again.";
this.isLoading = false;
} else {
// must be already in the right meeting
if (!this.firstName) {
this.$refs.userNameDialog.open(this.updateMemberInMeeting);
// updateMemberInMeeting sets isLoading to false
} else {
await this.updateMemberInMeeting(this.firstName);
// updateMemberInMeeting sets isLoading to false
}
}
} catch (error) {
this.errorMessage =
serverMessageForUser(error) ||
"There was an error checking for that meeting. Reload or go back and try again.";
logConsoleAndDb(
"Error checking meeting: " + errorStringForLog(error),
true,
);
this.isLoading = false;
}
}
async addMemberToMeeting(name?: string) {
if (name != null) {
this.firstName = name;
}
const memberData = {
name: this.firstName,
did: this.activeDid,
isRegistered: this.isRegistered,
};
const memberDataString = JSON.stringify(memberData);
const encryptedMemberData = await encryptMessage(
memberDataString,
this.password,
);
const headers = await getHeaders(this.activeDid);
try {
await this.axios.post(
`${this.apiServer}/api/partner/groupOnboardMember`,
{ groupId: this.groupId, content: encryptedMemberData },
{ headers },
);
} catch (error) {
logConsoleAndDb(
"Error adding member to meeting: " + errorStringForLog(error),
true,
);
this.errorMessage =
serverMessageForUser(error) ||
"You're not in a meeting and couldn't be added to this one. Reload or go back and try again.";
}
this.isLoading = false;
}
async updateMemberInMeeting(name?: string) {
if (name != null) {
this.firstName = name;
}
const memberData = {
name: this.firstName,
did: this.activeDid,
isRegistered: this.isRegistered,
};
const memberDataString = JSON.stringify(memberData);
const encryptedMemberData = await encryptMessage(
memberDataString,
this.password,
);
const headers = await getHeaders(this.activeDid);
try {
await this.axios.put(
`${this.apiServer}/api/partner/groupOnboardMember`,
{ content: encryptedMemberData },
{ headers },
);
} catch (error) {
logConsoleAndDb(
"Error updating member in meeting: " + errorStringForLog(error),
true,
);
this.errorMessage =
serverMessageForUser(error) ||
"There was an error updating your name. Reload or go back and try again.";
}
this.isLoading = false;
}
handleError(message: string) {

9
src/views/OnboardMeetingSetupView.vue

@ -192,23 +192,23 @@
<!-- Members Section -->
<div
v-if="!isLoading && currentMeeting != null"
v-if="!isLoading && currentMeeting != null && !!currentMeeting.password"
class="mt-8 p-4 border rounded-lg bg-white shadow"
>
<div class="flex items-center justify-between mb-4">
<h2 class="text-2xl">Meeting Members</h2>
</div>
<router-link
v-if="!!currentMeeting.password"
:to="onboardMeetingMembersLink()"
class="inline-block text-blue-600"
target="_blank"
>
Open shortcut page for members <fa icon="external-link" />
&bull; Open shortcut page for members <fa icon="external-link" />
</router-link>
<MembersList
:password="currentMeeting.password || ''"
:decrypt-failure-message="DECRYPT_FAILURE_MESSAGE"
:show-organizer-tools="true"
@error="handleMembersError"
class="mt-4"
@ -264,9 +264,6 @@ export default class OnboardMeetingView extends Vue {
timeout?: number,
) => void;
DECRYPT_FAILURE_MESSAGE =
"Unable to decrypt some member information. Check your password, or have them reset theirs if they don't show here.";
currentMeeting: ServerMeeting | null = null;
newOrUpdatedMeeting: MeetingSetupInfo | null = null;
activeDid = "";

15
src/views/ProjectViewView.vue

@ -69,7 +69,11 @@
</div>
<div v-if="startTime">
<fa icon="calendar" class="fa-fw text-slate-400"></fa>
{{ startTime }}
Starts {{ startTime }}
</div>
<div v-if="endTime">
<fa icon="calendar" class="fa-fw text-slate-400"></fa>
Ends {{ endTime }}
</div>
<div v-if="latitude || longitude">
<fa icon="location-dot" class="fa-fw text-slate-400"></fa>
@ -541,6 +545,7 @@ export default class ProjectViewView extends Vue {
apiServer = "";
checkingConfirmationForJwtId = "";
description = "";
endTime = "";
expanded = false;
fulfilledByThis: PlanSummaryRecord | null = null;
fulfillersToThis: Array<PlanSummaryRecord> = [];
@ -641,6 +646,14 @@ export default class ProjectViewView extends Vue {
" " +
startDateTime.toLocaleTimeString();
}
const endTime = resp.data.claim?.endTime;
if (endTime != null) {
const endDateTime = new Date(endTime);
this.endTime =
endDateTime.toLocaleDateString() +
" " +
endDateTime.toLocaleTimeString();
}
this.agentDid = resp.data.claim?.agent?.identifier;
this.agentDidVisibleToDids =
resp.data.claim?.agent?.identifierVisibleToDids || [];

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

@ -2,7 +2,6 @@ import { test, expect } from '@playwright/test';
import { importUser } from './testUtils';
test('Create new project, then search for it', async ({ page }) => {
test.slow();
// Generate a random string of 16 characters
let randomString = Math.random().toString(36).substring(2, 18);

2
test-playwright/25-create-project-x10.spec.ts

@ -2,7 +2,7 @@ import { test, expect } from '@playwright/test';
import { importUser, createUniqueStringsArray } from './testUtils';
test('Create 10 new projects', async ({ page }) => {
test.setTimeout(40000); // Set timeout longer since it often fails at 30 seconds
test.slow(); // Set timeout longer since it often fails at 30 seconds
const projectCount = 10;

2
test-playwright/50-record-offer.spec.ts

@ -2,6 +2,8 @@ import { test, expect } from '@playwright/test';
import { importUser } from './testUtils';
test('Record an offer', async ({ page }) => {
test.setTimeout(45000);
// Generate a random string of 3 characters, skipping the "0." at the beginning
const randomString = Math.random().toString(36).substring(2, 5);
// Standard title prefix

Loading…
Cancel
Save