Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Proposal] Watch plugins API #5399

Merged
merged 26 commits into from
Feb 6, 2018
Merged

[Proposal] Watch plugins API #5399

merged 26 commits into from
Feb 6, 2018

Conversation

rogeliog
Copy link
Contributor

@rogeliog rogeliog commented Jan 26, 2018

This PR adds watch plugins to Jest... Feedback is more than welcome!

plugins

Pending Items

  • Update Changelog
  • Add docs for watch plugins.
  • I added shouldRunTestSuite(...) to JestHooks but I still need to implement it.
  • Fix skipped tests
  • Make sure things work well in non interactive envirnoments
  • Make sure things work when save a file and you have an active plugin.

NOTE: once shouldRunTestSuite(...) it would be interesting if we can reimplement the "failed tests mode" as a plugin that hooks into onTestComplete(...) for keeping track of the results and shouldRunTestSuite(...) to prevent running test suites that didn't fail

Open questions

  • Given that plugins are instantiable objects, I'm a bit worried about how we are going to document the interface for creating a plugin. Should we change them to a function?
  • Right now it shows the plugins in a different order than master [Need to fix before merging]. How should we define a way of sorting the plugins?
  • Seems like at some point we will need to expand plugins to work in "non-watch" mode... any thoughts?

Plugin API

Feedback on the API is more than welcome.

WatchPlugin {
  showPrompt: (globalConfig: GlobalConfig, updateConfigAndRun: Function) => Promise<*>,
  getUsageRow: (globalConfig: GlobalConfig, hasFailedSnapshots: boolean) => ?{
    key: number,
    prompt: string,
    hide?: boolean,
  },
  onData: (value: string) => void,

  registerHooks: (hooks: JestHookSubscriber) => void,
}

First part: Watch mode usage interactions.

  • showPrompt(...): This gives control to the plugin to manage test execution. It provides a updateConfigAndRun function and returns a promise that can be resolved when the control needs to be given back to Jest.
  • onData(...): A helper function for capturing keystrokes when the plugin is active.
  • getUsageRow(...): Returns the usage row information, and allows for dynamically updating the text, hiding, etc. Example: { key: S, prompt: 'filter by @tags' }

Second part: registerHooks

This is how WatchPlugins will hook into different parts of the Jest.

How this could be expanded to support different plugin needs?

  • Right now the only hook that I created is testRunComplete and shouldRunTestSuite. If needed we could have hooks like onTestSuiteStart, onTestSuiteComplete, etc. This will allow us to have well defined, extension points.

  • Hooks with a return value can also be supported, for example shouldRunTestSuite(...): Promise<boolean>.

Click here to see original proposal

[WIP] Plugin Proposal

cc: @cpojer, @orta , @thymikee

It is still in a super rough spot, but I think this architecture might allow us to have a simple yet powerful API.

Context and current state of proposal

This PR is still dirty and WIP.

  • It makes each and every row in the current usage a watch plugin. For example UpdateSnapshotsInteractivePlugin, TestPathWatchPlugin or even 'trivial' stuff like QuitPlugin.
  • It removes all the knowledge of what each key is responsible of doing from watch.js.

Pending items

  • Given that I was trying to make sure that every of the existing functionality could be "migrated" to a plugin. I temporarily removed the ability to add external plugins, I need to add that back
  • Keep making the rest of the keys plugins.
  • Add flow types.
  • Remove unused code and a do lot of cleanup.
  • Fix failing tests.
  • Refine the API
  • Add docs

Which files should I focus on to better understand the proposal?

Plugin API

WatchPlugin {
  showPrompt: (globalConfig: GlobalConfig, updateConfigAndRun: Function) => Promise<*>,
  getUsageRow: (globalConfig: GlobalConfig, hasFailedSnapshots: boolean) => ?{
    key: number,
    prompt: string,
    hide?: boolean,
  },
  onData: (value: string) => void,

  registerHooks: (hooks: JestHooks) => void,
}

