internal: Add support for Studio first use instructions (#32197)

* add support for studioFirstUseInstructionsDismissed

* add support for setting global or project saved state

* add get:app:state socket event

* handle getSavedState errors

* check that config is initialized
This commit is contained in:
Chris Breiding
2025-08-12 08:17:49 -04:00
committed by GitHub
parent e59949fb79
commit c46b3c3165
5 changed files with 133 additions and 15 deletions
+17 -6
View File
@@ -403,7 +403,8 @@ export class ProjectBase extends EE {
onReloadBrowser: options.onReloadBrowser,
onFocusTests: options.onFocusTests,
onSpecChanged: options.onSpecChanged,
onSavedStateChanged: (state: any) => this.saveState(state),
getSavedState: this.getSavedState.bind(this),
onSavedStateChanged: this.saveState.bind(this),
closeExtraTargets: this.closeExtraTargets,
onStudioInit: async () => {
@@ -714,17 +715,27 @@ export class ProjectBase extends EE {
// Saved state
// forces saving of project's state by first merging with argument
async saveState (stateChanges = {}) {
async getSavedState (options: { type: 'global' | 'project' } = { type: 'project' }) {
if (!this.cfg) {
throw new Error('Missing project config')
throw new Error('Missing project config trying to get saved state')
}
if (!this.projectRoot) {
const state = await savedState.create(options.type === 'project' ? this.projectRoot : undefined, this.cfg.isTextTerminal)
return state.get()
}
// forces saving of project's state by first merging with argument
async saveState (stateChanges = {}, options: { type: 'global' | 'project' } = { type: 'project' }) {
if (!this.cfg) {
throw new Error('Missing project config trying to save state')
}
if (options.type === 'project' && !this.projectRoot) {
throw new Error('Missing project root')
}
let state = await savedState.create(this.projectRoot, this.cfg.isTextTerminal)
let state = await savedState.create(options.type === 'project' ? this.projectRoot : undefined, this.cfg.isTextTerminal)
state.set(stateChanges)
this.cfg.state = await state.get()
+15 -1
View File
@@ -157,6 +157,7 @@ export class SocketBase {
onChromiumRun () {},
onReloadBrowser () {},
closeExtraTargets () {},
getSavedState () {},
onSavedStateChanged () {},
onTestFileChange () {},
onCaptureVideoFrames () {},
@@ -581,8 +582,21 @@ export class SocketBase {
return cb(s || {}, cachedTestState)
})
socket.on('get:app:state', async (opts, cb) => {
try {
const state = await options.getSavedState(opts)
cb({ data: state })
} catch (error) {
cb({ error: errors.cloneErr(error) })
}
})
socket.on('save:app:state', (state, cb) => {
options.onSavedStateChanged(state)
const opts = state.__options
const stateWithoutOptions = _.omit(state, '__options')
options.onSavedStateChanged(stateWithoutOptions, opts)
// we only use the 'ack' here in tests
if (cb) {
+67 -3
View File
@@ -80,14 +80,51 @@ describe('lib/project-base', () => {
expect(p.projectRoot).to.eq(path.resolve(path.join('..', 'foo', 'bar')))
})
context('#getSavedState', () => {
beforeEach(async function () {
const globalState = await savedState.create()
await globalState.remove()
await globalState.set({ reporterWidth: 400 })
const projectState = await savedState.create(this.project.projectRoot)
await projectState.remove()
await projectState.set({ reporterWidth: 500 })
})
it('returns global state when type is global', async function () {
const state = await this.project.getSavedState({ type: 'global' })
expect(state).to.deep.eq({ reporterWidth: 400 })
})
it('returns project state when type is project', async function () {
const state = await this.project.getSavedState({ type: 'project' })
expect(state).to.deep.eq({ reporterWidth: 500 })
})
it('returns project state when type is undefined', async function () {
const state = await this.project.getSavedState()
expect(state).to.deep.eq({ reporterWidth: 500 })
})
})
context('#saveState', function () {
beforeEach(function () {
beforeEach(async function () {
const supportFile = path.join('the', 'save', 'state', 'test')
this.project.cfg = { supportFile }
return savedState.create(this.project.projectRoot)
.then((state) => state.remove())
const globalState = await savedState.create()
await globalState.remove()
const projectState = await savedState.create(this.project.projectRoot)
await projectState.remove()
})
afterEach(function () {
@@ -119,6 +156,33 @@ describe('lib/project-base', () => {
.then(() => this.project.saveState({ appWidth: 'modified' }))
.then((state) => expect(state).to.deep.eq({ appWidth: 'modified' }))
})
it('saves global state when type is global', async function () {
await this.project.saveState({ reporterWidth: 1 }, { type: 'global' })
const state = await savedState.create()
.then((state) => state.get())
expect(state).to.deep.eq({ reporterWidth: 1 })
})
it('saves project state when type is project', async function () {
await this.project.saveState({ reporterWidth: 2 }, { type: 'project' })
const state = await savedState.create(this.project.projectRoot)
.then((state) => state.get())
expect(state).to.deep.eq({ reporterWidth: 2 })
})
it('saves project state when type is undefined', async function () {
await this.project.saveState({ reporterWidth: 3 })
const state = await savedState.create(this.project.projectRoot)
.then((state) => state.get())
expect(state).to.deep.eq({ reporterWidth: 3 })
})
})
context('#initializeConfig', () => {
+32 -5
View File
@@ -64,6 +64,7 @@ describe('lib/socket', () => {
})
.then(() => {
this.options = {
getSavedState: sinon.stub(),
onSavedStateChanged: sinon.spy(),
onStudioInit: sinon.stub(),
onStudioDestroy: sinon.stub(),
@@ -250,12 +251,38 @@ describe('lib/socket', () => {
})
})
context('on(save:app:state)', () => {
it('calls onSavedStateChanged with the state', function (done) {
return this.client.emit('save:app:state', { reporterWidth: 500 }, () => {
expect(this.options.onSavedStateChanged).to.be.calledWith({ reporterWidth: 500 })
context('on(get:app:state)', () => {
it('calls getSavedState with options and returns the state', function (done) {
this.options.getSavedState.resolves({ reporterWidth: 500 })
return done()
this.client.emit('get:app:state', { type: 'global' }, (resp) => {
expect(this.options.getSavedState).to.be.calledWith({ type: 'global' })
expect(resp.data).to.deep.eq({ reporterWidth: 500 })
done()
})
})
it('handles errors thrown by getSavedState', function (done) {
const err = new Error('boom')
this.options.getSavedState.rejects(err)
this.client.emit('get:app:state', { type: 'global' }, (resp) => {
expect(this.options.getSavedState).to.be.calledWith({ type: 'global' })
expect(resp.error).to.deep.eq(errors.cloneErr(err))
done()
})
})
})
context('on(save:app:state)', () => {
it('calls onSavedStateChanged with the state and options', function (done) {
this.client.emit('save:app:state', { reporterWidth: 500, __options: { type: 'global' } }, () => {
expect(this.options.onSavedStateChanged).to.be.calledWith({ reporterWidth: 500 }, { type: 'global' })
done()
})
})
})
+2
View File
@@ -51,6 +51,7 @@ export const allowedKeys: Readonly<Array<keyof AllowedState>> = [
'notifyWhenRunStarts',
'notifyWhenRunStartsFailing',
'notifyWhenRunCompletes',
'studioFirstUseInstructionsDismissed',
] as const
type Maybe<T> = T | null | undefined
@@ -93,4 +94,5 @@ export type AllowedState = Partial<{
notifyWhenRunStarts: Maybe<boolean>
notifyWhenRunStartsFailing: Maybe<boolean>
notifyWhenRunCompletes: Maybe<NotifyWhenRunCompletes[]>
studioFirstUseInstructionsDismissed: Maybe<boolean>
}>