-
Notifications
You must be signed in to change notification settings - Fork 1.1k
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
shell: Even more types #21425
shell: Even more types #21425
Conversation
This time I tried out fully typing two largish files without any intermediate "relaxed" steps. It's fun but also quite intense. |
pkg/shell/nav.tsx
Outdated
@@ -366,6 +412,7 @@ | |||
target: { | |||
host: current_machine.address, | |||
path: current_machine_manifest_items.items.apps.path, | |||
hash: "/", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could add a test for this. This is about the "Edit" button in the "Applications" section. It only appears when at least one applications is installed.
pkg/shell/util.tsx
Outdated
@@ -91,6 +91,16 @@ export interface ManifestItem { | |||
keywords: ManifestKeyword[]; | |||
} | |||
|
|||
export interface ManifestParentSection { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm... we could make some functions like manifest_parent_section(m: Manifest): ManifestParentSection
which would ide all the "unsafe" JSON manipulations.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The whole Manifest stuff should go to its own file, actually.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Like, helpers which do type validation? what would they do on errors then?
This feels simultaneously too strict (hard to handle errors at that level, as these are effectively user-provided files) and too specific (we parse JSON everywhere, and should generalize the mechanics).
Are you aware of the bridge's jsonutil? That may be a more adequate way to parse JSON with strict typing, and the API would then also include default values.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is big WIP commit now that implements one approach: validating all manifests as they are received from the bridge. It's quite a bit of code, and it totally wants to be autogenerated, but I think that's not worth it just yet. (Also, it's not enough code to justify dragging in some toolkit/framework for this, unless we find a really nice one.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Addendum: the validation is pretty fine-grained: If you forget the label of a docs entry, only that one docs entry is omitted. If you use a string for the weight of a keyword instead of a number, you get an error message but the shell continues with a weight of 0.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks! Nice that this found some bugs! Most of the comments are nice, bikeshedding, praise, and questions. But I have serious concerns about using as
on externally read data, that feels just wrong and dangerous to me.
pkg/shell/util.tsx
Outdated
@@ -91,6 +91,16 @@ export interface ManifestItem { | |||
keywords: ManifestKeyword[]; | |||
} | |||
|
|||
export interface ManifestParentSection { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Like, helpers which do type validation? what would they do on errors then?
This feels simultaneously too strict (hard to handle errors at that level, as these are effectively user-provided files) and too specific (we parse JSON everywhere, and should generalize the mechanics).
Are you aware of the bridge's jsonutil? That may be a more adequate way to parse JSON with strict typing, and the API would then also include default values.
pkg/shell/util.tsx
Outdated
const manifest_section = (manifest[section] || {}) as ManifestSection; | ||
Object.entries(manifest_section).forEach(([prop, info]) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This cast is weird and IMHO detrimental. This isn't a case of "ts can't figure it out", it's literally "this could be anything as it's an user-provided (or at least user-customizable) external file on disk". So even after the as
you can in no way be sure that you actually have a ManifestSection
object, and the type checker will hide errors such as "component
is actually an int". I.e. you'll get runtime errors because you forgot to check the type. That's the opposite of what typing should achieve.
That's why I think for this case something like json_get_object(manifest, section, {})
would be much cleaner, more robust, and also more general here. The original code was actually fine already, but the helper functions could produce proper console warnings that help admins to figure out why their manifest customizations are being ignored.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This cast is weird and IMHO detrimental.
Yes... it is keeping the status quo, but we should try to do better eventually, agreed.
I.e. you'll get runtime errors because you forgot to check the type. That's the opposite of what typing should achieve.
Hmm, I would call this input validation rather than static typing. Currently, we validate manifest input by just pretending it's all valid and let the shell crash when it isn't. (And we have been fine with that since forever.) Static typing makes this obvious, but by itself is not enough to improve it.
We would have to write new code to do real input validation. I was hoping there is a way to get TypeScript to do that. Here are some good pointers: https://stackoverflow.com/questions/33800497/check-if-an-object-implements-an-interface-at-runtime-with-typescript
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, I would call this input validation rather than static typing
Yes, exactly. And input validation is a runtime thing, the type checker can't help with that. We either need some "JSON schema validation" thing, or just pick out the values individually with the expected type and default (which I personally favor -- it's explicit, tsc can help us get it right, and we don't reject manifests with wrong keys wholesale, which would be a behaviour changes).
pkg/shell/util.tsx
Outdated
if (comp && comp.parent && comp.parent.component) | ||
component = comp.parent.component as string; | ||
if (comp && comp.parent) { | ||
const parent = comp.parent as ManifestParentSection; | ||
if (parent.component) | ||
component = parent.component; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the same spirit as above, I think componennt = comp?.parent?.component
would be both cleaner and more robust. Alternatively, wrap this into some json_get_object()
helper with warning messages -- i.e. "nonexisting" is fine (default null
) but "exists but wrong type" should warn.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So you are asking for "The Shell should validate the schema of all manifests". That's a good idea. We could have done that without TypeScript all these years, but we didn't. A bug in a manifests is like a bug in JavaScript, and should be found and fixed by the developer.
And while TypeScript might benefit from it once schema validation is done (think generating TS types from JSON schemas), I don't think TypeScript by itself is gonna help. All it does it point out our sins...
So, can I ask to keep the manifest schema validation yak out of this PR? :-)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So, can I ask to keep the manifest schema validation yak out of this PR?
I am fine with that. I am not fine with pretending that everything is good here and papering over the issue with as
-- it really isn't, and tsc rightfully complains. So if it's too hard to fix these places, then let's add something greppable and obvious like @ts-expect-error
. (Because as
does have valid use cases, and isn't greppable)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Summary from meeting: My gut feeling/slight preference is the get_json_string()
kind of accessor on usage, but I agree that for common keys we may rather want some more centralized "schema validation" thing, with a constructor. My minimum requirement here would be to add a // HACK: unsafe "as"
or similar so that we can find these again.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is now import_Manifests
and import_ShellManifest
that concentrate this issue in one place.
I still want to fully type our Manifest data structures and then access them from our code without any additional accessors. I.e., I want to do this:
interface Foo {
name: string;
}
function bar(f: Foo) {
console.log("Hi", f.name.toUpper());
}
bar(import_Foo(get_some_json());
and not
function bar(f: JsonValue) {
console.log("Hi", json_as_string(json_get(f, "name"), "<unknown>").toUpper());
}
bar(get_some_json());
This needs a import_Foo
function, which is not as trivial to write as the accessors, but I still want it for Christmas! :-)
pkg/shell/topnav.tsx
Outdated
superuser_connection: cockpit.DBusClient | null = null; | ||
superuser: cockpit.DBusProxy | null = null; | ||
|
||
handleClickOutside = () => this.setState({ menuOpened: false, docsOpened: false }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Defining this outside of the ctor at the class level looks weird. What would this
even be here? I suppose the this
gets resolved at call time even for an arrow, but then wouldn't that be the this
of the call environment instead of the TopNav instance?
The tests prove it works -- my eyes just trip over it. JS has too much magic!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Defining this outside of the ctor at the class level looks weird.
I guess... I moved it back into the constructor.
pkg/shell/topnav.tsx
Outdated
if (this.state.osRelease.DOCUMENTATION_URL) | ||
docItems.push(<DropdownItem key="os-doc" to={this.state.osRelease.DOCUMENTATION_URL} target="blank" rel="noopener noreferrer" icon={<ExternalLinkAltIcon />}> | ||
if (this.state.osRelease?.DOCUMENTATION_URL) | ||
docItems.push(<DropdownItem key="os-doc" to={this.state.osRelease.DOCUMENTATION_URL as string} target="blank" rel="noopener noreferrer" icon={<ExternalLinkAltIcon />}> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I could tolerate this, as runtime errors here aren't catastrophic. But DOC_URL is optional and can well be undefined
(or someone could valalize their os-release to be an object or int). This really shouldn't become a pattern, something like json_get_str()
would be much better here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have changed this to if (typeof ...DOC_URL == "string")
, then the "as" is not necessary. Thanks for pointing these out! I will be much more careful with "as" in the future.
pkg/shell/topnav.tsx
Outdated
{cockpit.format(_("$0 documentation"), this.state.osRelease.NAME)} | ||
</DropdownItem>); | ||
|
||
const shell_manifest = (cockpit.manifests.shell || {}) as ShellManifest; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the same "unvalidated user-provided document" issue as above. We cannot claim that this is a valid ShellManifest. The typechecker will then falsely claim that our code is correct, while it is missing runtime type checks.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This has been de-assed with import_ShellManifest
.
this.setState((prevState: TopNavState) => { | ||
return { docsOpened: !prevState.docsOpened }; | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
btw, => ({ key: value })
is a nicer way to return an object in an arrow. Avoids the extra { return };
wrapper.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, subjective! :-) I am always amused that people complain about all the parentheses in Lisp and then produce code with )})}}};}};
in it. :-)
pkg/shell/shell-modals.tsx
Outdated
const manifest = cockpit.manifests.shell || { }; | ||
const manifest = (cockpit.manifests.shell || { }) as ShellManifest; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Dito
35a2d1e
to
baa20a0
Compare
5dce77b
to
5890f70
Compare
5890f70
to
284b65b
Compare
pkg/shell/util.tsx
Outdated
@@ -202,11 +204,11 @@ export class CompiledComponents { | |||
} | |||
} | |||
|
|||
export function compile_manifests(manifests?: Manifests): CompiledComponents { | |||
export function compile_manifests(manifests?: { [pkg: string]: Manifest }): CompiledComponents { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a leftover and should be removed. Manifests
is still a real type.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
284b65b
to
2ffbb98
Compare
About the validation stuff: I think we shouldn't do it in machines.js, but rather in CompiledComponents. And ShellState should keep a CompiledComponents per machine that is only updated when the machine state changes. That way we don't do validation on each shell state change. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks! I'm happy with this direction now. I leave it up to you if you want to land the stuff until the WIP and do that separately, or work it out all together here. I still have a few comments, but just thinking aloud/Kleinkram.
pkg/shell/nav.tsx
Outdated
} else if (cur instanceof HTMLElement) { | ||
cur.click(); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For our own sanity, this could have an else console.error()
so that tests fail if we forget something here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
// NOTE - this is defined in pkg/lib/notifications.js and should be | ||
// imported from there once that file has been typed. | ||
// | ||
export interface PageStatus { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OOI -- could we just create a pkg/lib/notifications.d.ts and put that there, as an intermediate step? Or would that require moving more chairs around the deck?
(Not blocking or a veto, just learning)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we could!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, let's port it to TS right away, separately.
pkg/shell/manifests.ts
Outdated
function import_string(val: JsonValue, fallback?: string): string { | ||
if (typeof val == "string") | ||
return val; | ||
validation_error(`Not a string: ${JSON.stringify(val)}`); | ||
if (fallback !== undefined) | ||
return fallback; | ||
throw new ValidationError(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is very close to what I had in mind, except that I'd handle "absent" (undefined) differently from "wrong type" -- after all, almost every field in a manifest is optional, and we shouldn't spew console errors for these.
(and maybe fallback
→ default
)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, there is some dicussion to be had. My idea here is that default values are not handled at all during validation. They are provided by the code that works with these data structures. The fallbacks here are only for the case were a value is of the wrong type, but we want to hobble along anyway.
The only fallbacks in use are:
- something is supposed to be an array but is something else, then we continue with an empty array (after emitting the error)
- something is supposed to be a dictionary but is something else, then we continue with an empty dict (after emitting an error)
- when the "weight" of a keyword or the order of a menu entry is not a number, then we use 0 (after emitting an error)
- when the "translate" of a keyword is not a boolean, then we continue with "false" (after emitting an error)
I think we could remove the last two cases actually. Using { weight: "heavy" }
as a keyword should probably disqualify (and skip) the whole entry.
Let's make a separate PR for this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should think long and hard about how callers want to use it -- i.e. if a manifest override says e.g. preload: 1
or doesn't specify preload, then it seems to me the only difference should be that the former causes a console warning. But in both cases the caller (specifying a reasonably default such as []
or null
) would get that default in both cases?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In other words, having two fallbacks (one for "wrong type" and another one for "absent") feels like accidental complexity and prone to confusion to me, WDYT?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, I agree.
The router will always use a string when jumping, and all functions are actually happy with a partial Location as input. This let's us remove a "as" type cast.
2ffbb98
to
4eb85af
Compare
Collect all definitions into one place and prepare for proper validation.
The cockpit.spawn function takes only two arguments (unlike cockpit.script). Location.reload() doesn't take any arguments, the "force_reload" argument is a Firefox extension. There is no "this.onSelect" in CockpitNav.
4eb85af
to
9c19728
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks! This seems to break TestMultiMachineKeyAuth.testBasic, but otherwise LGTM now! (with lots of possible follow-ups..)
I think this is actually another case of the slow opening animation of the host switcher clashing with the real mouse clicks. It might be time to share open_host_switcher between the tests. |
cur.click(); | ||
} else { | ||
console.error("Active element not a HTMLElement"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This added line is not executed by any test.
if (begin < 0) | ||
begin = all.length - 1; | ||
all[begin].focus(); | ||
} else { | ||
let i = all.findIndex(item => item === cur); | ||
i += step; | ||
if (i < 0 || i >= all.length) | ||
document.querySelector("#" + sel + " .pf-v5-c-text-input-group__text-input").focus(); | ||
document.querySelector<HTMLElement>("#" + sel + " .pf-v5-c-text-input-group__text-input")?.focus(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This added line is not executed by any test.
if (g.action) | ||
this.props.jump(g.action.target); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These 2 added lines are not executed by any test.
} | ||
|
||
const manifest = cockpit.manifests.shell || { }; | ||
const manifest = import_ShellManifest(cockpit.manifests.shell || { }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This added line is not executed by any test.
@@ -140,8 +144,9 @@ | |||
<MenuList> | |||
{ | |||
(() => { | |||
const filteredLocales = Object.keys(manifest.locales || {}) | |||
.filter(key => !searchInput || manifest.locales[key].toLowerCase().includes(searchInput.toString().toLowerCase())); | |||
const locales = manifest.locales || {}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This added line is not executed by any test.
docItems.push(<DropdownItem key="os-doc" to={this.state.osRelease.DOCUMENTATION_URL} target="blank" rel="noopener noreferrer" icon={<ExternalLinkAltIcon />}> | ||
{cockpit.format(_("$0 documentation"), this.state.osRelease.NAME)} | ||
</DropdownItem>); | ||
|
||
const shell_manifest = import_ShellManifest(cockpit.manifests.shell || {}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This added line is not executed by any test.
// global documentation for cockpit as a whole | ||
(cockpit.manifests.shell?.docs ?? []).forEach(doc => { | ||
(shell_manifest.docs ?? []).forEach(doc => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This added line is not executed by any test.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, thanks! I'm happy to refactor the helper into testlib.py after this.
No description provided.