Currently, there are two parts of the plugin system: which maybe could benefit from better names.

First part: Watch mode usage interactions.

  • showPrompt(...): This gives control to the plugin to manage test execution. It provides a updateConfigAndRun function and returns a promise that can be resolved when the control needs to be given back to Jest.
  • onData(...): A helper function for capturing keystrokes when the plugin is active.
  • getUsageRow(...): Returns the usage row information, and allows for dynamically updating the text, hiding, etc. Example: { key: S, prompt: 'filter by @tags' }

Second part: registerHooks

NOTE: This is my first time using webpack/tapable, so let me know if I'm thinking it at the completely wrong level.

This is how WatchPlugins will hook into different parts of the Jest. It is powered by webpack/tapable which is what Webpack uses for their plugin system. This is inspired by how Webpack plugins work.

How this could be expanded to support different plugin needs?
  • Right now the only hook that I created is testRunComplete.f needed we could have hooks like testSuiteStart, testSuiteComplete, testPass, testFail by leveraging webpack/tapable. This will allow us to have well defined, extension points.
  • Hooks with a return value can also be supported, for example shouldRunTest(...): boolean or shouldRunTestSuite(...): boolean.

Improvements & Questions

  • It might be a good idea to wrap the tapable API so that the user does not have to worry about tap, tapPromise, etc. Also to prevent from calling hooks.
  • There might be some abstractions that need to be created to better support plugins.
  • Why an instance? Because it makes it easy for plugins to keep state.
  • I understand that there are still a lot of unknown unknowns.


let hasExitListener = false;

const internalPlugins = [
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will get cleaned up

};

const watchPlugins: Array<WatchPlugin> = internalPlugins
.map(pluginPath => require(pluginPath).default)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All of this will get cleaned up

.map(p => p.getUsageRow(globalConfig, hasSnapshotFailure))
.map(p => (p ? p.key : undefined))
.filter(p => p !== undefined)
.sort((a, b) => a - b)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean up

@cpojer
Copy link
Member

cpojer commented Jan 26, 2018

Woah, this is extremely exciting, I didn't know your changes were going to be so extensive. This really makes Jest's watch mode a general purpose system that we should extract into jest-watch (in follow-up PRs). I would love to use this for Metro's dev server as well (cc @rafeca, @arcanis).

I think the fact that you are able to support all existing features of watch mode with plugins now is a good sign that this API design solves our needs well. I would recommend going with that, polishing it up, and shipping it before we consider further changes. This PR unlocks a new set of capabilities that we haven't thought of yet so I wanna be cautious that we don't waste too much time bikeshedding on the API. We can always change it later.

The only concern I have is around tapable. I do not think it provides value and think we can replicate it's functionality within Jest. Do you think that would be ok?

@rogeliog
Copy link
Contributor Author

rogeliog commented Jan 26, 2018

@cpojer hehe awesome, I'll start by polishing it.

I like the power that tapable provides, but I agree that it might be too early to justify it.

@rogeliog rogeliog changed the title [WIP][Proposals] Watch plugins API [Proposals] Watch plugins API Feb 2, 2018
@rogeliog rogeliog requested a review from SimenB February 2, 2018 17:24
@SimenB
Copy link
Member

SimenB commented Feb 5, 2018

This has conflicts, and failing appveyor CI (I think that one just needs a restart, though)

Copy link
Member

@SimenB SimenB left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly just questions, this is looking really awesome!

});

