question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Serenity 3.0 - Type-safe Notepad

See original GitHub issue

A follow up to https://github.com/serenity-js/serenity-js/issues/817, cc: @viper3400


Context

The current 3.0-rc implementation of the Notepad makes it easy for developers to create untyped notepads:

actorCalled('Nate')
  .whoCan(TakeNotes.using(Notepad.empty()))
  .attemptsTo(
    Note.record('example_note', 'content'),
    Ensure.that(Note.of('example_note').isPresent(), true),
    Ensure.that(Note.of('example_note'), equals('content'),
  )

However, an untyped Notepad also makes it easy to:

  • make typing errors when specifying the name of the note to be retrieved
  • forget to update the name of the note, especially in deeply-nested tasks, when the name changes

It seems like using an (optionally) strongly-typed Notepad that defines the types of notes that can be stored in it would address this problem.

Here again the current 3.0-rc implementation allows us to define a strongly-typed Notepad. For example, given the following structure of MyNotes:

interface PersonalDetails {
  name: string;
  age:  number;
}

interface MyNotes {
  personal: PersonalDetails;
}

We can define the notepad as follows:


Notepad.empty<MyNotes>()

Notepad.with<MyNotes>({
  personal: {
    name: 'Alice',
    age:  27,
  }
})

We can also retrieve the strongly-typed notes as follows:

actorCalled('Alice')
  .whoCan(TakeNotes.using(Notepad.empty<MyNotes>()))
  .attemptsTo(
    Note.record<MyNotes>('personal', { name: 'Alice', age:  27 }),
    Ensure.that(Note.of<MyNotes>('personal').isPresent(), true),
    Ensure.that(Note.of<MyNotes>('personal'), equals({ name: 'Alice', age:  27 })),

    // using a QuestionAdapter    
    Ensure.that(Note.of<MyNotes>('personal').name, equals('Alice')),
    Ensure.that(Note.of<MyNotes>('personal').age,  isGreaterThan(18)),
  )

The problem we’re trying to solve

The problem arises when the notes contain multiple TypeScript types.

For example, if MyNotes were to be defined as follows:

interface AuthDetails {
  username: string;
  password: string;
}

interface PersonalDetails {
  name: string;
  age:  number;
}

interface MyNotes {
  auth?:    AuthDetails;
  personal: PersonalDetails;
}

We will no longer be able to access Note.of<MyNotes>('personal').name and the following code will generate a type error: TS2339: Property 'name' does not exist on type 'QuestionAdapter '

    Ensure.that(Note.of<MyNotes>('personal').name, equals('Alice')),

This is because of TypeScript’s lack of support for partial type argument inference (see https://github.com/microsoft/TypeScript/issues/26242 and https://github.com/microsoft/TypeScript/issues/10571), which means that

Note.of<MyNotes>('personal')

is interpreted as QuestionAdapter<AuthDetails | PersonalDetails>, and since name is not present in type AuthDetails we get the type error.

How we can solve it

Option 1: explicitly property name

One way to solve this problem would be to provide an explicit name of the property we’re planning to retrieve when we specify the interface describing the notes themselves:

class Notes {
    static of<Notes extends Record<string, any>, Key extends keyof Notes>(subject: Key): QuestionAdapter<Notes[Key]> {
       // ...
    }
}

Advantages:

  • Makes the Notepad more type-safe and allows for type-specific assertions and using QuestionAdapter to access properties and methods of the underlying type, e.g. Note.of<MyNotes, 'personal'>('personal').name.toLocaleLowerCase()

Disadvantages:

  • The API doesn’t look that great Note.of<MyNotes, 'personal'>('personal').name 🧐
  • Doesn’t support dynamic subjects, e.g. Note.of<MyNotes, ???>('my_note_' + test_case_name)

Option 2: make the whole notepad accessible

Another approach would be to separate the retrieval of the notepad or one of its “sections” from retrieving the specific note.

For example, while the below factory methods allow us to create a Notepad:

Notepad.empty<MyNotes>()         : Notepad<MyNotes>
Notepad.with<MyNotes>({ /* */ }) : Notepad<MyNotes>

we could have a factory method that retrieves the notepad associated with a given actor:

Notepad.notes<MyNotes>() : QuestionAdapter<MyNotes>

Since the above method returns a QuestionAdapter<MyNotes> we could then easily access specific notes:

    Ensure.that(Notepad.notes<MyNotes>().personal.name, equals('Alice')),
    Ensure.that(Notepad.notes<MyNotes>()['personal']name, equals('Alice')),
    Ensure.that(Notepad.notes<MyNotes>()[`something-${ 'dynamic' }`].name, equals('Alice')) // returns `any`

Advantages:

  • makes retrieval of notes super easy
  • leverages QuestionAdapter
  • allows for the name of the note to be determined dynamically to a degree

Disadvantages:

  • doesn’t allow for the name of the note to be determined based on the value of another Question
  • I’m not sure what the API responsible for recording the notes should be 🤔

Recording notes

We could potentially have QuestionAdapter add .set(value: T): QuestionAdapter<T> the same way it adds .isPresent(): Question<Promise<boolean>>.

This way we’d have:

  • Notepad.notes<MyNotes>().personal.name returns QuestionAdapter<string>
  • ~Notepad.notes<MyNotes>().personal.name.set('Alex') also returns QuestionAdapter<string> (not sure if this one’s doable…)~ - it isn’t

or perhaps better:

  • Notepad.notes<MyNotes>().personal.set('name', 'Alex') returns QuestionAdapter<PersonalDetails>
  • Notepad.notes<MyNotes>().set('personal', { name: 'Alex', age: 34 }) returns QuestionAdapter<MyNotes>

Advantages:

  • Makes the API reasonably intuitive
  • The setter could support Answerable<string>, so would work with dynamic values
  • Opens the doors to supporting default values, e.g. Notepad.notes<MyNotes>().personal.name.orElse('Alex'), which could be useful on its own

Disadvantages:

  • changing QuestionAdapter would affect every other QuestionAdapter; this may or may not be desirable.
  • potentially tricky to implement; consider:
Notepad.empty() // so initial state is {}
Notepad.notes().personal.set('name', 'Bob')    // should this throw?
Notepad.notes().personal.name.set('length', 2) // overriding a property of a string? uh.

What about orElse()? Should it just return the alternative?

Issue Analytics

  • State:closed
  • Created a year ago
  • Comments:6 (2 by maintainers)

github_iconTop GitHub Comments

1reaction
viper3400commented, Jun 6, 2022

@jan-molak

I was able to update my business project on Friday to rc17. There I’m still using empty and untyped notepads with the old “subject” style. This went well.

As I’m now out of office for a week, I fiddled around with the notes specs this morning and tried to implement something like:

interface MyNotes { // you might want to call this interface something more descriptive
  items: string[];
  otherNotes: Map<string, string>; // association between item name and the properties we care about
}

This did not work out with rc17, but meanwhile you’ve released rc18 and I got this working - at least in the tests of the core package.

I’ve to look how this would apply to our real world project when I’m back in office next week.

As I wrote before, this notes thing may help to understand the difference between different actors. In the most of the Serenity/JS examples an actor just needs the Ability to BrowseTheWeb or to CallAnApi using an url. So there was no need to have different actors - or at least it was a bit abstract, why one should use different actors. But giving them different types of notepads makes them more distinguishable.

I have to think about that…

1reaction
viper3400commented, May 23, 2022

Use Case No. 2

This would be to add authentication to our shopping list. Users have to provide their credentials before they can use the shopping list. Initial data of the notepad shall be used. All other things are the same as in use case no. 1

Use Case No. 3

Our app now shall be tested with different users - so different sets of credentials.

Use Case No. 4

Our app gets an update. Users can now order the items in their list. They get a (not predictable) order number. This order number must be entered again at the next page for confirmation (so we have to take a note of it)


Writing down this use cases helped me to figure out my concerns about the notepad:

One concern ist about the mixup of typed and untyped notes in the same notepad, as well, as the mixup of initial / not initial data. But I think, that’s something you covered?

The second concern is the ability to scale, when application grows. There may be a lot of different types needed in different places of the application. So you would spin up all this in the actors object.

So maybe the second concern is not about the notepad at all and thats why it’s difficult for me to explain. It’s probably about what you mention here.

Actually, I never thought about an Actor as kind of DI container. For now, I still init the Actor once in my whole test suite (given the possibility, you can init it in the wdio.conf).

Maybe the question (or the concern) is about the lifetime of a notepad and the lifetime of an actor.

The nature of the “first steps in Serenity/JS examples” is, that they cover straight forward use cases. Maybe some good practices for growing test suites can help.

Read more comments on GitHub >

github_iconTop Results From Across the Web

Serenity/JS 3.0 (RC)
Using an untyped Notepad. If you don't want to use the typed notepad in the first steps of your migration, you can still...
Read more >
serenity-js/Lobby - Gitter
Serenity /JS 3.0 modules now ship with source maps for TypeScript type definitions. This means that clicking through a Serenity/JS method or class...
Read more >
@serenity-js/core | Yarn - Package Manager
Features · core: Screenplay-style Dictionary<T> to help resolve objects with nested Questions (6a66778), closes #1219 · core: type-safe Notepad and improved notes ...
Read more >
Jan Molak (@JanMolak) / Twitter
Serenity /JS 3.0.0-rc.27 available on #npm with: - support for ... significantly improved and type-safe Notes DSL - support for Notepads with an...
Read more >
serenity-js - Bountysource
It would be useful if actors could have notepads that have some initial ... Serenity/JS 3.0 - ERROR webdriver: Request failed with status...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found