it('prevents Jest from handling keys when active and returns control when end is called', () => {
// TODO: Fix or remove before merging
xit('prevents Jest from handling keys when active and returns control when end is called', () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems like something we'd want, isn't it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, this is one of the pending items that I have at the top.

['t', 'e', 's', 't']
await nextTick();

[('t', 'e', 's', 't')]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why the parens? this makes it just return the last one, and we get an array of ['t'] (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Comma_Operator)

Copy link
Contributor Author

@rogeliog rogeliog Feb 6, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤦‍♂️ 🤦‍♂️ 🤦‍♂️ I have no idea how that happened

? chalk.dim('test name ') + chalk.yellow('/' + testNamePattern + '/')
: null,
]
.filter(f => !!f)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.filter(Boolean)? Or just remove the double bang, not really needed (as you check against null)

Copy link
Contributor Author

@rogeliog rogeliog Feb 6, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I just moved that from watch.js, changing it now

.filter(f => !!f)
.join(', ');

const messages = ['\n' + chalk.bold('Active Filters: ') + filters];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why in an array? Did you mean to concat with filters instead of joining on line 27?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this doesn't seem to be needed, same as above just moved to from watch.js

showPrompt(): Promise<void> {
this._stdout.write('\n');
process.exit(0);
return Promise.resolve();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can just mark the function as async and drop the return, although the transpiled code is pretty ugly. Up to to you 🙂

(would be cool if babel just wrapped the return value in Promise.resolve if there were no awaits in an async function)

testNamePattern: '',
testPathPattern: '',
});
startRun(globalConfig);
break;
case KEYS.F:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be a plugin as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, this one should be a plugin... I can do it in this PR of as follow up PRs

Copy link
Contributor Author

@rogeliog rogeliog Feb 6, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also want to wait for it because it will require some refactor

.map(p => p.getUsageRow(globalConfig, hasSnapshotFailure))
.filter(usage => usage && !usage.hide)
// $FlowFixMe a and b will not be null or undefined
.sort((a, b) => a.key - b.key);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like q being at the bottom, no biggie, though 🙂


const thirdPartyPlugins = watchPlugins
.slice(INTERNAL_PLUGINS.length)
.map(p => p.getUsageRow(globalConfig, hasSnapshotFailure))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hasSnapshotFailure seems really specific, IDK if there's a better place to stick it, though

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

testResults array would maybe make more sense, possible with some properties like hasSnapshotFailure: boolean on it for ease of use

Copy link
Contributor Author

@rogeliog rogeliog Feb 5, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree!

This is something that I was thinking over the weekend, I'm glad that you felt the same way!

My thoughts are that getUsageRow() should only take in the globalFilter.

Stuff like hasSnapshotFailure (which as you mentioned only a small chunk of plugins will use) can be accessed from thetestRunComplete hook.

hooks.testRunComplete(results => {
  this._hasSnapshotFailure = !!results.snapshot.failure;
});

Unrelated comment

Which makes me think that we could take this approach even further and not even pass globalConfig as a param of getUsageRow, but instead if the plugin needs access to the most up to date globalConfig it can get it from a globalConfigChanged hook

hooks.globalConfigChanged(globalConfig => {
  this._globalConfig = globalConfig;
});

What are your thoughts on this?

onCancelPatternPrompt,
{header: activeFilters(globalConfig)},
);
break;
case KEYS.QUESTION_MARK:
break;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not for this PR, but should this break be here? Seems to make sense for w and ? to behave the same here.

plugin =>
chalk.dim(' \u203A Press') +
' ' +
// $FlowFixMe plugin will be present
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can't we fix the type definition so the ignore isn't needed?

@rogeliog rogeliog changed the title [Proposals] Watch plugins API [Proposal] Watch plugins API Feb 6, 2018
@rogeliog
Copy link
Contributor Author

rogeliog commented Feb 6, 2018

I'm still worried about how hard it is going to be to communicate the current API for watch plugins.

  • Should it be an instantiable object that extendsWatchPlugin?
  • Should it be an instantiable object that implements all the methods?
  • We can also take an approach similar to Webpack, and do something like "a watch plugin is an instantiable object with an apply method" and then we inject everything through jestHooks, even getUsageRow and showPrompt

cc: @cpojer @SimenB

@codecov-io
Copy link

Codecov Report

Merging #5399 into master will increase coverage by <.01%.
The diff coverage is 73.39%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master    #5399      +/-   ##
==========================================
+ Coverage   62.23%   62.24%   +<.01%     
==========================================
  Files         205      212       +7     
  Lines        6932     6955      +23     
  Branches        4        3       -1     
==========================================
+ Hits         4314     4329      +15     
- Misses       2617     2625       +8     
  Partials        1        1
Impacted Files Coverage Δ
packages/jest-cli/src/run_jest.js 50% <0%> (-2.55%) ⬇️
packages/jest-cli/src/plugins/test_path_pattern.js 100% <100%> (ø)
...ackages/jest-cli/src/lib/active_filters_message.js 100% <100%> (ø)
packages/jest-cli/src/plugins/test_name_pattern.js 100% <100%> (ø)
packages/jest-cli/src/plugins/update_snapshots.js 100% <100%> (ø)
packages/jest-cli/src/plugins/quit.js 33.33% <33.33%> (ø)
...st-cli/src/plugins/update_snapshots_interactive.js 37.5% <37.5%> (ø)
packages/jest-cli/src/watch_plugin.js 50% <50%> (ø)
packages/jest-cli/src/jest_hooks.js 55.55% <55.55%> (ø)
packages/jest-cli/src/watch.js 73.52% <83.33%> (+2.9%) ⬆️
... and 11 more

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update bda02e6...3dc8c06. Read the comment docs.

@cpojer cpojer merged commit 4561959 into jestjs:master Feb 6, 2018
@cpojer
Copy link
Member

cpojer commented Feb 6, 2018

Awesome, I hit the merge button but I do think there are some follow-ups, like you mentioned yourself.

getUsageRow: could we rename this and change behavior? I don't think row is a great name. Could we also change it instead of returning a hide field to return null or an object, where the presence of an object means it is activated and null means it is disabled? I propose naming it something like getUsageInfo or getUsageData but there may be better names.

What's your concrete next step? I think the current API looks good as a start and we can bring back the typeahead plugin with this. Are you planning on making this happen? :)

@rickhanlonii
Copy link
Member

Could consider:

WatchPlugin {
  apply: (hooks: JestHookSubscriber) => void,
  run: (globalConfig: GlobalConfig, updateConfigAndRun: Function) => Promise<*>,
  onKey: (value: string) => void,
  getPrompt: (globalConfig: GlobalConfig, hasFailedSnapshots: boolean) => ?{
    key: number,
    prompt: string,
  },
}
  • apply - Apply hooks into different parts of Jest.
  • run - Gives control to the plugin to manage test execution.
  • onKey - Handle keystrokes when the plugin is running.
  • getPrompt - Returns the key prompt information.

I took run and apply from the webpack plugin names, and onKey from typical on handler naming and getPrompt as a literal description of its responsibility. I admit that Key and prompt may be too specific to the current use case so those could be Data and Usage respectively

@rogeliog rogeliog deleted the simpler-plugins branch February 6, 2018 18:20
@rogeliog rogeliog mentioned this pull request Feb 6, 2018
7 tasks
@rogeliog
Copy link
Contributor Author

rogeliog commented Feb 6, 2018

@cpojer I just created #5478 to keep track of that.

@rickhanlonii I like that idea! What do you think @cpojer?

@cpojer
Copy link
Member

cpojer commented Feb 6, 2018

I'm on board with that. Do you wanna change it?


const messages = ['\n' + chalk.bold('Active Filters: ') + filters];

return messages.filter(message => !!message).join(delimiter);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is still weird - messages will always be an array of size 1

@github-actions
Copy link

This pull request has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs.
Please note this issue tracker is not a help forum. We recommend using StackOverflow or our discord channel for questions.

@github-actions github-actions bot locked as resolved and limited conversation to collaborators May 12, 2021
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants